Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ jobs:
with:
ref: ${{ needs.prepare-build.outputs.build_ref }}
fetch-depth: 1
# Targeted init (not `submodules: true`) so we skip the large tauri-cef
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves tinyagents from vendor/tinyagents.
- name: Init tinyagents submodule
run: git submodule update --init vendor/tinyagents
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@ jobs:
with:
ref: ${{ needs.prepare-build.outputs.build_ref }}
fetch-depth: 1
# Targeted init (not `submodules: true`) so we skip the large tauri-cef
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves tinyagents from vendor/tinyagents.
- name: Init tinyagents submodule
run: git submodule update --init vendor/tinyagents
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (no push)
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,4 @@ distribution.cer
# Release note previews
CHANGELOG.preview.md
*.profraw
*.diff
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
[submodule "app/src-tauri/vendor/tauri-plugin-notification"]
path = app/src-tauri/vendor/tauri-plugin-notification
url = https://github.com/tinyhumansai/tauri-plugin-notification.git
[submodule "vendor/tinyagents"]
path = vendor/tinyagents
url = https://github.com/tinyhumansai/tinyagents
4 changes: 1 addition & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ crate-type = ["rlib"]
tinyplace = "1.0.1"
# tinyflows — host-agnostic workflow engine (typed node graph → validate → compile →
# run on tinyagents). Powers the "Workflows" feature via the seam in
# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.3 transitively
# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.5 transitively
# (same version openhuman already uses — no conflict). Published on crates.io.
tinyflows = "0.3"
# TinyAgents — Rust LLM orchestration framework (LangGraph/LangChain-style):
Expand All @@ -56,7 +56,7 @@ tinyflows = "0.3"
# aligned to 0.40, avoiding duplicate `links = "sqlite3"` native bindings.
# Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the
# migration re-points those rows to the crate checkpointer.
tinyagents = { version = "1.3", features = ["sqlite"] }
tinyagents = { version = "1.5.0", features = ["sqlite"] }
# TokenJuice code compressor — AST-aware signature extraction. Optional (C build)
# behind the default `tokenjuice-treesitter` feature; disabling it falls back to
# the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs.
Expand Down Expand Up @@ -230,7 +230,7 @@ fantoccini = { version = "0.22.0", optional = true, default-features = false, fe
serde-big-array = { version = "0.5", optional = true }
pdf-extract = "0.10"
# WhatsApp Web — upstream `whatsapp-rust` 0.5. Its Diesel-backed sqlite-storage
# feature links sqlite3 separately from rusqlite 0.40, so the TinyAgents 1.3
# feature links sqlite3 separately from rusqlite 0.40, so the TinyAgents 1.5
# baseline compiles this provider against wacore's in-memory Backend until a
# rusqlite-backed durable store lands.
whatsapp-rust = { version = "0.5", optional = true, default-features = false, features = ["tokio-runtime"] }
Expand Down Expand Up @@ -331,6 +331,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] }
# See: https://github.com/tinyhumansai/openhuman/issues/273
[patch.crates-io]
whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" }
# TinyAgents is vendored as a git submodule (pinned at the released tag) so
# migration work can change the SDK source in-tree, test it against OpenHuman
# immediately, and PR the diff upstream from the submodule. Keep the submodule
# version in lockstep with the `tinyagents` requirement above. After cloning:
# `git submodule update --init vendor/tinyagents` (worktrees included).
tinyagents = { path = "vendor/tinyagents" }

# Emit just enough DWARF in release builds for Sentry to symbolicate Rust
# panics + render surrounding source lines. `line-tables-only` keeps the
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ WORKDIR /build

# Cache dependencies — copy only manifests first
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
# Vendored TinyAgents SDK (git submodule; [patch.crates-io] points here, so
# the dep-cache build below already resolves it). CI must init the submodule
# before docker build — see the "Init tinyagents submodule" steps in
# release-production.yml / release-staging.yml.
COPY vendor/ vendor/
# Create a dummy src to build deps
RUN mkdir -p src && \
echo 'fn main() {}' > src/main.rs && \
Expand Down
4 changes: 1 addition & 3 deletions app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ e2e-test-support = ["openhuman_core/e2e-test-support"]
# whisper.cpp to use MSVC's static runtime (/MT), matching CEF and avoiding
# LNK2038/LNK1169 CRT conflicts on Windows.
whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" }
# TinyAgents vendored submodule (repo-root vendor/tinyagents, pinned at the
# released tag) — same patch as the root Cargo world so both resolve the
# in-tree SDK source. `git submodule update --init vendor/tinyagents` first.
tinyagents = { path = "../../vendor/tinyagents" }

# CEF support lives on the `feat/cef` branch of tauri-apps/tauri. We carry our
# own fork at tinyhumansai/tauri-cef on `feat/cef-notification-intercept` which
Expand Down
5 changes: 4 additions & 1 deletion app/src-tauri/src/cef_stale_reap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,10 @@ mod tests {

#[test]
fn marker_is_fresh_bounds_on_age() {
assert!(marker_is_fresh(Some(Duration::from_secs(0)), MARKER_MAX_AGE));
assert!(marker_is_fresh(
Some(Duration::from_secs(0)),
MARKER_MAX_AGE
));
assert!(marker_is_fresh(Some(MARKER_MAX_AGE), MARKER_MAX_AGE));
assert!(!marker_is_fresh(
Some(MARKER_MAX_AGE + Duration::from_secs(1)),
Expand Down
15 changes: 15 additions & 0 deletions app/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Accounts from './pages/Accounts';
import Brain from './pages/Brain';
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
import Feedback from './pages/Feedback';
import FlowsPage from './pages/FlowsPage';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
import Onboarding from './pages/onboarding/Onboarding';
Expand Down Expand Up @@ -94,6 +95,20 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
}
/>

{/* Workflows — the `flows::` domain's discoverable list hub (issue
B5a). Distinct from the legacy SKILL.md `/workflows/*` Skill routes
below (create/run) and their `/workflows` → `/settings/automations`
back-compat redirect, which stay untouched. The canvas (B5b) and
agent-proposal surface (B4) are separate, later work. */}
<Route
path="/flows/*"
element={
<ProtectedRoute requireAuth={true}>
<FlowsPage />
</ProtectedRoute>
}
/>

{/* Back-compat: /activity and /intelligence → settings notifications page. */}
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
Expand Down
4 changes: 4 additions & 0 deletions app/src/components/EmptyStateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ interface EmptyStateCardProps {
description: string;
actionLabel?: string;
onAction?: () => void;
/** `data-testid` for the action button, so callers can target it distinctly from other same-labeled buttons on the page. */
actionTestId?: string;
footer?: ReactNode;
className?: string;
}
Expand All @@ -16,6 +18,7 @@ const EmptyStateCard = ({
description,
actionLabel,
onAction,
actionTestId,
footer,
className = '',
}: EmptyStateCardProps) => {
Expand All @@ -30,6 +33,7 @@ const EmptyStateCard = ({
{actionLabel && onAction ? (
<button
type="button"
data-testid={actionTestId}
onClick={onAction}
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 border border-primary-100 dark:border-primary-800/50 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20">
<span>{actionLabel}</span>
Expand Down
116 changes: 116 additions & 0 deletions app/src/components/chat/WorkflowProposalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import { WorkflowProposalCard } from './WorkflowProposalCard';

// Echo i18n keys so we can assert on the stable key string.
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));

const mockCreateFlow = vi.fn();
vi.mock('../../services/api/flowsApi', () => ({
createFlow: (...args: unknown[]) => mockCreateFlow(...args),
}));

const mockDispatch = vi.fn();
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));

function proposal(partial: Partial<WorkflowProposal> = {}): WorkflowProposal {
return {
name: 'Daily standup summary',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: {
trigger: 'schedule: 0 9 * * *',
steps: [
{ kind: 'agent', name: 'Summarize', config_hint: "Summarize yesterday's messages" },
{ kind: 'tool_call', name: 'Post to Slack' },
],
},
...partial,
};
}

describe('WorkflowProposalCard', () => {
beforeEach(() => {
mockCreateFlow.mockReset().mockResolvedValue({ id: 'f1', name: 'Daily standup summary' });
mockDispatch.mockReset();
});

it('renders the name, trigger, and steps with node-kind badges', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
expect(screen.getByText('Daily standup summary')).toBeInTheDocument();
expect(screen.getByText('schedule: 0 9 * * *')).toBeInTheDocument();
expect(screen.getByText('Summarize')).toBeInTheDocument();
expect(screen.getByText('Post to Slack')).toBeInTheDocument();
expect(screen.getByText('agent')).toBeInTheDocument();
expect(screen.getByText('tool_call')).toBeInTheDocument();
expect(screen.getAllByTestId('workflow-proposal-step-kind')).toHaveLength(2);
});

it('has the expected root test id', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
expect(screen.getByTestId('workflow-proposal-card')).toBeInTheDocument();
});

it('saves via createFlow with the right args and clears optimistically', async () => {
const p = proposal();
render(<WorkflowProposalCard threadId="t1" proposal={p} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() =>
expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph, p.requireApproval)
);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});

it('shows a loading state while saving', async () => {
let resolveCreate!: (value: unknown) => void;
mockCreateFlow.mockReturnValueOnce(
new Promise(resolve => {
resolveCreate = resolve;
})
);
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() => expect(screen.getByText('chat.flowProposal.saving')).toBeInTheDocument());
resolveCreate({ id: 'f1' });
});

it('surfaces an error and stays mounted when createFlow fails', async () => {
mockCreateFlow.mockRejectedValueOnce(new Error('boom'));
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() => expect(screen.getByText(/chat\.flowProposal\.error/)).toBeInTheDocument());
// Not cleared on failure.
expect(mockDispatch).not.toHaveBeenCalled();
});

it('dismiss clears the proposal without calling createFlow', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.dismiss'));
expect(mockCreateFlow).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledTimes(1);
});

it('renders a fallback message when there are no non-trigger steps', () => {
render(
<WorkflowProposalCard
threadId="t1"
proposal={proposal({ summary: { trigger: 'manual', steps: [] } })}
/>
);
expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument();
});

it('shows the require-approval hint only when requireApproval is true', () => {
const { rerender } = render(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: true })} />
);
expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument();

rerender(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: false })} />
);
expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument();
});
});
Loading
Loading