{run.steps.map((step, idx) => (
-
+ 0 ? normalizeItems(run.steps[idx - 1].output) : undefined}
+ />
))}
)}
diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx
index 35cf3282f..d2d55bd8a 100644
--- a/app/src/components/flows/FlowRunsDrawer.tsx
+++ b/app/src/components/flows/FlowRunsDrawer.tsx
@@ -33,6 +33,7 @@ import {
FLOW_RUN_STATUS_ACCENT,
FLOW_RUN_STATUS_DOT,
FLOW_RUN_STATUS_KEY,
+ type FlowRepairRequest,
FlowRunInspectorDrawer,
} from './FlowRunInspectorDrawer';
@@ -56,6 +57,8 @@ interface Props {
/** Flow name for the drawer title, when known. */
flowName?: string;
onClose: () => void;
+ /** "Fix with agent" (Phase 5c) — forwarded to the inspector for failed runs. */
+ onFixWithAgent?: (request: FlowRepairRequest) => void;
}
/**
@@ -63,7 +66,7 @@ interface Props {
* unconditionally and just flip `flowId` (same convention as
* `FlowRunInspectorDrawer`/`SubagentDrawer`).
*/
-export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
+export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Props) {
const { t } = useT();
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(false);
@@ -209,7 +212,11 @@ export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
{selectedRunId && (
- setSelectedRunId(null)} />
+ setSelectedRunId(null)}
+ onFixWithAgent={onFixWithAgent}
+ />
)}
>
);
diff --git a/app/src/components/flows/FlowTemplateGallery.tsx b/app/src/components/flows/FlowTemplateGallery.tsx
new file mode 100644
index 000000000..9e9553ea3
--- /dev/null
+++ b/app/src/components/flows/FlowTemplateGallery.tsx
@@ -0,0 +1,81 @@
+/**
+ * FlowTemplateGallery (Phase 4c) — the curated-template picker. Presentational
+ * only: it renders the bundled `FLOW_TEMPLATES` as selectable cards and calls
+ * `onSelect` with the chosen template. The *create* side effect
+ * (`flows_create` + navigate into the canvas) lives in the caller
+ * (`NewWorkflowModal` / `FlowsPage`), so this component is trivially testable
+ * and reusable both inside the new-workflow modal and inline on the Workflows
+ * empty state.
+ *
+ * Display strings are i18n'd via the `templateNameKey`/`templateDescriptionKey`/
+ * `templateCategoryKey` helpers — no template English is hardcoded here.
+ */
+import createDebug from 'debug';
+
+import {
+ FLOW_TEMPLATES,
+ type FlowTemplate,
+ templateCategoryKey,
+ templateDescriptionKey,
+ templateNameKey,
+} from '../../lib/flows/templates';
+import { useT } from '../../lib/i18n/I18nContext';
+
+const log = createDebug('app:flows:templates');
+
+interface FlowTemplateGalleryProps {
+ /** Called with the picked template; the caller performs the create + navigate. */
+ onSelect: (template: FlowTemplate) => void;
+ /** Template id currently being created (shows a spinner label, disables the grid). */
+ busyId?: string | null;
+}
+
+export default function FlowTemplateGallery({ onSelect, busyId }: FlowTemplateGalleryProps) {
+ const { t } = useT();
+
+ if (FLOW_TEMPLATES.length === 0) {
+ return (
+
+
+ );
+}
+
export default function FlowCanvasPage() {
const { t } = useT();
const navigate = useNavigate();
+ const location = useLocation();
const { id } = useParams<{ id: string }>();
const [state, setState] = useState({ status: 'loading' });
+ // "Fix with agent" (Phase 5c) navigates here with a repair seed in
+ // `location.state` so the copilot opens preloaded with the failed run.
+ const copilotSeed = useMemo(() => asCopilotRepairSeed(location.state), [location.state]);
useEffect(() => {
// Guards a stale response from clobbering newer state: this effect
@@ -95,6 +487,24 @@ export default function FlowCanvasPage() {
};
}, [id]);
+ if (state.status === 'ready') {
+ // Keyed by flow id so switching flows cleanly re-seeds the editable canvas's
+ // controlled node/edge state (which only reads its props at mount).
+ const flow = state.flow;
+ return (
+
+ );
+ }
+
const backButton = (
)}
+
+ );
+}
- {state.status === 'ready' &&
- (() => {
- const graph = state.flow.graph as WorkflowGraph;
- const { nodes, edges } = workflowGraphToXyflow(graph);
- return ;
- })()}
+/**
+ * FlowCanvasDraftPage (Phase 4e) — the editable Workflow Canvas hosting an
+ * UNSAVED draft handed in from the chat `WorkflowProposalCard` "Open in canvas"
+ * action, at `/flows/draft`. The candidate graph rides in `location.state`
+ * (ephemeral — see `lib/flows/canvasDraft.ts`); NOTHING is fetched or persisted
+ * on open. The canvas's own Save button remains the single persistence gate
+ * (it calls `flows_create` for a draft), so opening a draft never touches
+ * `flows_create`/`flows_update`. If there's no draft in state (e.g. a hard
+ * reload dropped it, or the route was hit directly), we show an empty state
+ * rather than a broken canvas.
+ */
+export function FlowCanvasDraftPage() {
+ const { t } = useT();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const draft = useMemo(() => asFlowCanvasDraftState(location.state), [location.state]);
+
+ // Non-fatal import warnings (Phase 4d) shown as dismissible toasts over the
+ // draft canvas. Seeded once from the draft state so unmapped n8n node types /
+ // untranslated expressions aren't silently lost on the way in.
+ const [toasts, setToasts] = useState(() =>
+ (draft?.importWarnings ?? []).map((message, i) => ({
+ id: `import-warning-${i}`,
+ type: 'warning',
+ title: t('flows.import.warningTitle'),
+ message,
+ }))
+ );
+ const removeToast = useCallback((id: string) => {
+ setToasts(prev => prev.filter(item => item.id !== id));
+ }, []);
+
+ if (draft) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const backButton = (
+
+ );
+
+ return (
+
+
+
+ {t('flows.canvas.draftMissing')}
+
+
);
}
diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx
index 6c1663413..66e024047 100644
--- a/app/src/pages/FlowsPage.test.tsx
+++ b/app/src/pages/FlowsPage.test.tsx
@@ -5,12 +5,14 @@
* "Workflow started" toast, and refetches the list, that "View runs" opens
* `FlowRunsDrawer` for the clicked flow, that clicking a flow's name
* navigates to its read-only Workflow Canvas (`/flows/:id`, issue B5b.1),
- * and that "New workflow" (header + empty state) navigates to Chat (no
- * canvas *builder* yet — bridges to B4's agent-proposal flow).
+ * and that "New workflow" (header + empty state) opens the Phase 4a chooser
+ * (start from scratch / template / describe), with the empty state also
+ * surfacing the Phase 4c template gallery inline.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { FLOW_TEMPLATES } from '../lib/flows/templates';
import type { Flow } from '../services/api/flowsApi';
import { renderWithProviders } from '../test/test-utils';
import FlowsPage from './FlowsPage';
@@ -19,7 +21,19 @@ const listFlows = vi.hoisted(() => vi.fn());
const setFlowEnabled = vi.hoisted(() => vi.fn());
const runFlow = vi.hoisted(() => vi.fn());
const listFlowRuns = vi.hoisted(() => vi.fn());
-vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow, listFlowRuns }));
+const createFlow = vi.hoisted(() => vi.fn());
+const importFlow = vi.hoisted(() => vi.fn());
+vi.mock('../services/api/flowsApi', () => ({
+ listFlows,
+ setFlowEnabled,
+ runFlow,
+ listFlowRuns,
+ createFlow,
+ importFlow,
+}));
+
+const downloadFlowGraph = vi.hoisted(() => vi.fn(() => true));
+vi.mock('../lib/flows/exportFlow', () => ({ downloadFlowGraph }));
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async importOriginal => {
@@ -147,7 +161,7 @@ describe('FlowsPage', () => {
expect(mockNavigate).toHaveBeenCalledWith('/flows/flow-1');
});
- it('renders a "New workflow" header button and navigates to /chat when clicked', async () => {
+ it('renders a "New workflow" header button that opens the chooser modal', async () => {
listFlows.mockResolvedValue([makeFlow()]);
renderWithProviders();
@@ -155,16 +169,94 @@ describe('FlowsPage', () => {
expect(newWorkflowButton).toHaveTextContent('New workflow');
fireEvent.click(newWorkflowButton);
- expect(mockNavigate).toHaveBeenCalledWith('/chat');
+ expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('new-workflow-scratch')).toBeInTheDocument();
});
- it('navigates to /chat when the empty-state "New workflow" action is clicked', async () => {
+ it('opens the chooser from the empty-state "New workflow" action', async () => {
listFlows.mockResolvedValue([]);
renderWithProviders();
const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow');
fireEvent.click(emptyStateButton);
- expect(mockNavigate).toHaveBeenCalledWith('/chat');
+ expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
+ });
+
+ it('"describe it" in the chooser focuses the in-place prompt bar (no Chat hand-off)', async () => {
+ listFlows.mockResolvedValue([makeFlow()]);
+ renderWithProviders();
+
+ fireEvent.click(await screen.findByTestId('flows-new-workflow'));
+ fireEvent.click(screen.getByTestId('new-workflow-describe'));
+
+ // Phase 5c: no more /chat hand-off — the chooser closes and the prompt bar
+ // (already rendered at the top of the page) takes focus for authoring.
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat');
+ expect(screen.getByTestId('workflow-prompt-bar')).toBeInTheDocument();
+ expect(screen.getByTestId('workflow-prompt-input')).toHaveFocus();
+ });
+
+ it('empty-state template gallery creates a flow and opens its canvas', async () => {
+ listFlows.mockResolvedValue([]);
+ createFlow.mockResolvedValue({ id: 'flow-created' });
+ renderWithProviders();
+
+ await screen.findByTestId('flows-empty-templates');
+ const template = FLOW_TEMPLATES[0];
+ fireEvent.click(screen.getByTestId(`flow-template-${template.id}`));
+
+ await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
+ expect(createFlow.mock.calls[0][1]).toBe(template.graph);
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/flows/flow-created'));
+ });
+
+ it('renders an Import button in the header', async () => {
+ listFlows.mockResolvedValue([makeFlow()]);
+ renderWithProviders();
+
+ const importButton = await screen.findByTestId('flows-import');
+ expect(importButton).toHaveTextContent('Import');
+ });
+
+ it('exports a flow row as JSON via downloadFlowGraph', async () => {
+ listFlows.mockResolvedValue([makeFlow({ graph: { nodes: [], edges: [] } })]);
+ renderWithProviders();
+
+ fireEvent.click(await screen.findByTestId('flow-export-flow-1'));
+
+ expect(downloadFlowGraph).toHaveBeenCalledWith('Daily digest', { nodes: [], edges: [] });
+ });
+
+ it('imports a picked JSON file and opens the result as a draft canvas', async () => {
+ listFlows.mockResolvedValue([]);
+ const graph = { schema_version: 1, name: 'Imported', nodes: [], edges: [] };
+ importFlow.mockResolvedValue({ graph, warnings: ['heads up'] });
+ renderWithProviders();
+
+ const input = await screen.findByTestId('flows-import-input');
+ const file = new File([JSON.stringify({ nodes: [] })], 'wf.json', { type: 'application/json' });
+ fireEvent.change(input, { target: { files: [file] } });
+
+ await waitFor(() => expect(importFlow).toHaveBeenCalledWith({ nodes: [] }, 'auto'));
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith('/flows/draft', {
+ state: { name: 'Imported', graph, requireApproval: true, importWarnings: ['heads up'] },
+ })
+ );
+ });
+
+ it('shows an error when the picked file is not valid JSON', async () => {
+ listFlows.mockResolvedValue([]);
+ renderWithProviders();
+
+ const input = await screen.findByTestId('flows-import-input');
+ const file = new File(['not json{'], 'wf.json', { type: 'application/json' });
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(await screen.findByTestId('flows-error')).toHaveTextContent(
+ 'That file is not valid workflow JSON.'
+ );
+ expect(importFlow).not.toHaveBeenCalled();
});
});
diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx
index 5f25b2211..107153142 100644
--- a/app/src/pages/FlowsPage.tsx
+++ b/app/src/pages/FlowsPage.tsx
@@ -2,24 +2,41 @@
* FlowsPage — the Workflows list page (issue B5a).
*
* The discoverable hub for the `flows::` domain: lists every saved
- * `Flow` (name, enabled toggle, last-run status, Run button). This is NOT the
- * canvas (B5b ships flow authoring/editing) — until it lands, "New workflow"
- * (header + empty-state) bridges to the B4 agent-proposal flow in Chat
- * instead, since that's the only way to author a flow today.
+ * `Flow` (name, enabled toggle, last-run status, Run button). "New workflow"
+ * (header + empty-state) opens the Phase 4a chooser — start from scratch, pick
+ * a template (Phase 4c), or describe it in Chat — each of which creates a flow
+ * and opens the editable canvas (`/flows/:id`). The empty state also surfaces
+ * the template gallery inline so first-time users have a one-click starting
+ * point.
*/
import createDebug from 'debug';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import EmptyStateCard from '../components/EmptyStateCard';
import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow';
+import type { FlowRepairRequest } from '../components/flows/FlowRunInspectorDrawer';
import FlowRunsDrawer from '../components/flows/FlowRunsDrawer';
+import FlowTemplateGallery from '../components/flows/FlowTemplateGallery';
+import NewWorkflowModal from '../components/flows/NewWorkflowModal';
+import { useCreateFlow } from '../components/flows/useCreateFlow';
+import WorkflowPromptBar from '../components/flows/WorkflowPromptBar';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import Button from '../components/ui/Button';
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
+import { FLOW_CANVAS_DRAFT_ROUTE, type FlowCanvasDraftState } from '../lib/flows/canvasDraft';
+import { downloadFlowGraph } from '../lib/flows/exportFlow';
+import { type FlowTemplate, templateNameKey } from '../lib/flows/templates';
+import type { WorkflowGraph } from '../lib/flows/types';
import { useT } from '../lib/i18n/I18nContext';
-import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi';
+import {
+ type Flow,
+ importFlow,
+ listFlows,
+ runFlow,
+ setFlowEnabled,
+} from '../services/api/flowsApi';
import type { ToastNotification } from '../types/intelligence';
const log = createDebug('app:flows');
@@ -43,6 +60,14 @@ export default function FlowsPage() {
// then stacks on top of that when a specific run is picked). `null` keeps
// the drawer unmounted.
const [selectedFlowId, setSelectedFlowId] = useState(null);
+ // Whether the Phase 4a "New workflow" chooser modal is open.
+ const [chooserOpen, setChooserOpen] = useState(false);
+ // Bumped by the chooser's "Describe it" action so the prompt bar remounts and
+ // takes focus (Phase 5c). Starts at 0 (no autofocus on initial page load).
+ const [describeNonce, setDescribeNonce] = useState(0);
+ // Create-and-open logic for the empty-state inline template gallery. (The
+ // chooser modal owns its own `useCreateFlow` instance.)
+ const emptyCreate = useCreateFlow();
const addToast = useCallback((toast: Omit) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
@@ -129,6 +154,29 @@ export default function FlowsPage() {
setSelectedFlowId(flow.id);
}, []);
+ /**
+ * "Fix with agent" (Phase 5c) from a failed run's inspector: open the flow's
+ * canvas with a copilot repair seed in `location.state` so the copilot opens
+ * preloaded, diagnosing the failed run. Never persists — the copilot only
+ * proposes.
+ */
+ const handleFixWithAgent = useCallback(
+ (request: FlowRepairRequest) => {
+ log('fix with agent: flow=%s run=%s', request.flowId, request.runId);
+ setSelectedFlowId(null);
+ navigate(`/flows/${request.flowId}`, {
+ state: {
+ copilotRepair: {
+ runId: request.runId,
+ error: request.error,
+ failingNodeIds: request.failingNodeIds,
+ },
+ },
+ });
+ },
+ [navigate]
+ );
+
/** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */
const handleView = useCallback(
(flow: Flow) => {
@@ -140,24 +188,95 @@ export default function FlowsPage() {
const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null;
+ /** Downloads a flow's `WorkflowGraph` as a JSON file (Phase 4d export). */
+ const handleExport = useCallback(
+ (flow: Flow) => {
+ log('export: id=%s', flow.id);
+ const ok = downloadFlowGraph(flow.name, flow.graph);
+ if (ok) {
+ addToast({ type: 'success', title: t('flows.list.exported') });
+ }
+ },
+ [addToast, t]
+ );
+
+ // Hidden file input backing the header "Import" action. Clicking the button
+ // opens the OS file picker; the change handler reads + imports the file.
+ const importInputRef = useRef(null);
+
+ const handleImportClick = useCallback(() => {
+ log('import: opening file picker');
+ importInputRef.current?.click();
+ }, []);
+
/**
- * "New workflow" (there's no canvas builder yet — B5b) bridges to Chat so
- * the user can kick off B4's agent-proposal flow instead. There's no
- * existing mechanism to prefill or auto-send an initial composer message
- * from outside the Chat page — `Conversations.tsx` only reads
- * `location.state.openThreadId` (to reopen a thread), and the composer's
- * text is local `useState` with no Redux draft slice. This is the same gap
- * `ActionItemChecklist.tsx`'s "Run with OpenHuman" button already hit, so
- * we follow its precedent: navigate to `/chat` with no prefill rather than
- * build new prefill plumbing from scratch.
+ * Reads the picked JSON file and runs it through `flows_import` (host-side
+ * migrate + validate + best-effort n8n mapping). On success, opens the
+ * normalized graph on the editable canvas as an UNSAVED draft — nothing is
+ * persisted until the user Saves via the canvas's existing gate. Auto-detect
+ * handles native vs n8n, so no format prompt is needed.
*/
+ const handleImportFile = useCallback(
+ async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ // Reset the input so re-picking the same file fires `change` again.
+ event.target.value = '';
+ if (!file) return;
+ setError(null);
+ log('import: reading file name=%s size=%d', file.name, file.size);
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(await file.text());
+ } catch (err) {
+ log('import: invalid JSON: %o', err);
+ setError(t('flows.import.invalidFile'));
+ return;
+ }
+ try {
+ const result = await importFlow(parsed, 'auto');
+ const graph = result.graph as WorkflowGraph;
+ log('import: ok warnings=%d', result.warnings.length);
+ const draft: FlowCanvasDraftState = {
+ name: graph.name || file.name.replace(/\.[^.]+$/, ''),
+ graph,
+ requireApproval: true,
+ importWarnings: result.warnings,
+ };
+ navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft });
+ } catch (err) {
+ log('import failed: %o', err);
+ setError(t('flows.import.error'));
+ }
+ },
+ [navigate, t]
+ );
+
+ /** "New workflow" opens the Phase 4a chooser (scratch / template / describe). */
const handleNewWorkflow = useCallback(() => {
- log('new workflow: navigating to chat');
- // TODO: prefill the chat composer with a workflow-building prompt once a
- // draft/initial-message API exists (see ActionItemChecklist.tsx's
- // identical TODO for the same gap).
- navigate('/chat');
- }, [navigate]);
+ log('new workflow: opening chooser');
+ setChooserOpen(true);
+ }, []);
+
+ /**
+ * "Describe it" hand-off (Phase 5c): rather than punting to Chat, focus the
+ * in-place prompt bar at the top of this page — it spawns a `workflow_builder`
+ * turn in a dedicated thread and renders the proposal inline. Bumping the
+ * nonce remounts the bar so it takes focus even though it's already visible.
+ */
+ const handleDescribe = useCallback(() => {
+ log('new workflow: describe — focusing the prompt bar');
+ setChooserOpen(false);
+ setDescribeNonce(n => n + 1);
+ }, []);
+
+ /** Create a flow from an empty-state gallery card and open its canvas. */
+ const handleEmptyTemplate = useCallback(
+ (template: FlowTemplate) => {
+ log('empty-state template selected: id=%s', template.id);
+ void emptyCreate.create(template.id, t(templateNameKey(template.id)), template.graph);
+ },
+ [emptyCreate, t]
+ );
return (
- {t('flows.page.newWorkflow')}
-
+
+
+
+
}>
+ void handleImportFile(e)}
+ />
+ {/* Prompt-first authoring (Phase 5c): describe a workflow and let the
+ builder agent propose it. Hero presentation when the list is empty,
+ compact otherwise. Keyed by `describeNonce` so the chooser's
+ "Describe it" action remounts + focuses it. */}
+ 0}
+ />
+
{error && (
@@ -229,8 +392,13 @@ export default function FlowsPage() {
flowId={selectedFlowId}
flowName={selectedFlow?.name}
onClose={() => setSelectedFlowId(null)}
+ onFixWithAgent={handleFixWithAgent}
/>
+ {chooserOpen && (
+ setChooserOpen(false)} onDescribe={handleDescribe} />
+ )}
+
);
diff --git a/app/src/pages/WorkflowNew.test.tsx b/app/src/pages/WorkflowNew.test.tsx
deleted file mode 100644
index 23c39d81a..000000000
--- a/app/src/pages/WorkflowNew.test.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * WorkflowNew — Phase 6 coverage.
- *
- * Covers:
- * - renders the form (delegates to CreateWorkflowForm) and the header
- * Cancel/Submit buttons.
- * - cancel button navigates back to /skills.
- * - on a successful submit (createWorkflow resolves), the page
- * navigates to /skills.
- * - submit button reflects the form's validity (disabled until both
- * required fields are filled).
- */
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import WorkflowNew from './WorkflowNew';
-
-const stableT = (key: string) => key;
-vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
-
-const hoisted = vi.hoisted(() => ({ createWorkflow: vi.fn() }));
-
-vi.mock('../services/api/workflowsApi', () => ({
- workflowsApi: { createWorkflow: hoisted.createWorkflow },
-}));
-
-const renderPage = () =>
- render(
-
-
- } />
- dashboard
} />
-
-
- );
-
-describe('WorkflowNew', () => {
- beforeEach(() => {
- hoisted.createWorkflow.mockReset();
- });
-
- it('renders the form and the header CTAs', () => {
- renderPage();
- expect(screen.getByTestId('skill-new-cancel')).toBeInTheDocument();
- expect(screen.getByTestId('skill-new-submit')).toBeInTheDocument();
- // CreateWorkflowForm renders the name + description inputs.
- expect(screen.getByLabelText(/skills.create.name/i)).toBeInTheDocument();
- expect(screen.getByLabelText(/skills.create.description/i)).toBeInTheDocument();
- });
-
- it('cancel button navigates back to /connections', async () => {
- renderPage();
- fireEvent.click(screen.getByTestId('skill-new-cancel'));
- expect(await screen.findByTestId('dashboard-landed')).toBeInTheDocument();
- });
-
- it('submit is disabled until both required fields are filled', () => {
- renderPage();
- const submit = screen.getByTestId('skill-new-submit') as HTMLButtonElement;
- expect(submit).toBeDisabled();
-
- fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
- target: { value: 'New Skill' },
- });
- expect(submit).toBeDisabled(); // still missing description
-
- fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
- target: { value: 'Does something neat.' },
- });
- expect(submit).not.toBeDisabled();
- });
-
- it('navigates to /connections after a successful submit', async () => {
- hoisted.createWorkflow.mockResolvedValue({
- id: 'new-skill',
- name: 'New Skill',
- scope: 'user',
- legacy: false,
- });
- renderPage();
-
- fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
- target: { value: 'New Skill' },
- });
- fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
- target: { value: 'Description.' },
- });
-
- fireEvent.click(screen.getByTestId('skill-new-submit'));
- await waitFor(() => expect(hoisted.createWorkflow).toHaveBeenCalled());
- await screen.findByTestId('dashboard-landed');
- });
-});
diff --git a/app/src/pages/WorkflowNew.tsx b/app/src/pages/WorkflowNew.tsx
deleted file mode 100644
index 4ec543a51..000000000
--- a/app/src/pages/WorkflowNew.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * /workflows/new — full-page Create-a-Skill authoring view.
- *
- * Renders `CreateWorkflowForm` (extracted from CreateSkillModal in
- * Phase 5) inside page chrome, so the same flow is available as a
- * standalone route — entry point for the Skills dashboard's [+ Create
- * a Skill] CTA and bookmark-able for users who routinely scaffold
- * new SKILL.md drafts.
- *
- * Behaviour on submit:
- * - Success → navigate to /connections so the user lands somewhere
- * meaningful. We considered /workflows/run?workflow=, but
- * new skills aren't auto-scheduled and the runner picker pre-select
- * only makes sense once the user has filled in inputs. The
- * Connections page (defaulting to the Composio tab) provides a clear "here
- * are your connections" signal. Use ?tab=skills to deep-link to
- * the Skills tab if needed.
- * - Cancel → /connections.
- */
-import { useCallback, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import CreateWorkflowForm from '../components/skills/CreateWorkflowForm';
-import Button from '../components/ui/Button';
-import { useT } from '../lib/i18n/I18nContext';
-import { type WorkflowSummary } from '../services/api/workflowsApi';
-
-const PAGE_FORM_ID = 'create-skill-page-form';
-
-export default function WorkflowNew() {
- const { t } = useT();
- const navigate = useNavigate();
-
- const [formValid, setFormValid] = useState(false);
- const [submitting, setSubmitting] = useState(false);
-
- const handleStateChange = useCallback((state: { valid: boolean; submitting: boolean }) => {
- setFormValid(state.valid);
- setSubmitting(state.submitting);
- }, []);
-
- const handleCreated = useCallback(
- (_skill: WorkflowSummary) => {
- // The dashboard re-fetches the cron list on mount, so any
- // schedule the user adds for this new skill will appear there
- // automatically — no need to plumb the new id through state.
- navigate('/connections');
- },
- [navigate]
- );
-
- return (
-
-
-
- {/* Header: title + Cancel/Submit on the right.
- The submit button is wired to the form via `form=PAGE_FORM_ID`
- so it submits the underlying form even though it sits in the
- header rather than inside the form element. */}
-
-
-
{t('skills.new.title')}
-
{t('skills.create.subtitle')}
-
-
-
-
-
-
-
- {/* Form */}
-
-
-
-
-
-
- );
-}
diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx
index a1911e19f..ec48fd70a 100644
--- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx
+++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx
@@ -1,18 +1,29 @@
/**
- * FlowCanvasPage (issue B5b.1) — the read-only Workflow Canvas view at
- * `/flows/:id`. Asserts the loading → canvas happy path, the not-found state
- * (mirrors the Rust `flows_get` "not found" error), and the generic error
- * state for any other failure.
+ * FlowCanvasPage (issue B5b / Phase 3) — the editable Workflow Canvas builder
+ * at `/flows/:id`. Asserts the loading → canvas happy path, the not-found state
+ * (mirrors the Rust `flows_get` "not found" error), the generic error state,
+ * and the Phase 3d host wiring: Save persists via `flows_update`, and the
+ * unsaved-changes guard intercepts the Back button while dirty.
*/
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Flow } from '../../services/api/flowsApi';
-import FlowCanvasPage from '../FlowCanvasPage';
+import FlowCanvasPage, { FlowCanvasDraftPage } from '../FlowCanvasPage';
const getFlow = vi.hoisted(() => vi.fn());
-vi.mock('../../services/api/flowsApi', () => ({ getFlow }));
+const updateFlow = vi.hoisted(() => vi.fn());
+const createFlow = vi.hoisted(() => vi.fn());
+const validateFlow = vi.hoisted(() => vi.fn());
+const listFlowConnections = vi.hoisted(() => vi.fn());
+vi.mock('../../services/api/flowsApi', () => ({
+ getFlow,
+ updateFlow,
+ createFlow,
+ validateFlow,
+ listFlowConnections,
+}));
function makeFlow(overrides: Partial = {}): Flow {
return {
@@ -57,6 +68,14 @@ function renderAtFlowId(id: string) {
describe('FlowCanvasPage', () => {
beforeEach(() => {
getFlow.mockReset();
+ updateFlow.mockReset();
+ createFlow.mockReset();
+ validateFlow.mockReset();
+ listFlowConnections.mockReset();
+ validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
+ listFlowConnections.mockResolvedValue([]);
+ updateFlow.mockResolvedValue(makeFlow());
+ createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Daily digest' }));
});
it('shows a loading state while the flow is being fetched', () => {
@@ -120,4 +139,126 @@ describe('FlowCanvasPage', () => {
expect(screen.getByText('New flow')).toBeInTheDocument();
expect(screen.queryByText('Old flow (stale)')).not.toBeInTheDocument();
});
+
+ function renderEditor(id = 'test-id') {
+ return render(
+
+
+ } />
+ Flows list} />
+
+
+ );
+ }
+
+ it('persists the live graph via flows_update when Save is clicked', async () => {
+ getFlow.mockResolvedValue(makeFlow());
+ renderEditor();
+ await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
+
+ // Edit the graph (add a node) so it is dirty, then Save.
+ fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
+ fireEvent.click(screen.getByTestId('flow-editor-save'));
+
+ await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1));
+ const [calledId, update] = updateFlow.mock.calls[0];
+ expect(calledId).toBe('test-id');
+ expect(update.graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual([
+ 'agent',
+ 'trigger',
+ ]);
+ });
+
+ it('does not prompt when navigating Back with no unsaved changes', async () => {
+ getFlow.mockResolvedValue(makeFlow());
+ renderEditor();
+ await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByTestId('flow-canvas-back'));
+ // Pristine → straight to the list, no confirmation dialog.
+ await waitFor(() => expect(screen.getByTestId('flows-list')).toBeInTheDocument());
+ expect(screen.queryByTestId('flow-leave-confirm')).not.toBeInTheDocument();
+ });
+
+ it('prompts before leaving when dirty, and discards to navigate away', async () => {
+ getFlow.mockResolvedValue(makeFlow());
+ renderEditor();
+ await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
+
+ // Make it dirty, then click Back — a confirmation dialog blocks navigation.
+ fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
+ fireEvent.click(screen.getByTestId('flow-canvas-back'));
+ expect(screen.getByTestId('flow-leave-confirm')).toBeInTheDocument();
+ expect(screen.queryByTestId('flows-list')).not.toBeInTheDocument();
+
+ // Staying dismisses the dialog and keeps the editor mounted.
+ fireEvent.click(screen.getByTestId('flow-leave-stay'));
+ expect(screen.queryByTestId('flow-leave-confirm')).not.toBeInTheDocument();
+ expect(screen.getByTestId('flow-canvas')).toBeInTheDocument();
+
+ // Re-open the prompt and confirm leaving → navigates to the list.
+ fireEvent.click(screen.getByTestId('flow-canvas-back'));
+ fireEvent.click(screen.getByTestId('flow-leave-discard'));
+ await waitFor(() => expect(screen.getByTestId('flows-list')).toBeInTheDocument());
+ });
+
+ // -------------------------------------------------------------------------
+ // Draft canvas (Phase 4e) — the chat "Open in canvas" action lands here with
+ // the proposed graph in router state. Opening it must NEVER persist.
+ // -------------------------------------------------------------------------
+ const draftGraph = {
+ schema_version: 1,
+ name: 'Proposed flow',
+ nodes: [
+ { id: 't', kind: 'trigger', name: 'Start', config: {}, ports: [], position: { x: 0, y: 0 } },
+ ],
+ edges: [],
+ };
+
+ function renderDraft(state: unknown) {
+ return render(
+
+
+ } />
+ } />
+ Flows list} />
+
+
+ );
+ }
+
+ it('renders the draft canvas from router state without fetching or persisting', async () => {
+ renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
+
+ await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
+ expect(screen.getByText('Proposed flow')).toBeInTheDocument();
+ // A draft is not fetched, is not runnable, and has persisted nothing.
+ expect(getFlow).not.toHaveBeenCalled();
+ expect(createFlow).not.toHaveBeenCalled();
+ expect(updateFlow).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('flow-canvas-run')).not.toBeInTheDocument();
+ });
+
+ it('creates (never updates) the flow when a draft is saved', async () => {
+ renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
+ await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
+
+ // Edit to make it dirty, then Save → the single persistence gate fires
+ // `flows_create` (with the require-approval flag), not `flows_update`.
+ fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
+ fireEvent.click(screen.getByTestId('flow-editor-save'));
+
+ await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
+ const [name, graph, requireApproval] = createFlow.mock.calls[0];
+ expect(name).toBe('Proposed flow');
+ expect(requireApproval).toBe(true);
+ expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']);
+ expect(updateFlow).not.toHaveBeenCalled();
+ });
+
+ it('shows an empty state when the draft route is hit with no draft in state', () => {
+ renderDraft(null);
+ expect(screen.getByTestId('flow-canvas-draft-missing')).toBeInTheDocument();
+ expect(screen.queryByTestId('flow-canvas')).not.toBeInTheDocument();
+ });
});
diff --git a/app/src/pages/__tests__/Skills.channels-grid.test.tsx b/app/src/pages/__tests__/Skills.channels-grid.test.tsx
index 14a4c5d72..af95df1dc 100644
--- a/app/src/pages/__tests__/Skills.channels-grid.test.tsx
+++ b/app/src/pages/__tests__/Skills.channels-grid.test.tsx
@@ -51,13 +51,13 @@ vi.mock('../../services/api/channelConnectionsApi', () => ({
channelConnectionsApi: { updatePreferences: (channel: string) => updatePreferencesMock(channel) },
}));
-vi.mock('../../services/api/workflowsApi', async () => {
- const actual = await vi.importActual(
- '../../services/api/workflowsApi'
+vi.mock('../../services/api/skillsApi', async () => {
+ const actual = await vi.importActual(
+ '../../services/api/skillsApi'
);
return {
...actual,
- workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
+ skillsApi: { ...actual.skillsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
};
});
diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
index 835905026..640f11d27 100644
--- a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
+++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
@@ -9,13 +9,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
-vi.mock('../../services/api/workflowsApi', async () => {
- const actual = await vi.importActual(
- '../../services/api/workflowsApi'
+vi.mock('../../services/api/skillsApi', async () => {
+ const actual = await vi.importActual(
+ '../../services/api/skillsApi'
);
return {
...actual,
- workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
+ skillsApi: { ...actual.skillsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
};
});
diff --git a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx
index 1b7f5ff31..7cf6a77c0 100644
--- a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx
+++ b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx
@@ -13,13 +13,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
-vi.mock('../../services/api/workflowsApi', async () => {
- const actual = await vi.importActual(
- '../../services/api/workflowsApi'
+vi.mock('../../services/api/skillsApi', async () => {
+ const actual = await vi.importActual(
+ '../../services/api/skillsApi'
);
return {
...actual,
- workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
+ skillsApi: { ...actual.skillsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
};
});
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index 439b8eeca..4ccfc75e0 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -265,6 +265,34 @@ function chatTurnUsagePayload(event: ChatDoneEvent): {
* must never crash the chat runtime, it should just silently not render a
* card.
*/
+/**
+ * Tool names whose successful `output` carries a `workflow_proposal` payload.
+ * `propose_workflow` (first draft) and `revise_workflow` (iterative refine)
+ * both return the identical wire shape (see `src/openhuman/flows/builder_tools.rs`),
+ * so the runtime surfaces a `WorkflowProposalCard` from either. These run inside
+ * the `workflow_builder` specialist — reached either as the main agent's own
+ * tool or, in the Flows copilot / prompt-bar flow, as a delegated subagent
+ * (`build_workflow`) — so BOTH `onToolResult` and `onSubagentToolResult` funnel
+ * through {@link maybeParseWorkflowProposalTool}.
+ */
+const WORKFLOW_PROPOSAL_TOOLS = new Set(['propose_workflow', 'revise_workflow']);
+
+/**
+ * If a completed tool result is a successful workflow-builder proposal
+ * (`propose_workflow`/`revise_workflow`), parse it. Returns `null` for anything
+ * else so callers can cheaply gate on it. Keyed by the tool NAME + success, not
+ * by agent, so a proposal surfaces whether the tool ran in the main agent or in
+ * the delegated `workflow_builder` worker.
+ */
+function maybeParseWorkflowProposalTool(
+ toolName: string,
+ success: boolean,
+ output: string | undefined
+): WorkflowProposal | null {
+ if (!success || !WORKFLOW_PROPOSAL_TOOLS.has(toolName) || !output) return null;
+ return parseWorkflowProposal(output);
+}
+
function parseWorkflowProposal(output: string): WorkflowProposal | null {
let parsed: unknown;
try {
@@ -694,19 +722,20 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
// composer. The tool only validates; only the card's "Save & enable"
// action ever calls `flows_create`, so this dispatch alone can never
// create a flow.
- if (event.tool_name === 'propose_workflow' && event.success) {
- const proposal = parseWorkflowProposal(event.output);
- if (proposal) {
- rtLog('propose_workflow proposal parsed', {
- thread: event.thread_id,
- name: proposal.name,
- });
- dispatch(setWorkflowProposalForThread({ threadId: event.thread_id, proposal }));
- } else {
- rtLog('propose_workflow result did not parse as a workflow_proposal', {
- thread: event.thread_id,
- });
- }
+ const mainProposal = maybeParseWorkflowProposalTool(
+ event.tool_name,
+ event.success,
+ event.output
+ );
+ if (mainProposal) {
+ rtLog('workflow proposal parsed (main agent)', {
+ thread: event.thread_id,
+ tool: event.tool_name,
+ name: mainProposal.name,
+ });
+ dispatch(
+ setWorkflowProposalForThread({ threadId: event.thread_id, proposal: mainProposal })
+ );
}
const current = store.getState().chatRuntime.inferenceStatusByThread[event.thread_id];
@@ -931,6 +960,30 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
failure: event.success ? undefined : parseToolFailure(event.failure),
})
);
+
+ // Phase 5c: the Flows prompt bar / canvas copilot route to the
+ // `workflow_builder` specialist via delegation (`build_workflow`), so a
+ // `propose_workflow`/`revise_workflow` proposal is produced INSIDE the
+ // delegated worker and arrives here (not on `onToolResult`). Surface it
+ // on the PARENT thread (`event.thread_id`) so the same
+ // `WorkflowProposalCard` / copilot the direct-tool path uses renders it.
+ // Still validate-only — the card's explicit Save is the sole persistence
+ // gate.
+ const subagentProposal = maybeParseWorkflowProposalTool(
+ event.tool_name,
+ event.success,
+ event.output
+ );
+ if (subagentProposal) {
+ rtLog('workflow proposal parsed (delegated worker)', {
+ thread: event.thread_id,
+ tool: event.tool_name,
+ name: subagentProposal.name,
+ });
+ dispatch(
+ setWorkflowProposalForThread({ threadId: event.thread_id, proposal: subagentProposal })
+ );
+ }
},
onSubagentTextDelta: (event: ChatSubagentTextDeltaEvent) => {
const taskId = event.subagent?.task_id;
diff --git a/app/src/services/__tests__/rpcMethods.test.ts b/app/src/services/__tests__/rpcMethods.test.ts
index c3c706c40..e60d47ea5 100644
--- a/app/src/services/__tests__/rpcMethods.test.ts
+++ b/app/src/services/__tests__/rpcMethods.test.ts
@@ -173,6 +173,15 @@ describe('rpcMethods catalog', () => {
path.resolve(__dirname, '../../../../src/openhuman/channels/controllers/schemas.rs'),
'utf8'
),
+ // The channels_* namespace/function literals now live in the vendored
+ // tinychannels crate (`ChannelControllerSchema`), not in the thin
+ // `src/openhuman/channels/controllers/schemas.rs` adapter above, which
+ // only converts from it (#4557 "Use tinychannels provider
+ // implementations") — read both so this drift guard still sees them.
+ fs.readFileSync(
+ path.resolve(__dirname, '../../../../vendor/tinychannels/src/controllers/schemas.rs'),
+ 'utf8'
+ ),
].join('\n');
for (const method of Object.values(CORE_RPC_METHODS)) {
diff --git a/app/src/services/api/__tests__/flowsApi.test.ts b/app/src/services/api/__tests__/flowsApi.test.ts
new file mode 100644
index 000000000..13d83edd8
--- /dev/null
+++ b/app/src/services/api/__tests__/flowsApi.test.ts
@@ -0,0 +1,58 @@
+/**
+ * flowsApi.importFlow (Phase 4d) — the host-validated import client. Asserts it
+ * forwards the graph + format to `openhuman.flows_import`, unwraps the
+ * CLI-compatible `{ result, logs }` envelope, and surfaces the normalized graph
+ * plus warnings. Also covers the auto-detect default and error propagation.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { importFlow } from '../flowsApi';
+
+vi.mock('../../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
+
+describe('flowsApi.importFlow', () => {
+ beforeEach(async () => {
+ const { callCoreRpc } = await import('../../coreRpcClient');
+ vi.mocked(callCoreRpc).mockReset();
+ });
+
+ it('forwards the graph with the default auto format and unwraps the envelope', async () => {
+ const { callCoreRpc } = await import('../../coreRpcClient');
+ const graph = { schema_version: 1, name: 'Imported', nodes: [], edges: [] };
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({
+ result: { graph, warnings: ['Node X unmapped'] },
+ logs: ['flow imported'],
+ });
+
+ const result = await importFlow({ some: 'n8n json' });
+
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.flows_import',
+ params: { graph: { some: 'n8n json' }, format: 'auto' },
+ });
+ expect(result.graph).toEqual(graph);
+ expect(result.warnings).toEqual(['Node X unmapped']);
+ });
+
+ it('passes an explicit n8n format through', async () => {
+ const { callCoreRpc } = await import('../../coreRpcClient');
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({
+ result: { graph: { nodes: [] }, warnings: [] },
+ logs: ['flow imported'],
+ });
+
+ await importFlow({ nodes: [] }, 'n8n');
+
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.flows_import',
+ params: { graph: { nodes: [] }, format: 'n8n' },
+ });
+ });
+
+ it('propagates a rejection from an invalid definition', async () => {
+ const { callCoreRpc } = await import('../../coreRpcClient');
+ vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('missing trigger'));
+
+ await expect(importFlow({ bad: true })).rejects.toThrow('missing trigger');
+ });
+});
diff --git a/app/src/services/api/__tests__/workflowsApi.test.ts b/app/src/services/api/__tests__/skillsApi.test.ts
similarity index 85%
rename from app/src/services/api/__tests__/workflowsApi.test.ts
rename to app/src/services/api/__tests__/skillsApi.test.ts
index 9fd6fea3c..2b590348b 100644
--- a/app/src/services/api/__tests__/workflowsApi.test.ts
+++ b/app/src/services/api/__tests__/skillsApi.test.ts
@@ -1,16 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { workflowsApi } from '../workflowsApi';
+import { skillsApi } from '../skillsApi';
vi.mock('../../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
-describe('workflowsApi.createWorkflow', () => {
+describe('skillsApi.createWorkflow', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
});
- it('forwards inputs to workflows_create and rekeys allowedTools', async () => {
+ it('forwards inputs to skills_create and rekeys allowedTools', async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockResolvedValueOnce({
workflow: {
@@ -30,7 +30,7 @@ describe('workflowsApi.createWorkflow', () => {
},
});
- const result = await workflowsApi.createWorkflow({
+ const result = await skillsApi.createWorkflow({
name: 'My Skill',
description: 'does stuff',
scope: 'user',
@@ -39,7 +39,7 @@ describe('workflowsApi.createWorkflow', () => {
});
expect(callCoreRpc).toHaveBeenCalledWith({
- method: 'openhuman.workflows_create',
+ method: 'openhuman.skills_create',
params: {
name: 'My Skill',
description: 'does stuff',
@@ -72,7 +72,7 @@ describe('workflowsApi.createWorkflow', () => {
},
});
- await workflowsApi.createWorkflow({ name: 'minimal', description: 'd' });
+ await skillsApi.createWorkflow({ name: 'minimal', description: 'd' });
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
expect(call.params).toEqual({ name: 'minimal', description: 'd' });
@@ -99,13 +99,13 @@ describe('workflowsApi.createWorkflow', () => {
},
},
});
- const result = await workflowsApi.createWorkflow({ name: 'env', description: 'e' });
+ const result = await skillsApi.createWorkflow({ name: 'env', description: 'e' });
expect(result.id).toBe('env');
expect(result.scope).toBe('project');
});
});
-describe('workflowsApi.installWorkflowFromUrl', () => {
+describe('skillsApi.installWorkflowFromUrl', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
@@ -120,13 +120,13 @@ describe('workflowsApi.installWorkflowFromUrl', () => {
new_workflows: ['my-skill'],
});
- const result = await workflowsApi.installWorkflowFromUrl({
+ const result = await skillsApi.installWorkflowFromUrl({
url: 'https://example.com/my-skill.tgz',
timeoutSecs: 120,
});
expect(callCoreRpc).toHaveBeenCalledWith({
- method: 'openhuman.workflows_install_from_url',
+ method: 'openhuman.skills_install_from_url',
params: { url: 'https://example.com/my-skill.tgz', timeout_secs: 120 },
});
expect(result.newWorkflows).toEqual(['my-skill']);
@@ -142,7 +142,7 @@ describe('workflowsApi.installWorkflowFromUrl', () => {
new_workflows: undefined,
});
- const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/x' });
+ const result = await skillsApi.installWorkflowFromUrl({ url: 'https://example.com/x' });
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
expect(call.params).toEqual({ url: 'https://example.com/x' });
@@ -159,13 +159,13 @@ describe('workflowsApi.installWorkflowFromUrl', () => {
new_workflows: ['y-skill'],
},
});
- const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/y' });
+ const result = await skillsApi.installWorkflowFromUrl({ url: 'https://example.com/y' });
expect(result.newWorkflows).toEqual(['y-skill']);
expect(result.stderr).toBe('warn');
});
});
-describe('workflowsApi.updateWorkflow', () => {
+describe('skillsApi.updateWorkflow', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
@@ -177,7 +177,7 @@ describe('workflowsApi.updateWorkflow', () => {
workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const },
});
- await workflowsApi.updateWorkflow({
+ await skillsApi.updateWorkflow({
name: 'WF',
description: 'd',
whenToUse: 'when X happens',
@@ -190,7 +190,7 @@ describe('workflowsApi.updateWorkflow', () => {
});
expect(callCoreRpc).toHaveBeenCalledWith({
- method: 'openhuman.workflows_update',
+ method: 'openhuman.skills_update',
params: {
name: 'WF',
description: 'd',
@@ -206,7 +206,7 @@ describe('workflowsApi.updateWorkflow', () => {
});
});
-describe('workflowsApi.listWorkflows', () => {
+describe('skillsApi.listWorkflows', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
@@ -221,16 +221,16 @@ describe('workflowsApi.listWorkflows', () => {
],
});
- const result = await workflowsApi.listWorkflows();
+ const result = await skillsApi.listWorkflows();
- expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' });
+ expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.skills_list' });
expect(result.map(w => w.id)).toEqual(['a', 'b']);
});
it('unwraps an envelope and defaults to [] when the field is absent', async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockResolvedValueOnce({ data: {} });
- const result = await workflowsApi.listWorkflows();
+ const result = await skillsApi.listWorkflows();
expect(result).toEqual([]);
});
@@ -273,9 +273,9 @@ describe('workflowsApi.listWorkflows', () => {
],
});
- const result = await workflowsApi.listWorkflows();
+ const result = await skillsApi.listWorkflows();
- expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' });
+ expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.skills_list' });
expect(result[0].relatedSkills).toEqual(['browser-automation']);
expect(result[0].sourceFormat).toBe('hermes');
expect(result[0].platforms).toEqual([]);
@@ -285,24 +285,24 @@ describe('workflowsApi.listWorkflows', () => {
it('omits params by default (automations-only view)', async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockResolvedValueOnce({ workflows: [] });
- await workflowsApi.listWorkflows();
+ await skillsApi.listWorkflows();
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
- expect(call.method).toBe('openhuman.workflows_list');
+ expect(call.method).toBe('openhuman.skills_list');
expect(call.params).toBeUndefined();
});
it('passes include_skills when includeSkills is set', async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockResolvedValueOnce({ workflows: [] });
- await workflowsApi.listWorkflows({ includeSkills: true });
+ await skillsApi.listWorkflows({ includeSkills: true });
expect(callCoreRpc).toHaveBeenCalledWith({
- method: 'openhuman.workflows_list',
+ method: 'openhuman.skills_list',
params: { include_skills: true },
});
});
});
-describe('workflowsApi.readWorkflowResource', () => {
+describe('skillsApi.readWorkflowResource', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
@@ -317,13 +317,13 @@ describe('workflowsApi.readWorkflowResource', () => {
bytes: 10,
});
- const result = await workflowsApi.readWorkflowResource({
+ const result = await skillsApi.readWorkflowResource({
workflowId: 'wf',
relativePath: 'scripts/run.sh',
});
expect(callCoreRpc).toHaveBeenCalledWith({
- method: 'openhuman.workflows_read_resource',
+ method: 'openhuman.skills_read_resource',
params: { workflow_id: 'wf', relative_path: 'scripts/run.sh' },
});
expect(result).toEqual({
@@ -335,7 +335,7 @@ describe('workflowsApi.readWorkflowResource', () => {
});
});
-describe('workflowsApi.uninstallWorkflow', () => {
+describe('skillsApi.uninstallWorkflow', () => {
beforeEach(async () => {
const { callCoreRpc } = await import('../../coreRpcClient');
vi.mocked(callCoreRpc).mockReset();
@@ -349,7 +349,7 @@ describe('workflowsApi.uninstallWorkflow', () => {
scope: 'user',
});
- const result = await workflowsApi.uninstallWorkflow('weather-helper');
+ const result = await skillsApi.uninstallWorkflow('weather-helper');
expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.skill_registry_uninstall',
diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts
index ef52dc8e0..66746f52c 100644
--- a/app/src/services/api/flowsApi.ts
+++ b/app/src/services/api/flowsApi.ts
@@ -111,6 +111,61 @@ export interface Flow {
require_approval: boolean;
}
+/**
+ * Result of `openhuman.flows_validate` (`src/openhuman/flows/types.rs::FlowValidation`).
+ * `valid === false` means the graph is structurally rejected and won't
+ * persist/enable; `warnings` are advisory and orthogonal to validity (a valid
+ * graph can still carry them). `errors` carries at most one message — the
+ * first structural error tinyflows's validator reports — so it's a
+ * graph-level signal, not a per-node list.
+ */
+export interface FlowValidation {
+ valid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+/**
+ * Source format for {@link importFlow}. `native` is a tinyflows `WorkflowGraph`
+ * JSON; `n8n` is an n8n workflow export (mapped best-effort host-side); `auto`
+ * (the default) detects the shape.
+ */
+export type FlowImportFormat = 'native' | 'n8n' | 'auto';
+
+/**
+ * Result of `openhuman.flows_import` (`src/openhuman/flows/types.rs::FlowImport`).
+ * The `graph` is the normalized, migrated + validated `WorkflowGraph` ready to
+ * open on the canvas as an unsaved draft; `warnings` carries non-fatal import
+ * notes (unmapped n8n node types, untranslated expressions, a synthesized or
+ * demoted trigger). Import NEVER persists — the user Saves via the normal gate.
+ */
+export interface FlowImport {
+ graph: unknown;
+ warnings: string[];
+}
+
+/**
+ * A secret-free credential reference for the node-config credential picker
+ * (`src/openhuman/flows/types.rs::FlowConnection`). `connection_ref` is
+ * `"composio::"` (composio) or `"http_cred:"`
+ * (raw HTTP cred). `toolkit` is present only for composio; `scheme`
+ * (`"bearer"|"basic"|"header"`) only for http.
+ */
+export interface FlowConnection {
+ connection_ref: string;
+ kind: 'composio' | 'http';
+ display: string;
+ toolkit?: string;
+ scheme?: string;
+}
+
+/** Optional fields for {@link updateFlow}. Omitted fields are left untouched. */
+export interface FlowUpdate {
+ name?: string;
+ graph?: unknown;
+ requireApproval?: boolean;
+}
+
// ---------------------------------------------------------------------------
// CLI-compatible envelope unwrapping.
// ---------------------------------------------------------------------------
@@ -299,8 +354,96 @@ export async function runFlow(id: string, input?: unknown): Promise {
+ log(
+ 'updateFlow: request id=%s name=%s graph=%s requireApproval=%s',
+ id,
+ update.name ?? '(unchanged)',
+ update.graph === undefined ? '(unchanged)' : 'present',
+ update.requireApproval ?? 'unchanged'
+ );
+ const params: Record = { id };
+ if (update.name !== undefined) params.name = update.name;
+ if (update.graph !== undefined) params.graph = update.graph;
+ if (update.requireApproval !== undefined) params.require_approval = update.requireApproval;
+ const response = await callCoreRpc({ method: 'openhuman.flows_update', params });
+ const flow = unwrapCliEnvelope(response);
+ log('updateFlow: response id=%s name=%s', flow.id, flow.name);
+ return flow;
+}
+
+/**
+ * Validate a candidate `WorkflowGraph` via `openhuman.flows_validate`. Pure and
+ * cheap server-side (no config load), so it's safe to call on a debounce while
+ * editing. Returns {@link FlowValidation} — check `valid` to gate Save, and
+ * surface `warnings` separately (they never block).
+ */
+export async function validateFlow(graph: unknown): Promise {
+ log('validateFlow: request');
+ const response = await callCoreRpc({
+ method: 'openhuman.flows_validate',
+ params: { graph },
+ });
+ const validation = unwrapCliEnvelope(response);
+ log(
+ 'validateFlow: response valid=%s errors=%d warnings=%d',
+ validation.valid,
+ validation.errors.length,
+ validation.warnings.length
+ );
+ return validation;
+}
+
+/**
+ * List the secret-free credential references (composio + http) available to a
+ * node's config credential picker via `openhuman.flows_list_connections`. No
+ * params; returns the `FlowConnection[]` directly (same no-wrapper shape as
+ * `flows_list`).
+ */
+export async function listFlowConnections(): Promise {
+ log('listFlowConnections: request');
+ const response = await callCoreRpc({
+ method: 'openhuman.flows_list_connections',
+ params: {},
+ });
+ const connections = unwrapCliEnvelope(response);
+ log('listFlowConnections: response count=%d', connections.length);
+ return connections;
+}
+
+/**
+ * Import a workflow definition (native tinyflows JSON or an n8n export) via
+ * `openhuman.flows_import`. The server migrates + validates it host-side and
+ * returns the normalized graph plus non-fatal warnings WITHOUT persisting — the
+ * caller opens the result on the canvas as a draft and Saves via the existing
+ * `flows_create` gate. Rejects (throws) when the definition is structurally
+ * invalid or unparseable server-side, so the UI can surface a load error
+ * instead of opening a broken canvas.
+ */
+export async function importFlow(
+ graph: unknown,
+ format: FlowImportFormat = 'auto'
+): Promise {
+ log('importFlow: request format=%s', format);
+ const response = await callCoreRpc({
+ method: 'openhuman.flows_import',
+ params: { graph, format },
+ });
+ const result = unwrapCliEnvelope(response);
+ log('importFlow: response warnings=%d', result.warnings?.length ?? 0);
+ return result;
+}
+
export const flowsApi = {
createFlow,
+ importFlow,
resumeFlow,
listFlowRuns,
getFlowRun,
@@ -308,6 +451,9 @@ export const flowsApi = {
listFlows,
setFlowEnabled,
runFlow,
+ updateFlow,
+ validateFlow,
+ listFlowConnections,
};
export default flowsApi;
diff --git a/app/src/services/api/workflowsApi.test.ts b/app/src/services/api/skillsApi.test.ts
similarity index 84%
rename from app/src/services/api/workflowsApi.test.ts
rename to app/src/services/api/skillsApi.test.ts
index 1e6814525..c6ab8736a 100644
--- a/app/src/services/api/workflowsApi.test.ts
+++ b/app/src/services/api/skillsApi.test.ts
@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { workflowsApi } from './workflowsApi';
+import { skillsApi } from './skillsApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
-describe('workflowsApi', () => {
+describe('skillsApi', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
@@ -15,7 +15,7 @@ describe('workflowsApi', () => {
mockCallCoreRpc.mockResolvedValue({
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
});
- await workflowsApi.createWorkflow({
+ await skillsApi.createWorkflow({
name: 'S',
description: 'desc',
inputs: [{ name: 'repo', type: 'string' as const, description: 'repo', required: true }],
@@ -27,17 +27,17 @@ describe('workflowsApi', () => {
});
describe('describeWorkflow', () => {
- it('calls openhuman.workflows_describe with workflow_id', async () => {
+ it('calls openhuman.skills_describe with workflow_id', async () => {
mockCallCoreRpc.mockResolvedValue({
id: 'dev-workflow',
name: 'Dev Workflow',
description: 'Auto dev',
inputs: [],
});
- const result = await workflowsApi.describeWorkflow('dev-workflow');
+ const result = await skillsApi.describeWorkflow('dev-workflow');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
- method: 'openhuman.workflows_describe',
+ method: 'openhuman.skills_describe',
params: { workflow_id: 'dev-workflow' },
})
);
@@ -48,7 +48,7 @@ describe('workflowsApi', () => {
mockCallCoreRpc.mockResolvedValue({
data: { id: 'x', name: 'X', description: '', inputs: [], workflow_id: 'x' },
});
- const result = await workflowsApi.describeWorkflow('x');
+ const result = await skillsApi.describeWorkflow('x');
expect(result.id).toBe('x');
});
});
@@ -61,7 +61,7 @@ describe('workflowsApi', () => {
skill_id: 's',
log: '/tmp/log',
});
- const result = await workflowsApi.runWorkflow('s', { repo: 'owner/repo' });
+ const result = await skillsApi.runWorkflow('s', { repo: 'owner/repo' });
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_runtime_run',
@@ -82,7 +82,7 @@ describe('workflowsApi', () => {
content: 'log line',
offset: 100,
});
- const result = await workflowsApi.readRunLog('run-1');
+ const result = await skillsApi.readRunLog('run-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_runtime_read_run_log',
@@ -100,7 +100,7 @@ describe('workflowsApi', () => {
content: '',
offset: 500,
});
- await workflowsApi.readRunLog('run-2', 200, 4096);
+ await skillsApi.readRunLog('run-2', 200, 4096);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ run_id: 'run-2', offset: 200, max_bytes: 4096 }),
@@ -112,13 +112,13 @@ describe('workflowsApi', () => {
describe('recentRuns', () => {
it('returns scanned runs array', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
- const result = await workflowsApi.recentRuns();
+ const result = await skillsApi.recentRuns();
expect(Array.isArray(result)).toBe(true);
});
it('passes workflow_id filter when provided', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
- await workflowsApi.recentRuns('dev-workflow', 5);
+ await skillsApi.recentRuns('dev-workflow', 5);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_runtime_recent_runs',
@@ -133,7 +133,7 @@ describe('workflowsApi', () => {
mockCallCoreRpc.mockResolvedValue({
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
});
- await workflowsApi.createWorkflow({
+ await skillsApi.createWorkflow({
name: 'S',
description: 'desc',
whenToUse: 'when asked',
@@ -145,7 +145,7 @@ describe('workflowsApi', () => {
});
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
- method: 'openhuman.workflows_create',
+ method: 'openhuman.skills_create',
params: expect.objectContaining({
when_to_use: 'when asked',
scope: 'user',
@@ -162,18 +162,18 @@ describe('workflowsApi', () => {
mockCallCoreRpc.mockResolvedValue({
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
});
- await workflowsApi.createWorkflow({ name: 'S', description: 'd', whenToUse: ' ' });
+ await skillsApi.createWorkflow({ name: 'S', description: 'd', whenToUse: ' ' });
const params = mockCallCoreRpc.mock.calls[0][0].params;
expect(params).not.toHaveProperty('when_to_use');
});
});
describe('updateWorkflow', () => {
- it('calls openhuman.workflows_update and returns the skill', async () => {
+ it('calls openhuman.skills_update and returns the skill', async () => {
mockCallCoreRpc.mockResolvedValue({
workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const },
});
- const result = await workflowsApi.updateWorkflow({
+ const result = await skillsApi.updateWorkflow({
name: 'WF',
description: 'd',
whenToUse: 'edit trigger',
@@ -181,7 +181,7 @@ describe('workflowsApi', () => {
});
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
- method: 'openhuman.workflows_update',
+ method: 'openhuman.skills_update',
params: expect.objectContaining({
name: 'WF',
when_to_use: 'edit trigger',
@@ -196,7 +196,7 @@ describe('workflowsApi', () => {
mockCallCoreRpc.mockResolvedValue({
data: { workflow: { id: 'wf2', name: 'WF2', description: '', scope: 'user' as const } },
});
- const result = await workflowsApi.updateWorkflow({ name: 'WF2', description: 'd' });
+ const result = await skillsApi.updateWorkflow({ name: 'WF2', description: 'd' });
expect(result.id).toBe('wf2');
});
});
@@ -204,7 +204,7 @@ describe('workflowsApi', () => {
describe('cancelRun', () => {
it('calls openhuman.skill_runtime_cancel with run_id and returns cancelled', async () => {
mockCallCoreRpc.mockResolvedValue({ cancelled: true });
- const result = await workflowsApi.cancelRun('run-9');
+ const result = await skillsApi.cancelRun('run-9');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_runtime_cancel',
@@ -216,7 +216,7 @@ describe('workflowsApi', () => {
it('returns false when the run was not live (envelope shape)', async () => {
mockCallCoreRpc.mockResolvedValue({ data: { cancelled: false } });
- const result = await workflowsApi.cancelRun('gone');
+ const result = await skillsApi.cancelRun('gone');
expect(result).toBe(false);
});
});
@@ -224,7 +224,7 @@ describe('workflowsApi', () => {
describe('uninstallWorkflow', () => {
it('calls openhuman.skill_registry_uninstall and normalizes removed_path', async () => {
mockCallCoreRpc.mockResolvedValue({ name: 'demo', removed_path: '/tmp/demo', scope: 'user' });
- const result = await workflowsApi.uninstallWorkflow('demo');
+ const result = await skillsApi.uninstallWorkflow('demo');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_registry_uninstall',
@@ -251,7 +251,7 @@ describe('workflowsApi', () => {
},
],
});
- const result = await workflowsApi.resolveRuntimes('node');
+ const result = await skillsApi.resolveRuntimes('node');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skill_runtime_resolve_runtimes',
@@ -264,7 +264,7 @@ describe('workflowsApi', () => {
it('uses empty params object when runtime is "all" (the default)', async () => {
mockCallCoreRpc.mockResolvedValue({ runtimes: [] });
- await workflowsApi.resolveRuntimes('all');
+ await skillsApi.resolveRuntimes('all');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.skill_runtime_resolve_runtimes', params: {} })
@@ -274,7 +274,7 @@ describe('workflowsApi', () => {
it('uses empty params when called with no argument (default = all)', async () => {
mockCallCoreRpc.mockResolvedValue({ runtimes: [] });
- await workflowsApi.resolveRuntimes();
+ await skillsApi.resolveRuntimes();
const call = mockCallCoreRpc.mock.calls[0][0];
expect(call.params).toEqual({});
diff --git a/app/src/services/api/workflowsApi.ts b/app/src/services/api/skillsApi.ts
similarity index 95%
rename from app/src/services/api/workflowsApi.ts
rename to app/src/services/api/skillsApi.ts
index 371457795..ccfccb9c9 100644
--- a/app/src/services/api/workflowsApi.ts
+++ b/app/src/services/api/skillsApi.ts
@@ -2,7 +2,7 @@ import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
-const log = debug('workflowsApi');
+const log = debug('skillsApi');
/**
* Scope a skill was discovered in.
@@ -14,7 +14,7 @@ export type WorkflowScope = 'user' | 'project' | 'legacy';
/**
* Wire-format representation of a discovered skill returned by
- * `openhuman.workflows_list`.
+ * `openhuman.skills_list`.
*
* Paths are intentionally serialized as strings (not URLs) to avoid lossy
* conversions on non-UTF-8 filesystems.
@@ -67,7 +67,7 @@ type RawWorkflowSummary = Omit => {
log('listWorkflows: request includeSkills=%s', opts?.includeSkills ?? false);
const response = await callCoreRpc | WorkflowsListResult>({
- method: 'openhuman.workflows_list',
+ method: 'openhuman.skills_list',
params: opts?.includeSkills ? { include_skills: true } : undefined,
});
const result = unwrapEnvelope(response);
@@ -289,7 +289,7 @@ export const workflowsApi = {
const response = await callCoreRpc<
Envelope | RawWorkflowsReadResourceResult
>({
- method: 'openhuman.workflows_read_resource',
+ method: 'openhuman.skills_read_resource',
params: { workflow_id: workflowId, relative_path: relativePath },
});
const raw = unwrapEnvelope(response);
@@ -304,7 +304,7 @@ export const workflowsApi = {
},
/**
- * Scaffold a new SKILL.md skill via `openhuman.workflows_create`.
+ * Scaffold a new SKILL.md skill via `openhuman.skills_create`.
*
* The Rust side slugifies the name, writes `SKILL.md` with the supplied
* frontmatter, and returns the freshly-discovered `WorkflowSummary` so the
@@ -315,7 +315,7 @@ export const workflowsApi = {
const response = await callCoreRpc<
Envelope | RawWorkflowsCreateResult
>({
- method: 'openhuman.workflows_create',
+ method: 'openhuman.skills_create',
params: {
name: input.name,
description: input.description,
@@ -337,7 +337,7 @@ export const workflowsApi = {
},
/**
- * Edit an existing workflow via `openhuman.workflows_update`. Same payload
+ * Edit an existing workflow via `openhuman.skills_update`. Same payload
* shape as create; the Rust side overwrites the workflow at the resolved
* slug — rewriting frontmatter + workflow.toml while preserving the
* hand-authored SKILL.md/WORKFLOW.md body.
@@ -347,7 +347,7 @@ export const workflowsApi = {
const response = await callCoreRpc<
Envelope | RawWorkflowsCreateResult
>({
- method: 'openhuman.workflows_update',
+ method: 'openhuman.skills_update',
params: {
name: input.name,
description: input.description,
@@ -369,7 +369,7 @@ export const workflowsApi = {
},
/**
- * Install a remote SKILL.md by URL via `openhuman.workflows_install_from_url`.
+ * Install a remote SKILL.md by URL via `openhuman.skills_install_from_url`.
*
* The Rust side fetches the SKILL.md directly over HTTPS (no subprocess,
* no Node toolchain required), validates the frontmatter, and writes it
@@ -385,7 +385,7 @@ export const workflowsApi = {
const response = await callCoreRpc<
Envelope | RawInstallWorkflowFromUrlResult
>({
- method: 'openhuman.workflows_install_from_url',
+ method: 'openhuman.skills_install_from_url',
params: {
url: input.url,
...(input.timeoutSecs !== undefined ? { timeout_secs: input.timeoutSecs } : {}),
@@ -446,7 +446,7 @@ export const workflowsApi = {
describeWorkflow: async (workflowId: string): Promise => {
log('describeWorkflow: request workflowId=%s', workflowId);
const response = await callCoreRpc | WorkflowDescription>({
- method: 'openhuman.workflows_describe',
+ method: 'openhuman.skills_describe',
params: { workflow_id: workflowId },
});
const raw = unwrapEnvelope(response);
@@ -574,7 +574,7 @@ export const workflowsApi = {
/**
* One input declaration from a skill's `[[inputs]]` block, returned by
- * `openhuman.workflows_describe`. The FE renders one form control per entry:
+ * `openhuman.skills_describe`. The FE renders one form control per entry:
* `string`/`integer`/`boolean` map to text/number/checkbox controls.
*/
export interface WorkflowInputDescription {
@@ -585,7 +585,7 @@ export interface WorkflowInputDescription {
type: string;
}
-/** Wire shape returned by `openhuman.workflows_describe`. */
+/** Wire shape returned by `openhuman.skills_describe`. */
export interface WorkflowDescription {
id: string;
display_name: string;
diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx
index 6743a29e4..beda18964 100644
--- a/app/src/test/test-utils.tsx
+++ b/app/src/test/test-utils.tsx
@@ -15,6 +15,7 @@ import { CoreStateContext } from '../providers/coreStateContext';
import accountsReducer from '../store/accountsSlice';
import backendMeetReducer from '../store/backendMeetSlice';
import channelConnectionsReducer from '../store/channelConnectionsSlice';
+import chatRuntimeReducer from '../store/chatRuntimeSlice';
import companionReducer from '../store/companionSlice';
import connectivityReducer from '../store/connectivitySlice';
import coreModeReducer from '../store/coreModeSlice';
@@ -43,6 +44,7 @@ const testRootReducer = combineReducers({
accounts: accountsReducer,
backendMeet: backendMeetReducer,
channelConnections: channelConnectionsReducer,
+ chatRuntime: chatRuntimeReducer,
companion: companionReducer,
connectivity: connectivityReducer,
coreMode: coreModeReducer,
diff --git a/app/test/e2e/specs/flows.spec.ts b/app/test/e2e/specs/flows.spec.ts
new file mode 100644
index 000000000..8ee3216d8
--- /dev/null
+++ b/app/test/e2e/specs/flows.spec.ts
@@ -0,0 +1,199 @@
+// @ts-nocheck
+/**
+ * Tinyflows E2E — Workflows create → run → inspect happy path (Phase 6).
+ *
+ * Drives the real product UI end-to-end (renderer → coreRpcClient → Tauri relay
+ * → in-process Rust core, which runs the tinyflows engine locally):
+ *
+ * 1. Open the Workflows list (`/flows`, `FlowsPage`).
+ * 2. Create a workflow from the "New workflow" chooser — the
+ * "Start from scratch" path (`NewWorkflowModal` → `useCreateFlow` →
+ * `openhuman.flows_create`), which persists a minimal single-`manual`-
+ * trigger graph and opens it on the editable canvas (`/flows/:id`).
+ * 3. Run it from the canvas (`FlowCanvasPage` Run button →
+ * `openhuman.flows_run`). A trigger-only graph runs to completion in the
+ * local engine with no external calls, so it's deterministic under the
+ * mock backend.
+ * 4. Return to the list, open the flow's run history (`FlowRunsDrawer`), pick
+ * the run, and assert the run inspector (`FlowRunInspectorDrawer`) shows a
+ * terminal status (Completed) with at least one recorded step (the trigger
+ * node reconstructs one — see `settle_steps`/`reconstruct_steps` in
+ * `src/openhuman/flows/ops.rs`).
+ *
+ * Follows the reference structure in `cron-jobs-flow.spec.ts`: ONE Appium
+ * session, `resetApp()` for a fresh-install baseline, then real
+ * UI clicks. Everything is targeted via stable `data-testid`s already exposed
+ * by the flows components (no raw platform selectors) — the unified Chromium/CDP
+ * driver exposes the DOM on all three OSes.
+ */
+import { waitForApp } from '../helpers/app-helpers';
+import { clickTestId, waitForTestId } from '../helpers/element-helpers';
+import { resetApp } from '../helpers/reset-app';
+import { navigateViaHash } from '../helpers/shared-flows';
+import { startMockServer, stopMockServer } from '../mock-server';
+
+const USER_ID = 'e2e-flows';
+
+/** Terminal run-status pill labels (see `flowRuns.status.*` in en.ts). */
+const TERMINAL_STATUS_LABELS = ['Completed', 'Failed'];
+
+/** Captured once the scratch flow is created — its `/flows/:id` route id. */
+let createdFlowId: string | null = null;
+
+function stepLog(message: string, context?: unknown): void {
+ const stamp = new Date().toISOString();
+ if (context === undefined) {
+ console.log(`[FlowsE2E][${stamp}] ${message}`);
+ return;
+ }
+ console.log(`[FlowsE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
+}
+
+/** Read the current renderer hash (e.g. `#/flows/`). */
+async function currentHash(): Promise {
+ return browser.execute(() => window.location.hash);
+}
+
+/** Open the Workflows list page and wait for it to render. */
+async function openFlowsPage(): Promise {
+ await navigateViaHash('/flows');
+ await waitForTestId('flows-page', 15_000);
+}
+
+describe('Workflows create → run → inspect (real UI flow)', () => {
+ before(async function () {
+ // waitForApp() + resetApp() can exceed the default 30s Mocha hook budget.
+ this.timeout(120_000);
+ await startMockServer();
+ await waitForApp();
+ await resetApp(USER_ID);
+ });
+
+ after(async () => {
+ await stopMockServer();
+ });
+
+ it('opens the Workflows list from the /flows route', async function () {
+ this.timeout(30_000);
+ await openFlowsPage();
+ // Fresh install → the empty-state "New workflow" affordance is present.
+ // The header action button is always rendered regardless of list state.
+ await waitForTestId('flows-new-workflow', 10_000);
+ });
+
+ it('creates a workflow from scratch and lands on the editable canvas', async function () {
+ this.timeout(45_000);
+
+ // Open the Phase 4a chooser and pick "Start from scratch".
+ await clickTestId('flows-new-workflow', 10_000);
+ await waitForTestId('new-workflow-modal', 10_000);
+ await clickTestId('new-workflow-scratch', 10_000);
+
+ // useCreateFlow persists the blank graph via flows_create, then navigates
+ // to the new flow's canvas at /flows/:id. Wait for the hash to settle on a
+ // concrete id (not the bare /flows list route) and capture it.
+ await browser.waitUntil(
+ async () => {
+ const hash = await currentHash();
+ const match = /#\/flows\/([^/]+)$/.exec(hash);
+ if (match && match[1] !== 'draft') {
+ createdFlowId = match[1];
+ return true;
+ }
+ return false;
+ },
+ {
+ timeout: 20_000,
+ interval: 300,
+ timeoutMsg: 'canvas route /flows/:id never became active after create',
+ }
+ );
+ stepLog('created flow', { createdFlowId });
+ expect(createdFlowId).toBeTruthy();
+
+ // The editable canvas mounted with a runnable flow (Run button present only
+ // for a persisted, non-draft flow).
+ await waitForTestId('flow-canvas-page', 15_000);
+ await waitForTestId('flow-canvas-run', 15_000);
+ });
+
+ it('runs the flow from the canvas without error', async function () {
+ this.timeout(60_000);
+
+ await clickTestId('flow-canvas-run', 10_000);
+
+ // The run drives the local tinyflows engine. A trigger-only graph settles
+ // almost immediately; assert no run-error banner appeared. (The Run button
+ // flips to "Running…" and back — we don't assert on that transient state.)
+ await browser.pause(2_000);
+ const errorBanner = await browser.$('[data-testid="flow-canvas-run-error"]');
+ expect(await errorBanner.isExisting()).toBe(false);
+ });
+
+ it('inspects the run: terminal status with at least one step', async function () {
+ this.timeout(90_000);
+
+ // Back to the Workflows list to reach this flow's run history.
+ await openFlowsPage();
+ expect(createdFlowId).toBeTruthy();
+ const flowId = createdFlowId as string;
+
+ // The created flow's row is present; open its run-history drawer.
+ await waitForTestId(`flow-row-${flowId}`, 15_000);
+ await clickTestId(`flow-view-runs-${flowId}`, 10_000);
+ await waitForTestId('flow-runs-drawer', 10_000);
+
+ // At least one run row exists (the run we just kicked off). Run rows are
+ // keyed by run id, which the spec doesn't know ahead of time, so target the
+ // first by testid prefix. Poll: the list is a one-shot fetch on open, and
+ // the run row is written when the engine settles.
+ const runRowSelector = '[data-testid^="flow-run-row-"]';
+ await browser.waitUntil(
+ async () => {
+ const rows = await browser.$$(runRowSelector);
+ if (rows.length > 0) return true;
+ // Re-open the drawer to re-fetch the (now-settled) run list.
+ await clickTestId('flow-runs-close', 5_000).catch(() => undefined);
+ await clickTestId(`flow-view-runs-${flowId}`, 5_000).catch(() => undefined);
+ await waitForTestId('flow-runs-drawer', 5_000).catch(() => undefined);
+ return false;
+ },
+ {
+ timeout: 30_000,
+ interval: 1_500,
+ timeoutMsg: 'no run rows appeared in the flow runs drawer',
+ }
+ );
+
+ const firstRunRow = await browser.$(runRowSelector);
+ await firstRunRow.click();
+
+ // The run inspector stacks on top; it polls flows_get_run until terminal.
+ await waitForTestId('flow-run-inspector-drawer', 15_000);
+ await waitForTestId('flow-run-status-pill', 15_000);
+
+ // Poll the status pill until it reads a terminal status (Completed/Failed).
+ const pill = await browser.$('[data-testid="flow-run-status-pill"]');
+ await browser.waitUntil(
+ async () => {
+ const text = (await pill.getText().catch(() => '')) ?? '';
+ return TERMINAL_STATUS_LABELS.some(label => text.includes(label));
+ },
+ {
+ timeout: 45_000,
+ interval: 1_500,
+ timeoutMsg: 'run never reached a terminal status in the inspector',
+ }
+ );
+ const statusText = await pill.getText();
+ stepLog('run inspector status', { statusText });
+ // The trigger-only graph must succeed — assert it specifically completed.
+ expect(statusText).toContain('Completed');
+
+ // At least one step is recorded (the trigger node reconstructs one step).
+ const steps = await browser.$('[data-testid="flow-run-steps"]');
+ await steps.waitForExist({ timeout: 15_000 });
+ const firstStep = await browser.$('[data-testid="flow-run-step-0"]');
+ expect(await firstStep.isExisting()).toBe(true);
+ });
+});
diff --git a/src/core/all.rs b/src/core/all.rs
index 9b4a7d07b..8644d0ba3 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -214,7 +214,7 @@ fn build_registered_controllers() -> Vec {
// Managed Node.js runtime bridge (tool listing + dispatch)
controllers.extend(crate::openhuman::javascript::all_javascript_registered_controllers());
// Discovered SKILL.md skills and their bundled resources
- controllers.extend(crate::openhuman::workflows::all_workflows_registered_controllers());
+ controllers.extend(crate::openhuman::skills::all_skills_registered_controllers());
// Skill runtime: run/cancel/log skill executions and resolve Node/Python toolchains
controllers.extend(crate::openhuman::skill_runtime::all_skill_runtime_registered_controllers());
// Skill registry: browse, search, install from remote registries
@@ -427,7 +427,7 @@ fn build_declared_controller_schemas() -> Vec {
schemas.extend(crate::openhuman::sandbox::all_sandbox_controller_schemas());
schemas.extend(crate::openhuman::socket::all_socket_controller_schemas());
schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas());
- schemas.extend(crate::openhuman::workflows::all_workflows_controller_schemas());
+ schemas.extend(crate::openhuman::skills::all_skills_controller_schemas());
schemas.extend(crate::openhuman::skill_runtime::all_skill_runtime_controller_schemas());
schemas.extend(crate::openhuman::skill_registry::all_skill_registry_controller_schemas());
schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas());
@@ -571,7 +571,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
}
"skill_registry" => Some("Browse, search, install, and uninstall skills from remote registries (OpenHuman, Hermes, OpenClaw)."),
"skill_runtime" => Some("Run installed skills, inspect run logs, and resolve Node/Python skill runtimes."),
- "workflows" => Some("Discovered workflows (WORKFLOW.md/SKILL.md bundles) and their resources."),
+ "skills" => Some("Discovered SKILL.md skills (discovery, parse, install, run) and their resources."),
"socket" => Some("Backend Socket.IO bridge controls."),
"memory" => Some("Document storage, vector search, key-value store, and knowledge graph."),
"memory_goals" => Some(
diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs
index 76c41f254..e8da0d201 100644
--- a/src/core/jsonrpc.rs
+++ b/src/core/jsonrpc.rs
@@ -1859,7 +1859,7 @@ async fn run_server_inner(
// / pr-review-shepherd) that older builds seeded into
// /skills/. OpenHuman no longer ships bundled defaults;
// this removes the stale dirs on upgrade. Idempotent.
- crate::openhuman::workflows::registry::prune_legacy_default_workflows(
+ crate::openhuman::skills::registry::prune_legacy_default_workflows(
&cfg.workspace_dir,
);
// Boot-time Sentry user binding — issue #3135. If the user is
@@ -2593,7 +2593,7 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) {
// installs. Idempotent — shares a process-global OnceLock with the
// `start_channels` site so it registers exactly once regardless of which
// path runs first. (Matching only for now; activation handoff still pending.)
- crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace_dir);
+ crate::openhuman::skills::bus::ensure_triggered_workflow_subscriber(&workspace_dir);
// --- Approval gate (#1339) ---
// ON by default; opt out with `OPENHUMAN_APPROVAL_GATE=0` (or `false`).
diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs
index da7eee409..f58200709 100644
--- a/src/openhuman/agent/harness/builtin_definitions.rs
+++ b/src/openhuman/agent/harness/builtin_definitions.rs
@@ -266,6 +266,7 @@ mod tests {
"critic",
"archivist",
"summarizer",
+ "workflow_builder",
] {
assert!(ids.contains(&expected.to_string()), "missing {expected}");
}
diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs
index 95dbe898f..73ccf32fc 100644
--- a/src/openhuman/agent/harness/fork_context.rs
+++ b/src/openhuman/agent/harness/fork_context.rs
@@ -14,8 +14,8 @@ use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::config::AgentConfig;
use crate::openhuman::inference::provider::Provider;
use crate::openhuman::memory::Memory;
+use crate::openhuman::skills::Workflow;
use crate::openhuman::tools::{Tool, ToolSpec};
-use crate::openhuman::workflows::Workflow;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs
index f8a0d3f38..9f246df6d 100644
--- a/src/openhuman/agent/harness/session/builder/factory.rs
+++ b/src/openhuman/agent/harness/session/builder/factory.rs
@@ -1201,7 +1201,7 @@ impl Agent {
.temperature(effective_temperature)
.workspace_dir(config.workspace_dir.clone())
.action_dir(config.action_dir.clone())
- .workflows(crate::openhuman::workflows::load_workflow_metadata(
+ .workflows(crate::openhuman::skills::load_workflow_metadata(
&config.workspace_dir,
))
.auto_save(config.memory.auto_save)
diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs
index d2eac5636..523fa8a4c 100644
--- a/src/openhuman/agent/harness/session/builder/setters.rs
+++ b/src/openhuman/agent/harness/session/builder/setters.rs
@@ -169,7 +169,7 @@ impl AgentBuilder {
}
/// Sets the skills available to the agent.
- pub fn workflows(mut self, skills: Vec) -> Self {
+ pub fn workflows(mut self, skills: Vec) -> Self {
self.workflows = Some(skills);
self
}
diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs
index b37b78af1..c39173012 100644
--- a/src/openhuman/agent/harness/session/runtime.rs
+++ b/src/openhuman/agent/harness/session/runtime.rs
@@ -115,7 +115,7 @@ impl Agent {
}
/// The agent's loaded workflows, if any.
- pub fn workflows(&self) -> &[crate::openhuman::workflows::Workflow] {
+ pub fn workflows(&self) -> &[crate::openhuman::skills::Workflow] {
&self.workflows
}
diff --git a/src/openhuman/agent/harness/session/runtime_tests.rs b/src/openhuman/agent/harness/session/runtime_tests.rs
index cc5e7a842..2dc114fa1 100644
--- a/src/openhuman/agent/harness/session/runtime_tests.rs
+++ b/src/openhuman/agent/harness/session/runtime_tests.rs
@@ -323,7 +323,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() {
});
let mut agent = make_agent(provider);
agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))];
- agent.workflows = vec![crate::openhuman::workflows::Workflow {
+ agent.workflows = vec![crate::openhuman::skills::Workflow {
name: "demo".into(),
..Default::default()
}];
diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs
index f96405ca2..b2063a568 100644
--- a/src/openhuman/agent/harness/session/tests.rs
+++ b/src/openhuman/agent/harness/session/tests.rs
@@ -487,7 +487,7 @@ fn skill_listener_closed_channel_nulls_rx_and_is_not_a_signal() {
#[test]
fn refresh_workflows_picks_up_skill_installed_on_disk() {
- use crate::openhuman::workflows::ops_types::{SKILL_MD, TRUST_MARKER};
+ use crate::openhuman::skills::ops_types::{SKILL_MD, TRUST_MARKER};
// Isolated, trusted workspace with one project-scope skill on disk.
let ws = tempfile::TempDir::new().expect("temp workspace");
@@ -553,7 +553,7 @@ fn refresh_workflows_picks_up_skill_installed_on_disk() {
#[test]
fn refresh_workflows_retracts_skill_removed_from_disk() {
- use crate::openhuman::workflows::ops_types::{SKILL_MD, TRUST_MARKER};
+ use crate::openhuman::skills::ops_types::{SKILL_MD, TRUST_MARKER};
let ws = tempfile::TempDir::new().expect("temp workspace");
let wsp = ws.path().to_path_buf();
diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs
index a9b6db998..980f7f3db 100644
--- a/src/openhuman/agent/harness/session/turn/tools.rs
+++ b/src/openhuman/agent/harness/session/turn/tools.rs
@@ -319,14 +319,14 @@ impl Agent {
/// Returns `true` when the installed set changed. Cheap no-op when it
/// hasn't: a directory scan plus an id-set comparison, no prompt rebuild.
pub(in super::super) fn refresh_workflows(&mut self, trigger: &str) -> bool {
- let id_of = |w: &crate::openhuman::workflows::Workflow| -> String {
+ let id_of = |w: &crate::openhuman::skills::Workflow| -> String {
if w.dir_name.is_empty() {
w.name.clone()
} else {
w.dir_name.clone()
}
};
- let latest = crate::openhuman::workflows::load_workflow_metadata(&self.workspace_dir);
+ let latest = crate::openhuman::skills::load_workflow_metadata(&self.workspace_dir);
let current_ids: std::collections::HashSet =
self.workflows.iter().map(&id_of).collect();
let latest_ids: std::collections::HashSet = latest.iter().map(&id_of).collect();
diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs
index 0c6b06f82..ae4afa687 100644
--- a/src/openhuman/agent/harness/session/turn_tests.rs
+++ b/src/openhuman/agent/harness/session/turn_tests.rs
@@ -449,7 +449,7 @@ fn trim_history_snaps_past_orphaned_tool_results() {
fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
let mut agent = make_agent(None);
agent.last_memory_context = Some("remember this".into());
- agent.workflows = vec![crate::openhuman::workflows::Workflow {
+ agent.workflows = vec![crate::openhuman::skills::Workflow {
name: "demo".into(),
..Default::default()
}];
diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs
index 24f9d293e..b3353705d 100644
--- a/src/openhuman/agent/harness/session/types.rs
+++ b/src/openhuman/agent/harness/session/types.rs
@@ -60,7 +60,7 @@ pub struct Agent {
pub(super) temperature: f64,
pub(super) workspace_dir: std::path::PathBuf,
pub(super) action_dir: std::path::PathBuf,
- pub(super) workflows: Vec,
+ pub(super) workflows: Vec,
/// Agent workflows discovered at session start.
pub(super) auto_save: bool,
/// Last memory context loaded for the current turn. Stored so it can
@@ -329,7 +329,7 @@ pub struct AgentBuilder {
pub(super) temperature: Option,
pub(super) workspace_dir: Option,
pub(super) action_dir: Option,
- pub(super) workflows: Option>,
+ pub(super) workflows: Option>,
/// Agent workflows to surface in the prompt. Populated from `load_workflows`
/// at session start; defaults to empty when not explicitly set.
pub(super) auto_save: Option,
diff --git a/src/openhuman/agent/prompts/render_helpers.rs b/src/openhuman/agent/prompts/render_helpers.rs
index 1a349a816..993198c7c 100644
--- a/src/openhuman/agent/prompts/render_helpers.rs
+++ b/src/openhuman/agent/prompts/render_helpers.rs
@@ -700,7 +700,7 @@ pub fn default_workspace_file_content(filename: &str) -> &'static str {
/// manufacture a full context when they only need the static text.
fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
static EMPTY_TOOLS: &[PromptTool<'static>] = &[];
- static EMPTY_WORKFLOWS: &[crate::openhuman::workflows::Workflow] = &[];
+ static EMPTY_WORKFLOWS: &[crate::openhuman::skills::Workflow] = &[];
static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[];
// SAFETY: the &HashSet reference must outlive the returned context;
// a leaked OnceLock-style allocation gives us a permanent 'static
diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs
index fd79baf78..88c543cdb 100644
--- a/src/openhuman/agent/prompts/types.rs
+++ b/src/openhuman/agent/prompts/types.rs
@@ -6,8 +6,8 @@
//! the sibling `mod.rs` so type edits don't pull in the whole 2 000-line
//! renderer.
+use crate::openhuman::skills::Workflow;
use crate::openhuman::tools::Tool;
-use crate::openhuman::workflows::Workflow;
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::path::Path;
diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs
index 97bca3d07..a910c1e93 100644
--- a/src/openhuman/agent/task_dispatcher/executor.rs
+++ b/src/openhuman/agent/task_dispatcher/executor.rs
@@ -69,8 +69,7 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) ->
}
// 2) Workflow (#2824): the same autonomous run, seeded with SKILL.md.
- if let Some(skill) = crate::openhuman::workflows::registry::get_workflow(workspace_dir, handle)
- {
+ if let Some(skill) = crate::openhuman::skills::registry::get_workflow(workspace_dir, handle) {
let guidelines = match &skill.definition.system_prompt {
PromptSource::Inline(s) => truncate_chars(s, EXECUTOR_PREAMBLE_MAX_CHARS),
_ => String::new(),
diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs
index 1d7ea3a3b..c438f9e3e 100644
--- a/src/openhuman/agent/tools/run_workflow.rs
+++ b/src/openhuman/agent/tools/run_workflow.rs
@@ -34,8 +34,8 @@ use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_background};
+use crate::openhuman::skills::schemas::resolve_workspace_dir;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
-use crate::openhuman::workflows::schemas::resolve_workspace_dir;
/// Tool name surfaced to the LLM's function-calling schema.
pub const RUN_WORKFLOW_TOOL_NAME: &str = "run_workflow";
@@ -178,7 +178,7 @@ fn outcome_to_result(
run_id: &str,
workflow_id: &str,
log_path: &std::path::Path,
- outcome: Option,
+ outcome: Option,
) -> ToolResult {
match outcome {
Some(o) => ToolResult::success(
@@ -448,7 +448,7 @@ impl Tool for AwaitWorkflowTool {
let workspace = resolve_workspace_dir().await;
let log_path =
- match crate::openhuman::workflows::run_log::find_run_log_path(&workspace, &run_id) {
+ match crate::openhuman::skills::run_log::find_run_log_path(&workspace, &run_id) {
Some(p) => p,
None => {
return Ok(ToolResult::error(format!(
diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs
index 85448d4c2..681400959 100644
--- a/src/openhuman/agent_registry/agents/loader.rs
+++ b/src/openhuman/agent_registry/agents/loader.rs
@@ -299,6 +299,15 @@ pub const BUILTINS: &[BuiltinAgent] = &[
prompt_fn: crate::openhuman::orchestration::reasoning_agent::prompt::build,
graph_fn: Some(crate::openhuman::orchestration::reasoning_agent::graph::graph),
},
+ // Workflow-authoring specialist (Phase 5a): builds tinyflows automation
+ // graphs from natural language and returns a validated PROPOSAL — it never
+ // persists or enables a flow. Deliberately narrow propose-or-read tool belt.
+ BuiltinAgent {
+ id: "workflow_builder",
+ toml: include_str!("workflow_builder/agent.toml"),
+ prompt_fn: super::workflow_builder::prompt::build,
+ graph_fn: None,
+ },
];
/// Parse every entry in [`BUILTINS`] into an [`AgentDefinition`].
@@ -965,6 +974,76 @@ mod tests {
assert!(def.subagents.is_empty());
}
+ #[test]
+ fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() {
+ // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose
+ // tool scope is EXACTLY the propose-or-read belt — no persistence
+ // (flows_create/update/set_enabled), no shell, no file writes, no
+ // channel sends. This pins the "propose, never persist" invariant in
+ // the agent definition itself, not just the tool implementations.
+ let def = find("workflow_builder");
+ assert_eq!(def.agent_tier, AgentTier::Worker);
+ assert_eq!(def.delegate_name.as_deref(), Some("build_workflow"));
+ assert_eq!(def.sandbox_mode, SandboxMode::None);
+ // Worker leaf: no onward delegation.
+ assert!(
+ def.subagents.is_empty(),
+ "workflow_builder is a leaf and must not list subagents"
+ );
+ match &def.tools {
+ ToolScope::Named(names) => {
+ let expected = [
+ "propose_workflow",
+ "revise_workflow",
+ "list_flows",
+ "get_flow",
+ "get_flow_run",
+ "list_flow_connections",
+ "search_tool_catalog",
+ "dry_run_workflow",
+ ];
+ for required in expected {
+ assert!(
+ names.iter().any(|n| n == required),
+ "workflow_builder tool list missing `{required}`"
+ );
+ }
+ assert_eq!(
+ names.len(),
+ expected.len(),
+ "workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})"
+ );
+ // Hard exclusions: nothing that persists, executes, or sends.
+ for forbidden in [
+ "flows_create",
+ "flows_update",
+ "flows_set_enabled",
+ "shell",
+ "file_write",
+ "edit",
+ "apply_patch",
+ "composio_execute",
+ "spawn_subagent",
+ ] {
+ assert!(
+ !names.iter().any(|n| n == forbidden),
+ "workflow_builder must NOT have `{forbidden}` — propose/read only"
+ );
+ }
+ }
+ ToolScope::Wildcard => panic!("workflow_builder must have a Named tool scope"),
+ }
+
+ // Reachable by delegation from the orchestrator (Phase 5 routing).
+ let orchestrator = find("orchestrator");
+ assert!(
+ orchestrator.subagents.iter().any(
+ |entry| matches!(entry, SubagentEntry::AgentId(id) if id == "workflow_builder")
+ ),
+ "orchestrator must allow `workflow_builder` so build_workflow can spawn it"
+ );
+ }
+
#[test]
fn tinyplace_agent_is_registered_and_narrow() {
let def = find("tinyplace_agent");
diff --git a/src/openhuman/agent_registry/agents/mod.rs b/src/openhuman/agent_registry/agents/mod.rs
index aa30640cb..a7e721b76 100644
--- a/src/openhuman/agent_registry/agents/mod.rs
+++ b/src/openhuman/agent_registry/agents/mod.rs
@@ -36,5 +36,6 @@ pub mod trigger_reactor;
pub mod trigger_triage;
pub mod video_agent;
pub mod vision_agent;
+pub mod workflow_builder;
pub use loader::{load_builtins, validate_tier_hierarchy, BuiltinAgent, BUILTINS};
diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml
index 0fcc42d4d..2b74eae99 100644
--- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml
+++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml
@@ -154,6 +154,13 @@ allowlist = [
# SKILL.md instructions and executing the procedure.
"skill_setup",
"skill_executor",
+ # Workflow-authoring specialist (Phase 5a). Synthesised into a
+ # `build_workflow` tool at agent-build time. Route any "set up a workflow
+ # that…", "automate…", "when X happens do Y", or "build/edit an automation
+ # or flow" request here — it grounds nodes in real tool slugs + connections,
+ # can sandbox dry-run a draft, and returns a workflow PROPOSAL for the user
+ # to review and save. It never creates/enables/runs a real flow itself.
+ "workflow_builder",
# NOTE: `summarizer` used to be listed here for the runtime-only
# oversized-tool-result hook. That path is currently disabled
# (`context.summarizer_payload_threshold_tokens = 0`) after recursive
@@ -298,7 +305,7 @@ named = [
# what's installed and learn a workflow's inputs before running it; the
# `*_run*` read tools let it inspect a run's status + log. All route
# through `workflows::schemas::spawn_skill_run_background` +
- # `await_run_outcome` — the same spawn path the `openhuman.workflows_run`
+ # `await_run_outcome` — the same spawn path the `openhuman.skills_run`
# JSON-RPC controller uses (iter cap, transcript isolation,
# degenerate-response detector all apply).
"list_workflows",
diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs
index 479495dd2..e39b21a5f 100644
--- a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs
+++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs
@@ -14,8 +14,8 @@
use crate::openhuman::context::prompt::{
render_datetime, render_tools, render_user_files, ConnectedIntegration, PromptContext,
};
+use crate::openhuman::skills::ops_types::Workflow;
use crate::openhuman::tools::orchestrator_tools::sanitise_slug;
-use crate::openhuman::workflows::ops_types::Workflow;
use anyhow::Result;
use std::fmt::Write;
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/agent.toml b/src/openhuman/agent_registry/agents/workflow_builder/agent.toml
new file mode 100644
index 000000000..420416093
--- /dev/null
+++ b/src/openhuman/agent_registry/agents/workflow_builder/agent.toml
@@ -0,0 +1,38 @@
+id = "workflow_builder"
+display_name = "Workflow Builder"
+delegate_name = "build_workflow"
+when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, can dry-run a draft in a sandbox to self-check, and returns a workflow PROPOSAL for the user to review and save — it NEVER creates, enables, or runs a real flow itself. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'."
+temperature = 0.2
+max_iterations = 12
+iteration_policy = "extended"
+max_result_chars = 12000
+# No sandbox filter needed: every tool in scope is propose-or-read, and
+# dry_run_workflow runs against tinyflows MOCK capabilities (no real effects).
+sandbox_mode = "none"
+omit_identity = true
+omit_memory_context = true
+# Safety preamble stays OFF: this agent has zero real external effect (it only
+# proposes validated graphs and reads), so the general acting guard-rails would
+# be noise. The "propose, never persist" invariant is taught in prompt.md.
+omit_safety_preamble = true
+omit_skills_catalog = true
+
+[model]
+# Structured authoring on the cheap, high-throughput worker tier.
+hint = "burst"
+
+[tools]
+# DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs,
+# connections, tool catalog) + sandbox dry-run. NO shell, NO file writes, NO
+# channel sends, NO flows_create/update/set_enabled — the agent can never
+# persist or enable a flow.
+named = [
+ "propose_workflow",
+ "revise_workflow",
+ "list_flows",
+ "get_flow",
+ "get_flow_run",
+ "list_flow_connections",
+ "search_tool_catalog",
+ "dry_run_workflow",
+]
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/mod.rs b/src/openhuman/agent_registry/agents/workflow_builder/mod.rs
new file mode 100644
index 000000000..8bf84783c
--- /dev/null
+++ b/src/openhuman/agent_registry/agents/workflow_builder/mod.rs
@@ -0,0 +1 @@
+pub mod prompt;
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/prompt.md b/src/openhuman/agent_registry/agents/workflow_builder/prompt.md
new file mode 100644
index 000000000..351455ec6
--- /dev/null
+++ b/src/openhuman/agent_registry/agents/workflow_builder/prompt.md
@@ -0,0 +1,130 @@
+# Workflow Builder
+
+You are the **Workflow Builder**, a specialist that turns a plain-language
+automation request ("every morning summarize my unread email and post it to
+Slack", "when a new Stripe payment arrives, add a row to my sheet") into a
+concrete **tinyflows `WorkflowGraph`** and returns it as a *proposal* for the
+user to review and save.
+
+## The one invariant you must never break: propose, never persist
+
+You **cannot and must not** create, update, enable, disable, or run a real saved
+flow. You have no tool that does — by design. Your only outputs are:
+
+- **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate
+ graph and hand back a proposal summary. They **never** save anything.
+- **`dry_run_workflow`** — runs a *draft* in a **sandbox** against mock
+ capabilities (deterministic echoes). Nothing real happens: no message is sent,
+ no code runs, no HTTP fires. Treat its output as a wiring check only.
+
+Only the user's own "Save & enable" click in the review card persists a flow
+(via `flows_create`, which re-validates server-side). If a user says "just turn
+it on for me", explain that you can only propose it — they confirm the save.
+
+## Your authoring loop
+
+1. **Understand the trigger and the steps.** What starts the flow? What should
+ happen, in order? What branches on a condition?
+2. **Ground it in reality before you build:**
+ - `list_flow_connections` → the exact `connection_ref` values available
+ (Composio accounts + named HTTP creds). Put these verbatim on nodes that
+ act on a connected account. Never invent a connection.
+ - `search_tool_catalog` → real Composio action **slugs** for `tool_call`
+ nodes. **Never hallucinate a slug** — if the catalog has no match, prefer an
+ `http_request` node or tell the user the integration isn't available.
+ - `list_flows` / `get_flow` → reuse or clone an existing flow instead of
+ duplicating one.
+3. **Build the graph** (see the model below).
+4. **Self-check with `dry_run_workflow`** on the draft — catch missing edges,
+ wrong ports, unreachable nodes. Fix and re-run.
+5. **`propose_workflow`** (first draft) or **`revise_workflow`** (iterating on a
+ prior draft — apply the change to the existing graph, don't regenerate from
+ scratch). If validation fails, read the error, fix the graph, call again.
+6. **Debugging a broken saved flow?** `get_flow` for its graph and
+ `get_flow_run` for a failing run's steps, then propose a repaired version.
+
+## The workflow model
+
+A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`.
+
+- **Node:** `{ id, kind, name, config }`. `id` is unique within the graph.
+- **Edge:** `{ from_node, to_node, from_port?, to_port? }`. Ports default to
+ `"main"`. Branch nodes emit on named ports (below) — wire those explicitly.
+- **Exactly ONE `trigger` node is required.** Every other node should be
+ reachable from it; a dry-run helps catch orphans.
+
+### The 12 node kinds
+
+1. **`trigger`** — the entry point (`config.trigger_kind`, see triggers below).
+2. **`agent`** — an LLM step. `config.prompt` (may use `=` expressions).
+3. **`tool_call`** — a Composio action. `config.slug` **REQUIRED** (from
+ `search_tool_catalog`) + `config.args`; `config.connection_ref` for the
+ account.
+4. **`http_request`** — `config.method` + `config.url`, optional `headers` /
+ `body`; `config.connection_ref` = an `http_cred:` for auth.
+5. **`code`** — `config.language` (`"javascript"` | `"python"`) + `config.source`.
+6. **`condition`** — boolean gate on `config.field`; routes to the **`true`** or
+ **`false`** port. Wire both (or the `false` branch dead-ends).
+7. **`switch`** — multi-way on `config.expression` or `config.field`; routes to
+ the matching **case** port, else **`default`**.
+8. **`merge`** — fan-in barrier; passes inputs through. No config.
+9. **`split_out`** — `config.path` to an array field; fans out one item per
+ element.
+10. **`transform`** — `config.set` = `{ key: "=expr" }`, merged onto each item.
+11. **`output_parser`** — passthrough today; no config required.
+12. **`sub_workflow`** — `config.workflow` = an embedded child `WorkflowGraph`.
+
+### Expressions: the `=` / jq convention
+
+Any config **string** beginning with `=` is an **expression** evaluated against
+the run scope (`.`):
+
+- Simple dotted path: `"=item.name"` → `scope.item.name` (missing → null).
+- Full **jq** program otherwise: `"=.item.items | length"`, `"=.a + .b"`. Only
+ the first output is used; a bad program yields `null` (never an error).
+- A string **without** a leading `=` is a literal. To emit a literal `=`, don't
+ start the string with it.
+
+Use expressions to thread data between steps (a `transform`'s `set`, an
+`agent`'s `prompt`, a `tool_call`'s `args`).
+
+### Trigger kinds — which ones actually fire
+
+Set `config.trigger_kind` on the trigger node. **Only three fire automatically
+in this host today:**
+
+- **`manual`** — runs on demand (the default; never a surprise).
+- **`schedule`** — needs `config.schedule`: `{kind:"cron",expr,tz?}` |
+ `{kind:"at",at}` | `{kind:"every",every_ms}`. Backed by a cron job.
+- **`app_event`** — needs `config.toolkit` + `config.trigger_slug` (e.g.
+ `gmail` / `GMAIL_NEW_GMAIL_MESSAGE`). Matched against incoming Composio
+ triggers.
+
+**These are accepted and saved but will NOT self-fire yet** — warn the user if
+they ask for one: `webhook`, `chat_message`, `form`, `evaluation`, `system`,
+`execute_by_workflow`. Suggest `schedule`/`app_event`, or note it must be run
+manually. (`propose_workflow` surfaces this as a warning too.)
+
+### Error handling per node
+
+Any acting node may carry:
+
+- **`config.on_error`**: `"stop"` (default — a failure fails the whole run),
+ `"continue"` (turn the error into data on the node's default port), or
+ `"route"` (emit the error on the node's **`error`** port so you can wire a
+ recovery sub-graph — add an edge from `from_port: "error"`).
+- **`config.retry`**: `{ max_attempts, backoff_ms?, backoff? }` where `backoff`
+ is `"fixed"` (default) or `"exponential"`. Attempts are capped and delays are
+ bounded.
+- **`config.requires_approval: true`** — pauses the run at this node for a human
+ to approve before it acts (human-in-the-loop). Good for irreversible steps.
+
+Prefer `retry` + `on_error: "route"` for flaky network/tool steps, and
+`requires_approval` for anything the user would not want to happen unattended.
+
+## Style
+
+Be concise. Ask a clarifying question only when the trigger or a critical step is
+genuinely ambiguous — otherwise make a sensible proposal and let the user refine
+it. Always end by proposing (or revising) the workflow; describe what it does in
+one or two plain sentences alongside the proposal.
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/prompt.rs b/src/openhuman/agent_registry/agents/workflow_builder/prompt.rs
new file mode 100644
index 000000000..df95d14f5
--- /dev/null
+++ b/src/openhuman/agent_registry/agents/workflow_builder/prompt.rs
@@ -0,0 +1,90 @@
+//! System prompt builder for the `workflow_builder` built-in agent (Phase 5a).
+//!
+//! Assembles the workflow-authoring archetype (from the sibling `prompt.md`)
+//! plus the shared runtime sections (user files, the agent's tool list, and the
+//! workspace footer). No `## Safety` block — the agent has `omit_safety_preamble
+//! = true` in its TOML because every tool in scope is propose-or-read and has no
+//! real external effect (the "propose, never persist" invariant lives in the
+//! archetype body instead).
+
+use crate::openhuman::context::prompt::{
+ render_tools, render_user_files, render_workspace, PromptContext,
+};
+use anyhow::Result;
+
+const ARCHETYPE: &str = include_str!("prompt.md");
+
+pub fn build(ctx: &PromptContext<'_>) -> Result {
+ let mut out = String::with_capacity(8192);
+ out.push_str(ARCHETYPE.trim_end());
+ out.push_str("\n\n");
+
+ let user_files = render_user_files(ctx)?;
+ if !user_files.trim().is_empty() {
+ out.push_str(user_files.trim_end());
+ out.push_str("\n\n");
+ }
+
+ let tools = render_tools(ctx)?;
+ if !tools.trim().is_empty() {
+ out.push_str(tools.trim_end());
+ out.push_str("\n\n");
+ }
+
+ let workspace = render_workspace(ctx)?;
+ if !workspace.trim().is_empty() {
+ out.push_str(workspace.trim_end());
+ out.push('\n');
+ }
+
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
+ use std::collections::HashSet;
+
+ fn ctx() -> PromptContext<'static> {
+ static VISIBLE: std::sync::OnceLock> = std::sync::OnceLock::new();
+ let visible = VISIBLE.get_or_init(HashSet::new);
+ PromptContext {
+ workspace_dir: std::path::Path::new("."),
+ model_name: "test",
+ agent_id: "workflow_builder",
+ tools: &[],
+ workflows: &[],
+ dispatcher_instructions: "",
+ learned: LearnedContextData::default(),
+ visible_tool_names: visible,
+ tool_call_format: ToolCallFormat::PFormat,
+ connected_integrations: &[],
+ connected_identities_md: String::new(),
+ include_profile: false,
+ include_memory_md: false,
+ curated_snapshot: None,
+ user_identity: None,
+ personality_soul_md: None,
+ personality_memory_md: None,
+ personality_roster: vec![],
+ }
+ }
+
+ #[test]
+ fn build_returns_nonempty_body() {
+ let body = build(&ctx()).unwrap();
+ assert!(!body.is_empty());
+ }
+
+ #[test]
+ fn prompt_teaches_the_propose_never_persist_invariant() {
+ let body = build(&ctx()).unwrap();
+ let lc = body.to_lowercase();
+ assert!(lc.contains("propose"), "prompt must teach proposing");
+ assert!(
+ lc.contains("never") && (lc.contains("persist") || lc.contains("save")),
+ "prompt must teach the never-persist invariant"
+ );
+ }
+}
diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs
index 1eaa17c18..d30d79143 100644
--- a/src/openhuman/channels/runtime/startup.rs
+++ b/src/openhuman/channels/runtime/startup.rs
@@ -156,7 +156,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
let bus = event_bus::init_global(DEFAULT_CAPACITY);
let _tracing_handle = bus.subscribe(Arc::new(TracingSubscriber));
crate::openhuman::health::bus::register_health_subscriber();
- crate::openhuman::workflows::bus::register_workflow_cleanup_subscriber();
+ crate::openhuman::skills::bus::register_workflow_cleanup_subscriber();
crate::openhuman::memory_conversations::register_conversation_persistence_subscriber(
config.workspace_dir.clone(),
);
@@ -427,14 +427,14 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
None,
));
- let skills = crate::openhuman::workflows::load_workflow_metadata(&workspace);
+ let skills = crate::openhuman::skills::load_workflow_metadata(&workspace);
// Install the triggered-workflow subscriber now that workflows are
// discovered — otherwise any workflow declaring `triggers:` is silently
// ignored. Idempotent + shares a process-global OnceLock with the
// `bootstrap_core_runtime` site, so it registers exactly once regardless of
// which startup path runs first (web-chat-only cores never reach here).
- crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace);
+ crate::openhuman::skills::bus::ensure_triggered_workflow_subscriber(&workspace);
// Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![
diff --git a/src/openhuman/channels/tests/prompt.rs b/src/openhuman/channels/tests/prompt.rs
index baae0f6b3..3061083cc 100644
--- a/src/openhuman/channels/tests/prompt.rs
+++ b/src/openhuman/channels/tests/prompt.rs
@@ -164,7 +164,7 @@ fn prompt_runtime_metadata() {
#[test]
fn prompt_skills_compact_list() {
let ws = make_workspace();
- let skills = vec![crate::openhuman::workflows::Workflow {
+ let skills = vec![crate::openhuman::skills::Workflow {
name: "code-review".into(),
description: "Review code for bugs".into(),
version: "1.0.0".into(),
diff --git a/src/openhuman/context/channels_prompt.rs b/src/openhuman/context/channels_prompt.rs
index 7d8905246..0bcbf4bd0 100644
--- a/src/openhuman/context/channels_prompt.rs
+++ b/src/openhuman/context/channels_prompt.rs
@@ -77,7 +77,7 @@ pub fn build_system_prompt(
workspace_dir: &Path,
model_name: &str,
tools: &[(&str, &str)],
- skills: &[crate::openhuman::workflows::Workflow],
+ skills: &[crate::openhuman::skills::Workflow],
bootstrap_max_chars: Option,
channel_name: Option<&str>,
) -> String {
@@ -255,7 +255,7 @@ fn inject_workspace_file(
#[cfg(test)]
mod tests {
use super::*;
- use crate::openhuman::workflows::{Workflow, WorkflowFrontmatter, WorkflowScope};
+ use crate::openhuman::skills::{Workflow, WorkflowFrontmatter, WorkflowScope};
use tempfile::tempdir;
fn skill(name: &str, location: Option) -> Workflow {
diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs
new file mode 100644
index 000000000..e10e57ea3
--- /dev/null
+++ b/src/openhuman/flows/builder_tools.rs
@@ -0,0 +1,766 @@
+//! Agent tool belt for the `workflow-builder` specialist (Phase 5b).
+//!
+//! These tools give the `workflow-builder` agent (see
+//! `agent_registry/agents/workflow_builder/`) a **deliberately narrow**,
+//! propose-or-read surface for authoring tinyflows [`WorkflowGraph`]s in chat:
+//!
+//! | Tool | Permission | Effect |
+//! | ----------------------- | ----------------------- | ----------------------------------------- |
+//! | [`ReviseWorkflowTool`] | `None` | validate a revised draft → proposal |
+//! | [`ListFlowsTool`] | `None` | read: list saved flows |
+//! | [`GetFlowTool`] | `None` | read: fetch a saved flow's graph |
+//! | [`GetFlowRunTool`] | `None` | read: fetch a run's steps |
+//! | [`ListFlowConnectionsTool`] | `None` | read: connection refs (ids/names only) |
+//! | [`SearchToolCatalogTool`] | `None` | read: real Composio tool slugs |
+//! | [`DryRunWorkflowTool`] | `Execute` (tier-gated) | run a *draft* against MOCK capabilities |
+//!
+//! **Human-in-the-loop invariant (shared with [`super::tools::ProposeWorkflowTool`]):**
+//! nothing here EVER persists or enables a flow. `revise_workflow` only
+//! validates and returns a proposal payload (identical contract to
+//! `propose_workflow`); the read tools are pure reads; `dry_run_workflow`
+//! executes against `tinyflows`' deterministic **mock** capabilities so no real
+//! LLM / tool / HTTP / code side effect can fire. Only the user's own
+//! "Save & enable" click (→ `openhuman.flows_create`) writes anything.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use serde_json::{json, Value};
+use tinyflows::model::WorkflowGraph;
+
+use crate::openhuman::config::Config;
+use crate::openhuman::flows::ops;
+use crate::openhuman::flows::ops::validate_and_migrate_graph;
+use crate::openhuman::security::{AutonomyLevel, SecurityPolicy};
+use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
+
+/// Wall-clock bound on a single `dry_run_workflow` mock execution. A malformed
+/// or pathological draft graph must never hang the agent tool-loop; the mock
+/// capabilities are non-blocking echoes, so this is a generous safety net.
+const DRY_RUN_TIMEOUT_SECS: u64 = 30;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// revise_workflow — iterative refine of an existing draft (proposal only)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `revise_workflow`: validate a **revised** draft graph and return the same
+/// `workflow_proposal` payload as `propose_workflow`.
+///
+/// Framed for iterative refinement: the agent supplies the updated `graph` (its
+/// revision of a prior draft) plus the `instruction` that motivated the change;
+/// the tool validates via the exact same [`validate_and_migrate_graph`] path
+/// `flows_create` uses and echoes an optional `revision` note. It NEVER
+/// persists — identical human-in-the-loop invariant to
+/// [`super::tools::ProposeWorkflowTool`].
+pub struct ReviseWorkflowTool {
+ config: Arc,
+}
+
+impl ReviseWorkflowTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for ReviseWorkflowTool {
+ fn name(&self) -> &str {
+ "revise_workflow"
+ }
+
+ fn description(&self) -> &str {
+ "Refine an EXISTING workflow draft: supply the full updated tinyflows \
+ WorkflowGraph (your revision applied to the prior draft — NOT a \
+ regeneration from scratch) plus the `instruction` that motivated the \
+ change. Like propose_workflow, this ONLY VALIDATES the revised graph \
+ and returns a proposal summary for the user to review — it NEVER \
+ creates, updates, or enables the flow. Same graph shape and node kinds \
+ as propose_workflow. If validation fails, fix the graph and call again."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Human-readable name for the (revised) proposed flow."
+ },
+ "graph": {
+ "type": "object",
+ "description": "The full REVISED tinyflows WorkflowGraph: { name?, nodes: [...], edges: [...] }. Apply your changes to the prior draft and pass the whole graph — see propose_workflow for node kinds and config shapes.",
+ "properties": {
+ "nodes": { "type": "array" },
+ "edges": { "type": "array" }
+ },
+ "required": ["nodes", "edges"]
+ },
+ "instruction": {
+ "type": "string",
+ "description": "The revision instruction that motivated this change (e.g. 'add a Slack step after the summary'). Echoed back for the review card; does not affect validation."
+ },
+ "require_approval": {
+ "type": "boolean",
+ "description": "Force a human-approval gate on every outbound action once saved. Defaults to true for agent-proposed flows."
+ }
+ },
+ "required": ["name", "graph"]
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ // Pure validation, no side effect — mirrors propose_workflow.
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ let name = match args.get("name").and_then(Value::as_str).map(str::trim) {
+ Some(name) if !name.is_empty() => name.to_string(),
+ _ => return Ok(ToolResult::error("Missing 'name' parameter".to_string())),
+ };
+ let graph_json = match args.get("graph") {
+ Some(v) if !v.is_null() => v.clone(),
+ _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())),
+ };
+ let instruction = args
+ .get("instruction")
+ .and_then(Value::as_str)
+ .map(str::to_string);
+ let require_approval = args
+ .get("require_approval")
+ .and_then(Value::as_bool)
+ .unwrap_or(true);
+
+ tracing::debug!(
+ target: "flows",
+ %name,
+ require_approval,
+ has_instruction = instruction.is_some(),
+ workspace = %self.config.workspace_dir.display(),
+ "[flows] revise_workflow: validating revised candidate graph"
+ );
+
+ let graph = match validate_and_migrate_graph(graph_json) {
+ Ok(graph) => graph,
+ Err(e) => {
+ tracing::debug!(target: "flows", %name, error = %e, "[flows] revise_workflow: validation failed");
+ return Ok(ToolResult::error(format!(
+ "Revised workflow graph is invalid: {e}. Fix the graph and call \
+ revise_workflow again."
+ )));
+ }
+ };
+
+ let summary = super::tools::build_summary(&graph);
+ let warnings = ops::graph_trigger_warnings(&graph);
+ let graph_value = serde_json::to_value(&graph)?;
+
+ tracing::info!(
+ target: "flows",
+ %name,
+ node_count = graph.nodes.len(),
+ require_approval,
+ warning_count = warnings.len(),
+ "[flows] revise_workflow: revised proposal ready for user review"
+ );
+
+ let mut payload = json!({
+ "type": "workflow_proposal",
+ "revision": true,
+ "name": name,
+ "graph": graph_value,
+ "require_approval": require_approval,
+ "summary": summary,
+ "warnings": warnings,
+ });
+ if let Some(instruction) = instruction {
+ payload["instruction"] = json!(instruction);
+ }
+
+ Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// list_flows — read-only: saved flow summaries
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `list_flows`: read-only listing of saved flows (id / name / enabled /
+/// last_status) so the builder can reference, clone, or avoid duplicating an
+/// existing automation.
+pub struct ListFlowsTool {
+ config: Arc,
+}
+
+impl ListFlowsTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for ListFlowsTool {
+ fn name(&self) -> &str {
+ "list_flows"
+ }
+
+ fn description(&self) -> &str {
+ "List the user's saved automation flows (tinyflows workflows). Read-only. \
+ Returns a JSON array of { id, name, enabled, last_status, last_run_at } so \
+ you can reference an existing flow, clone its structure (fetch the full \
+ graph with get_flow), or avoid proposing a duplicate."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({ "type": "object", "properties": {}, "additionalProperties": false })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, _args: Value) -> anyhow::Result {
+ tracing::debug!(target: "flows", "[flows] list_flows: listing saved flows (read-only)");
+ match ops::flows_list(&self.config).await {
+ Ok(outcome) => {
+ let flows: Vec = outcome
+ .value
+ .iter()
+ .map(|f| {
+ json!({
+ "id": f.id,
+ "name": f.name,
+ "enabled": f.enabled,
+ "last_status": f.last_status,
+ "last_run_at": f.last_run_at,
+ })
+ })
+ .collect();
+ Ok(ToolResult::success(serde_json::to_string_pretty(
+ &json!({ "flows": flows }),
+ )?))
+ }
+ Err(e) => Ok(ToolResult::error(format!("Failed to list flows: {e}"))),
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// get_flow — read-only: a saved flow's graph
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `get_flow`: read-only fetch of a saved flow's full [`WorkflowGraph`] by id,
+/// so the builder can clone or extend an existing automation.
+pub struct GetFlowTool {
+ config: Arc,
+}
+
+impl GetFlowTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for GetFlowTool {
+ fn name(&self) -> &str {
+ "get_flow"
+ }
+
+ fn description(&self) -> &str {
+ "Fetch a saved flow's full tinyflows WorkflowGraph (nodes + edges) plus \
+ its metadata by id. Read-only. Use it to clone or extend an existing \
+ automation — pass the returned graph (possibly modified) to \
+ revise_workflow or dry_run_workflow."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "description": "The saved flow's id (from list_flows)." }
+ },
+ "required": ["id"],
+ "additionalProperties": false
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ let id = match args.get("id").and_then(Value::as_str).map(str::trim) {
+ Some(id) if !id.is_empty() => id.to_string(),
+ _ => return Ok(ToolResult::error("Missing 'id' parameter".to_string())),
+ };
+ tracing::debug!(target: "flows", flow_id = %id, "[flows] get_flow: fetching saved flow (read-only)");
+ match ops::flows_get(&self.config, &id).await {
+ Ok(outcome) => {
+ let f = outcome.value;
+ let graph = serde_json::to_value(&f.graph)?;
+ Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
+ "id": f.id,
+ "name": f.name,
+ "enabled": f.enabled,
+ "require_approval": f.require_approval,
+ "last_status": f.last_status,
+ "graph": graph,
+ }))?))
+ }
+ Err(e) => Ok(ToolResult::error(format!("Failed to get flow '{id}': {e}"))),
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// get_flow_run — read-only: a run's steps (for repair/debugging)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `get_flow_run`: read-only fetch of a single flow run's step records, so the
+/// builder can diagnose a failure and propose a repair.
+pub struct GetFlowRunTool {
+ config: Arc,
+}
+
+impl GetFlowRunTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for GetFlowRunTool {
+ fn name(&self) -> &str {
+ "get_flow_run"
+ }
+
+ fn description(&self) -> &str {
+ "Fetch a single flow run's record by run id: status, per-node step \
+ results, any pending approvals, and the error (if it failed). Read-only. \
+ Use it to debug a failing flow from an error report and propose a repair."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "run_id": { "type": "string", "description": "The run id (also the run's thread_id)." }
+ },
+ "required": ["run_id"],
+ "additionalProperties": false
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ let run_id = match args.get("run_id").and_then(Value::as_str).map(str::trim) {
+ Some(id) if !id.is_empty() => id.to_string(),
+ _ => return Ok(ToolResult::error("Missing 'run_id' parameter".to_string())),
+ };
+ tracing::debug!(target: "flows", %run_id, "[flows] get_flow_run: fetching run record (read-only)");
+ match ops::flows_get_run(&self.config, &run_id).await {
+ Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(
+ &outcome.value,
+ )?)),
+ Err(e) => Ok(ToolResult::error(format!(
+ "Failed to get flow run '{run_id}': {e}"
+ ))),
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// list_flow_connections — read-only: connection refs (ids/names only)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `list_flow_connections`: read-only enumeration of the connection sources a
+/// node's `connection_ref` can attach to (Composio connected accounts +
+/// named HTTP credentials) — ids / display labels / kind only, never secrets.
+pub struct ListFlowConnectionsTool {
+ config: Arc,
+}
+
+impl ListFlowConnectionsTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for ListFlowConnectionsTool {
+ fn name(&self) -> &str {
+ "list_flow_connections"
+ }
+
+ fn description(&self) -> &str {
+ "List the connection sources a flow node's `connection_ref` can attach to: \
+ Composio connected accounts and named HTTP credentials. Read-only; \
+ returns ids + display labels + kind ONLY (never any secret). Use the \
+ `connection_ref` values verbatim on tool_call / http_request nodes so the \
+ generated flow carries valid connections."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({ "type": "object", "properties": {}, "additionalProperties": false })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, _args: Value) -> anyhow::Result {
+ tracing::debug!(target: "flows", "[flows] list_flow_connections: enumerating connection refs (read-only)");
+ match ops::flows_list_connections(&self.config).await {
+ Ok(outcome) => {
+ let conns: Vec = outcome
+ .value
+ .iter()
+ .map(|c| {
+ json!({
+ "connection_ref": c.connection_ref,
+ "kind": c.kind,
+ "display": c.display,
+ "toolkit": c.toolkit,
+ "scheme": c.scheme,
+ })
+ })
+ .collect();
+ Ok(ToolResult::success(serde_json::to_string_pretty(
+ &json!({ "connections": conns }),
+ )?))
+ }
+ Err(e) => Ok(ToolResult::error(format!(
+ "Failed to list flow connections: {e}"
+ ))),
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// search_tool_catalog — read-only: real Composio tool slugs
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `search_tool_catalog`: search OpenHuman's curated Composio catalog for REAL
+/// action slugs so `tool_call` nodes are grounded in slugs that actually exist
+/// (rather than a hallucinated slug that fails the save-time curation gate).
+pub struct SearchToolCatalogTool;
+
+impl SearchToolCatalogTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Default for SearchToolCatalogTool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Cap on returned matches so a broad query can't flood the agent's context.
+const MAX_CATALOG_RESULTS: usize = 40;
+
+/// Search the curated catalog for action slugs whose slug (or toolkit) matches
+/// every whitespace-separated term in `query` (case-insensitive AND). When
+/// `toolkit` is set, only that toolkit's catalog is scanned. Pure — no I/O.
+pub(crate) fn search_curated_catalog(
+ query: &str,
+ toolkit_filter: Option<&str>,
+ limit: usize,
+) -> Vec {
+ use crate::openhuman::memory_sync::composio::providers::{
+ agent_ready_toolkits, catalog_for_toolkit,
+ };
+
+ let terms: Vec = query
+ .split_whitespace()
+ .map(|t| t.to_ascii_lowercase())
+ .collect();
+
+ let toolkits: Vec = match toolkit_filter {
+ Some(tk) if !tk.trim().is_empty() => vec![tk.trim().to_ascii_lowercase()],
+ _ => agent_ready_toolkits()
+ .into_iter()
+ .map(str::to_string)
+ .collect(),
+ };
+
+ let mut out = Vec::new();
+ for toolkit in toolkits {
+ let Some(catalog) = catalog_for_toolkit(&toolkit) else {
+ continue;
+ };
+ for tool in catalog {
+ let slug_lc = tool.slug.to_ascii_lowercase();
+ // Every term must match either the slug or the toolkit name.
+ let matches = terms
+ .iter()
+ .all(|term| slug_lc.contains(term) || toolkit.contains(term));
+ if !matches {
+ continue;
+ }
+ out.push(json!({
+ "slug": tool.slug,
+ "toolkit": toolkit,
+ "scope": tool.scope.as_str(),
+ }));
+ if out.len() >= limit {
+ return out;
+ }
+ }
+ }
+ out
+}
+
+#[async_trait]
+impl Tool for SearchToolCatalogTool {
+ fn name(&self) -> &str {
+ "search_tool_catalog"
+ }
+
+ fn description(&self) -> &str {
+ "Search the curated Composio tool catalog for REAL action slugs to use on \
+ `tool_call` nodes. Read-only. Query by keyword (e.g. 'send email', \
+ 'slack message'); optionally scope to one `toolkit` (e.g. 'gmail'). \
+ Returns matching { slug, toolkit, scope } entries. ALWAYS ground a \
+ tool_call node's `slug` in a real result here — do not invent slugs."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Keywords to match against tool slugs (case-insensitive; all terms must match)."
+ },
+ "toolkit": {
+ "type": "string",
+ "description": "Optional toolkit slug to scope the search (e.g. 'gmail', 'slack')."
+ }
+ },
+ "required": ["query"],
+ "additionalProperties": false
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ fn external_effect(&self) -> bool {
+ false
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ let query = match args.get("query").and_then(Value::as_str).map(str::trim) {
+ Some(q) if !q.is_empty() => q.to_string(),
+ _ => return Ok(ToolResult::error("Missing 'query' parameter".to_string())),
+ };
+ let toolkit = args.get("toolkit").and_then(Value::as_str);
+ tracing::debug!(
+ target: "flows",
+ %query,
+ toolkit = toolkit.unwrap_or("(any)"),
+ "[flows] search_tool_catalog: searching curated Composio catalog (read-only)"
+ );
+ let results = search_curated_catalog(&query, toolkit, MAX_CATALOG_RESULTS);
+ Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
+ "query": query,
+ "count": results.len(),
+ "results": results,
+ }))?))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// dry_run_workflow — execute a DRAFT against MOCK capabilities (tier-gated)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// `dry_run_workflow`: compile a **draft** graph and run it against tinyflows'
+/// deterministic **mock** capabilities, returning the merged node-state output
+/// so the builder can self-verify a proposal before presenting it.
+///
+/// **No real side effects:** the run is wired to
+/// [`tinyflows::caps::mock::mock_capabilities`] — the LLM / tool / HTTP / code
+/// capabilities are echo stubs, so nothing external ever fires regardless of
+/// the graph. The output is explicitly labeled `sandbox: true`.
+///
+/// Autonomy-tier gated (issue: Phase 2 node gating): read-only tier refuses,
+/// mirroring the `SecurityPolicy` contract that a read-only session cannot
+/// exercise executable capability even in simulation.
+pub struct DryRunWorkflowTool {
+ security: Arc,
+}
+
+impl DryRunWorkflowTool {
+ pub fn new(security: Arc) -> Self {
+ Self { security }
+ }
+}
+
+#[async_trait]
+impl Tool for DryRunWorkflowTool {
+ fn name(&self) -> &str {
+ "dry_run_workflow"
+ }
+
+ fn description(&self) -> &str {
+ "Dry-run a DRAFT workflow graph in a SANDBOX to self-verify it before \
+ proposing. Compiles the graph and executes it against MOCK capabilities \
+ — every LLM / tool_call / http_request / code node returns a deterministic \
+ echo, so NOTHING real happens (no messages sent, no code run). Returns the \
+ simulated per-node output labeled as sandbox output. Use it to catch \
+ wiring/routing mistakes; it does NOT prove real integrations work. Pass \
+ the same graph shape as propose_workflow, plus an optional `input`."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "graph": {
+ "type": "object",
+ "description": "The DRAFT tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }.",
+ "properties": {
+ "nodes": { "type": "array" },
+ "edges": { "type": "array" }
+ },
+ "required": ["nodes", "edges"]
+ },
+ "input": {
+ "description": "Optional trigger input passed to the run (defaults to {})."
+ }
+ },
+ "required": ["graph"]
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ // Represents executable capability (a full sandbox could run code/http),
+ // so it is gated like an execute-class tool even though the mock backend
+ // means no real side effect can fire.
+ PermissionLevel::Execute
+ }
+
+ fn external_effect(&self) -> bool {
+ // Mock capabilities only — no real outbound effect. The `Execute`
+ // permission above plus the read-only tier refusal below carry the gate.
+ false
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ // Autonomy-tier gate: a read-only session cannot dry-run (executable
+ // capability, even simulated). Supervised / Full may.
+ if self.security.autonomy == AutonomyLevel::ReadOnly {
+ tracing::debug!(
+ target: "flows",
+ "[flows] dry_run_workflow: refused — autonomy tier is read-only"
+ );
+ return Ok(ToolResult::error(
+ "dry_run_workflow requires at least 'supervised' autonomy — the current \
+ tier is read-only. Propose the workflow instead (propose_workflow), or \
+ raise autonomy in Settings → Agent access."
+ .to_string(),
+ ));
+ }
+
+ let graph_json = match args.get("graph") {
+ Some(v) if !v.is_null() => v.clone(),
+ _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())),
+ };
+ let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
+
+ let graph: WorkflowGraph = match validate_and_migrate_graph(graph_json) {
+ Ok(graph) => graph,
+ Err(e) => {
+ return Ok(ToolResult::error(format!(
+ "Cannot dry-run an invalid graph: {e}. Fix the graph first."
+ )))
+ }
+ };
+
+ tracing::debug!(
+ target: "flows",
+ node_count = graph.nodes.len(),
+ "[flows] dry_run_workflow: compiling + running draft against MOCK capabilities"
+ );
+
+ let compiled = match tinyflows::compiler::compile(&graph) {
+ Ok(c) => c,
+ Err(e) => {
+ return Ok(ToolResult::error(format!(
+ "Draft graph failed to compile: {e}"
+ )))
+ }
+ };
+
+ let caps = tinyflows::caps::mock::mock_capabilities();
+ let run = tinyflows::engine::run(&compiled, input, &caps);
+ let outcome = match tokio::time::timeout(
+ std::time::Duration::from_secs(DRY_RUN_TIMEOUT_SECS),
+ run,
+ )
+ .await
+ {
+ Ok(Ok(outcome)) => outcome,
+ Ok(Err(e)) => {
+ tracing::debug!(target: "flows", error = %e, "[flows] dry_run_workflow: sandbox run errored");
+ return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
+ "sandbox": true,
+ "ok": false,
+ "error": e.to_string(),
+ "note": "SANDBOX (mock) output — a node errored during simulation. No real side effects occurred.",
+ }))?));
+ }
+ Err(_elapsed) => {
+ return Ok(ToolResult::error(format!(
+ "Sandbox dry-run timed out after {DRY_RUN_TIMEOUT_SECS}s"
+ )))
+ }
+ };
+
+ tracing::info!(
+ target: "flows",
+ node_count = graph.nodes.len(),
+ pending_approvals = outcome.pending_approvals.len(),
+ "[flows] dry_run_workflow: sandbox run finished"
+ );
+
+ Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
+ "sandbox": true,
+ "ok": true,
+ "output": outcome.output,
+ "pending_approvals": outcome.pending_approvals,
+ "note": "SANDBOX (mock) output — LLM/tool/HTTP/code nodes returned deterministic echoes; NO real side effects occurred. This checks wiring/routing only, not whether real integrations work.",
+ }))?))
+ }
+}
+
+#[cfg(test)]
+#[path = "builder_tools_tests.rs"]
+mod tests;
diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs
new file mode 100644
index 000000000..26e8329c5
--- /dev/null
+++ b/src/openhuman/flows/builder_tools_tests.rs
@@ -0,0 +1,248 @@
+use super::*;
+use crate::openhuman::config::Config;
+use crate::openhuman::security::{AutonomyLevel, SecurityPolicy};
+use serde_json::json;
+use tempfile::TempDir;
+
+fn test_config(tmp: &TempDir) -> Arc {
+ let config = Config {
+ workspace_dir: tmp.path().join("workspace"),
+ action_dir: tmp.path().join("workspace"),
+ config_path: tmp.path().join("config.toml"),
+ ..Config::default()
+ };
+ std::fs::create_dir_all(&config.workspace_dir).unwrap();
+ Arc::new(config)
+}
+
+fn policy(level: AutonomyLevel) -> Arc {
+ Arc::new(SecurityPolicy {
+ autonomy: level,
+ ..SecurityPolicy::default()
+ })
+}
+
+fn valid_graph() -> Value {
+ json!({
+ "nodes": [
+ { "id": "t", "kind": "trigger", "name": "Manual" },
+ { "id": "a", "kind": "agent", "name": "Summarize", "config": { "prompt": "hi" } }
+ ],
+ "edges": [ { "from_node": "t", "to_node": "a" } ]
+ })
+}
+
+// ── revise_workflow ──────────────────────────────────────────────────────────
+
+#[tokio::test]
+async fn revise_workflow_validates_and_returns_revision_proposal() {
+ let tmp = TempDir::new().unwrap();
+ let tool = ReviseWorkflowTool::new(test_config(&tmp));
+
+ let result = tool
+ .execute(json!({
+ "name": "Revised flow",
+ "graph": valid_graph(),
+ "instruction": "add a summarize step"
+ }))
+ .await
+ .unwrap();
+
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ assert_eq!(parsed["type"], "workflow_proposal");
+ assert_eq!(parsed["revision"], true);
+ assert_eq!(parsed["name"], "Revised flow");
+ assert_eq!(parsed["instruction"], "add a summarize step");
+ assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 2);
+}
+
+#[tokio::test]
+async fn revise_workflow_rejects_invalid_graph() {
+ let tmp = TempDir::new().unwrap();
+ let tool = ReviseWorkflowTool::new(test_config(&tmp));
+
+ let result = tool
+ .execute(json!({
+ "name": "bad",
+ "graph": { "nodes": [ { "id": "a", "kind": "agent", "name": "A" } ], "edges": [] }
+ }))
+ .await
+ .unwrap();
+
+ assert!(result.is_error);
+ assert!(result.output().to_lowercase().contains("invalid"));
+}
+
+#[test]
+fn revise_workflow_never_persists() {
+ // The revise tool shares propose_workflow's human-in-the-loop invariant:
+ // no side effect, no permission gate — it only validates and returns.
+ let tmp = TempDir::new().unwrap();
+ let tool = ReviseWorkflowTool::new(test_config(&tmp));
+ assert_eq!(tool.name(), "revise_workflow");
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+ assert!(!tool.external_effect());
+}
+
+// ── read-only tools ──────────────────────────────────────────────────────────
+
+#[tokio::test]
+async fn list_flows_is_read_only_and_lists() {
+ let tmp = TempDir::new().unwrap();
+ let tool = ListFlowsTool::new(test_config(&tmp));
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+ assert!(!tool.external_effect());
+
+ let result = tool.execute(json!({})).await.unwrap();
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ // No flows saved in a fresh workspace.
+ assert!(parsed["flows"].as_array().unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn get_flow_missing_id_is_error() {
+ let tmp = TempDir::new().unwrap();
+ let tool = GetFlowTool::new(test_config(&tmp));
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+
+ let result = tool.execute(json!({})).await.unwrap();
+ assert!(result.is_error);
+ assert!(result.output().contains("Missing 'id'"));
+}
+
+#[tokio::test]
+async fn get_flow_unknown_id_is_error() {
+ let tmp = TempDir::new().unwrap();
+ let tool = GetFlowTool::new(test_config(&tmp));
+
+ let result = tool.execute(json!({ "id": "nope" })).await.unwrap();
+ assert!(result.is_error);
+ assert!(
+ result.output().to_lowercase().contains("not found") || result.output().contains("nope")
+ );
+}
+
+#[tokio::test]
+async fn get_flow_run_missing_id_is_error() {
+ let tmp = TempDir::new().unwrap();
+ let tool = GetFlowRunTool::new(test_config(&tmp));
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+
+ let result = tool.execute(json!({})).await.unwrap();
+ assert!(result.is_error);
+ assert!(result.output().contains("Missing 'run_id'"));
+}
+
+#[tokio::test]
+async fn list_flow_connections_is_read_only() {
+ let tmp = TempDir::new().unwrap();
+ let tool = ListFlowConnectionsTool::new(test_config(&tmp));
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+ assert!(!tool.external_effect());
+
+ let result = tool.execute(json!({})).await.unwrap();
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ assert!(parsed["connections"].is_array());
+}
+
+// ── search_tool_catalog ──────────────────────────────────────────────────────
+
+#[test]
+fn search_curated_catalog_finds_real_gmail_slug() {
+ // Grounded search over the curated catalog returns a real slug/scope.
+ let results = search_curated_catalog("gmail", Some("gmail"), 40);
+ assert!(!results.is_empty(), "gmail catalog should have entries");
+ for r in &results {
+ assert_eq!(r["toolkit"], "gmail");
+ assert!(r["slug"]
+ .as_str()
+ .unwrap()
+ .to_ascii_uppercase()
+ .starts_with("GMAIL"));
+ assert!(r["scope"].is_string());
+ }
+}
+
+#[test]
+fn search_curated_catalog_all_terms_must_match() {
+ // A nonsense term matches nothing.
+ let results = search_curated_catalog("zzz_no_such_slug_zzz", None, 40);
+ assert!(results.is_empty());
+}
+
+#[tokio::test]
+async fn search_tool_catalog_tool_is_read_only_and_grounds() {
+ let tool = SearchToolCatalogTool::new();
+ assert_eq!(tool.name(), "search_tool_catalog");
+ assert_eq!(tool.permission_level(), PermissionLevel::None);
+ assert!(!tool.external_effect());
+
+ let result = tool
+ .execute(json!({ "query": "send", "toolkit": "gmail" }))
+ .await
+ .unwrap();
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ assert!(parsed["count"].as_u64().unwrap() >= 1);
+}
+
+#[tokio::test]
+async fn search_tool_catalog_missing_query_is_error() {
+ let tool = SearchToolCatalogTool::new();
+ let result = tool.execute(json!({})).await.unwrap();
+ assert!(result.is_error);
+ assert!(result.output().contains("Missing 'query'"));
+}
+
+// ── dry_run_workflow ─────────────────────────────────────────────────────────
+
+#[test]
+fn dry_run_is_execute_permission() {
+ let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
+ assert_eq!(tool.name(), "dry_run_workflow");
+ assert_eq!(tool.permission_level(), PermissionLevel::Execute);
+ // Mock-backed: no real outbound effect.
+ assert!(!tool.external_effect());
+}
+
+#[tokio::test]
+async fn dry_run_refused_under_readonly_tier() {
+ let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::ReadOnly));
+ let result = tool
+ .execute(json!({ "graph": valid_graph() }))
+ .await
+ .unwrap();
+ assert!(result.is_error);
+ assert!(result.output().to_lowercase().contains("read-only"));
+}
+
+#[tokio::test]
+async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
+ let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
+ let result = tool
+ .execute(json!({ "graph": valid_graph(), "input": { "x": 1 } }))
+ .await
+ .unwrap();
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ assert_eq!(parsed["sandbox"], true);
+ assert_eq!(parsed["ok"], true);
+ assert!(parsed["note"]
+ .as_str()
+ .unwrap()
+ .to_lowercase()
+ .contains("sandbox"));
+}
+
+#[tokio::test]
+async fn dry_run_invalid_graph_is_error() {
+ let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Full));
+ let result = tool
+ .execute(json!({ "graph": { "nodes": [], "edges": [] } }))
+ .await
+ .unwrap();
+ assert!(result.is_error);
+}
diff --git a/src/openhuman/flows/mod.rs b/src/openhuman/flows/mod.rs
index 95d4b799b..4f0174d13 100644
--- a/src/openhuman/flows/mod.rs
+++ b/src/openhuman/flows/mod.rs
@@ -7,7 +7,9 @@
//! [`crate::openhuman::tinyflows::caps::FlowStateStore`]); the RPC/CLI
//! controller surface in `schemas` (private, re-exported below).
+pub mod builder_tools;
pub mod bus;
+mod n8n_import;
pub mod ops;
mod run_registry;
mod schemas;
@@ -29,4 +31,6 @@ pub use schemas::{
// lives in the sibling `tinyflows` domain and persists each finished step onto
// the `flow_runs` row through this function as the run executes.
pub use store::{kv_get, kv_set, upsert_flow_run_step};
-pub use types::{Flow, FlowConnection, FlowRun, FlowRunStep, FlowRunTrigger, FlowValidation};
+pub use types::{
+ Flow, FlowConnection, FlowImport, FlowRun, FlowRunStep, FlowRunTrigger, FlowValidation,
+};
diff --git a/src/openhuman/flows/n8n_import.rs b/src/openhuman/flows/n8n_import.rs
new file mode 100644
index 000000000..4476c1a79
--- /dev/null
+++ b/src/openhuman/flows/n8n_import.rs
@@ -0,0 +1,852 @@
+//! Best-effort importer that maps an **n8n** workflow export into a tinyflows
+//! [`WorkflowGraph`]. Backs the `format: "n8n"` branch of `flows_import`
+//! (`schemas::handle_import` → `ops::flows_import`).
+//!
+//! n8n and tinyflows share a large slice of automation vocabulary (branching,
+//! merging, HTTP, code, triggers), so this maps the overlap directly:
+//!
+//! | n8n node type (`n8n-nodes-base.*`) | tinyflows kind |
+//! | --------------------------------------- | ----------------------- |
+//! | `if` | `condition` |
+//! | `switch` | `switch` |
+//! | `merge` | `merge` |
+//! | `splitOut` / `itemLists`(splitOut mode) | `split_out` |
+//! | `httpRequest` | `http_request` |
+//! | `code` / `function` / `functionItem` | `code` |
+//! | `scheduleTrigger` / `cron` / `interval` | `trigger` (schedule) |
+//! | `webhook` | `trigger` (webhook) |
+//! | `manualTrigger` | `trigger` (manual) |
+//!
+//! **Everything else is not a failed import** — an unmapped node type lands as
+//! an annotated placeholder (`transform`) node carrying the original n8n type
+//! and parameters in its `config`, plus a `_n8n_import` note, so the graph
+//! still loads, validates, and can be edited on the canvas. Connections and
+//! canvas positions are preserved wherever the source provides them.
+//!
+//! The mapping is intentionally lossy and advisory: every approximation
+//! (unmapped type, untranslated expression, synthesized/demoted trigger) is
+//! reported as a warning string the UI surfaces next to the imported draft.
+
+use serde_json::{json, Map, Value};
+use tinyflows::model::{Edge, Node, NodeKind, Position, WorkflowGraph};
+
+/// The outcome of mapping an n8n workflow: the best-effort tinyflows graph plus
+/// the list of advisory warnings collected during the mapping.
+#[derive(Debug)]
+pub(crate) struct N8nImportResult {
+ /// The mapped graph (still passed through `migrate` + `validate` by the
+ /// caller before it is handed to the UI).
+ pub graph: WorkflowGraph,
+ /// Human-readable, non-fatal notes: unmapped node types, untranslated
+ /// expressions, and any synthesized/demoted trigger.
+ pub warnings: Vec,
+}
+
+/// Returns `true` when `value` looks like an n8n workflow export rather than a
+/// native tinyflows `WorkflowGraph` — used by `flows_import`'s auto-detect. The
+/// tell-tales are a top-level `connections` object and/or nodes carrying an
+/// `n8n-nodes-base.*`/`type`-style discriminator (tinyflows nodes use `kind`).
+pub(crate) fn looks_like_n8n(value: &Value) -> bool {
+ if value.get("connections").map(Value::is_object) == Some(true) {
+ return true;
+ }
+ let Some(nodes) = value.get("nodes").and_then(Value::as_array) else {
+ return false;
+ };
+ nodes.iter().any(|n| {
+ // A native tinyflows node has `kind`; an n8n node has `type` and no `kind`.
+ n.get("kind").is_none() && n.get("type").and_then(Value::as_str).is_some()
+ })
+}
+
+/// Maps a parsed n8n workflow JSON `value` into a tinyflows [`WorkflowGraph`].
+///
+/// Never returns `Err` for an unrecognized node type — those become annotated
+/// placeholders. `Err` is reserved for input that is not shaped like an n8n
+/// export at all (e.g. `nodes` is not an array).
+pub(crate) fn map_n8n_workflow(value: &Value) -> Result {
+ let mut warnings: Vec = Vec::new();
+
+ let name = value
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("Imported workflow")
+ .to_string();
+
+ let raw_nodes = value
+ .get("nodes")
+ .and_then(Value::as_array)
+ .ok_or_else(|| "n8n workflow is missing a `nodes` array".to_string())?;
+
+ tracing::debug!(
+ target: "flows",
+ %name,
+ node_count = raw_nodes.len(),
+ "[flows] n8n_import: mapping n8n workflow"
+ );
+
+ // n8n connections key nodes by *name*; tinyflows edges reference node *ids*.
+ // Build a name → id lookup so connections can be rewired onto ids.
+ let mut name_to_id: Map = Map::new();
+ let mut nodes: Vec = Vec::new();
+
+ for raw in raw_nodes {
+ let n8n_name = raw
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("node")
+ .to_string();
+ let id = raw
+ .get("id")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .unwrap_or_else(|| slug(&n8n_name));
+ name_to_id.insert(n8n_name.clone(), Value::String(id.clone()));
+
+ let n8n_type = raw.get("type").and_then(Value::as_str).unwrap_or("");
+ let params = raw.get("parameters").cloned().unwrap_or(Value::Null);
+ let position = parse_position(raw.get("position"));
+
+ let (kind, config) = map_node(n8n_type, ¶ms, &n8n_name, &mut warnings);
+ nodes.push(Node {
+ id,
+ kind,
+ type_version: 1,
+ name: n8n_name,
+ config,
+ ports: Vec::new(),
+ position,
+ });
+ }
+
+ // tinyflows requires exactly one trigger. Reconcile the mapped triggers:
+ // synthesize a manual one when none survived, or demote extras to
+ // placeholders when several did — either way `validate` will pass.
+ reconcile_triggers(&mut nodes, &mut warnings);
+
+ let edges = map_connections(value.get("connections"), &name_to_id, &nodes, &mut warnings);
+
+ let graph = WorkflowGraph {
+ schema_version: tinyflows::model::CURRENT_SCHEMA_VERSION,
+ id: None,
+ name,
+ nodes,
+ edges,
+ };
+
+ tracing::debug!(
+ target: "flows",
+ node_count = graph.nodes.len(),
+ edge_count = graph.edges.len(),
+ warning_count = warnings.len(),
+ "[flows] n8n_import: mapping complete"
+ );
+
+ Ok(N8nImportResult { graph, warnings })
+}
+
+/// Maps a single n8n node `type` + `parameters` to a tinyflows kind and config.
+/// Unrecognized types return a `transform` placeholder carrying the original
+/// type/params under `_n8n_import` and record a warning.
+fn map_node(
+ n8n_type: &str,
+ params: &Value,
+ n8n_name: &str,
+ warnings: &mut Vec,
+) -> (NodeKind, Value) {
+ // Strip the vendor prefix so both `n8n-nodes-base.if` and a bare `if` match.
+ let short = n8n_type
+ .rsplit_once('.')
+ .map(|(_, s)| s)
+ .unwrap_or(n8n_type);
+
+ match short {
+ "if" => (
+ NodeKind::Condition,
+ translate_config(params, warnings, n8n_name),
+ ),
+ "switch" => (
+ NodeKind::Switch,
+ translate_config(params, warnings, n8n_name),
+ ),
+ "merge" => (
+ NodeKind::Merge,
+ translate_config(params, warnings, n8n_name),
+ ),
+ "splitOut" | "itemLists" => (
+ NodeKind::SplitOut,
+ translate_config(params, warnings, n8n_name),
+ ),
+ "httpRequest" => (
+ NodeKind::HttpRequest,
+ map_http_request(params, warnings, n8n_name),
+ ),
+ "code" | "function" | "functionItem" => {
+ (NodeKind::Code, map_code(params, warnings, n8n_name))
+ }
+ "scheduleTrigger" | "cron" | "interval" => (
+ NodeKind::Trigger,
+ trigger_config("schedule", params, warnings, n8n_name),
+ ),
+ "webhook" => (
+ NodeKind::Trigger,
+ trigger_config("webhook", params, warnings, n8n_name),
+ ),
+ "manualTrigger" | "start" => (
+ NodeKind::Trigger,
+ trigger_config("manual", params, warnings, n8n_name),
+ ),
+ _ => {
+ warnings.push(format!(
+ "Node '{n8n_name}' has n8n type '{n8n_type}', which has no tinyflows equivalent — \
+ imported as an editable placeholder that carries its original configuration. \
+ Replace it with a supported node before enabling the flow."
+ ));
+ let config = json!({
+ "_n8n_import": {
+ "original_type": n8n_type,
+ "note": "Unmapped n8n node imported as a placeholder; original parameters preserved below.",
+ },
+ "parameters": params,
+ });
+ (NodeKind::Transform, config)
+ }
+ }
+}
+
+/// Builds a tinyflows `trigger` config carrying the given `trigger_kind`
+/// discriminator plus any (expression-translated) source parameters.
+fn trigger_config(
+ trigger_kind: &str,
+ params: &Value,
+ warnings: &mut Vec,
+ n8n_name: &str,
+) -> Value {
+ let mut cfg = match translate_config(params, warnings, n8n_name) {
+ Value::Object(map) => map,
+ _ => Map::new(),
+ };
+ cfg.insert(
+ "trigger_kind".to_string(),
+ Value::String(trigger_kind.to_string()),
+ );
+ Value::Object(cfg)
+}
+
+/// Maps n8n `httpRequest` parameters onto tinyflows' `{ method, url, ... }`
+/// http_request config. n8n uses `url` + `method`/`requestMethod`; anything
+/// else is carried through after expression translation.
+fn map_http_request(params: &Value, warnings: &mut Vec, n8n_name: &str) -> Value {
+ let translated = translate_config(params, warnings, n8n_name);
+ let mut cfg = match translated {
+ Value::Object(map) => map,
+ _ => Map::new(),
+ };
+ // Normalize the method key (`requestMethod` is the older n8n spelling).
+ if !cfg.contains_key("method") {
+ if let Some(method) = cfg.remove("requestMethod") {
+ cfg.insert("method".to_string(), method);
+ }
+ }
+ cfg.entry("method".to_string())
+ .or_insert_with(|| Value::String("GET".to_string()));
+ Value::Object(cfg)
+}
+
+/// Maps n8n `code`/`function` parameters onto tinyflows' code config, pulling
+/// the source out of n8n's `jsCode`/`functionCode`/`pythonCode` fields into the
+/// `source` key tinyflows' `code` node actually reads (`vendor/tinyflows/src/nodes/integration/code.rs`)
+/// while preserving the language hint.
+fn map_code(params: &Value, warnings: &mut Vec, n8n_name: &str) -> Value {
+ let translated = translate_config(params, warnings, n8n_name);
+ let mut cfg = match translated {
+ Value::Object(map) => map,
+ _ => Map::new(),
+ };
+ for (src, lang) in [
+ ("jsCode", "javascript"),
+ ("functionCode", "javascript"),
+ ("pythonCode", "python"),
+ ] {
+ if let Some(code) = cfg.remove(src) {
+ cfg.entry("source".to_string()).or_insert(code);
+ cfg.entry("language".to_string())
+ .or_insert_with(|| Value::String(lang.to_string()));
+ }
+ }
+ Value::Object(cfg)
+}
+
+/// Recursively translates n8n `={{ … }}` expressions inside a config `Value`
+/// into tinyflows' `=`-prefixed jq form where trivially possible; anything not
+/// trivially translatable is left as its raw string and a warning is recorded.
+fn translate_config(value: &Value, warnings: &mut Vec, n8n_name: &str) -> Value {
+ match value {
+ Value::String(s) => Value::String(translate_expr(s, warnings, n8n_name)),
+ Value::Array(items) => Value::Array(
+ items
+ .iter()
+ .map(|v| translate_config(v, warnings, n8n_name))
+ .collect(),
+ ),
+ Value::Object(map) => {
+ let mut out = Map::new();
+ for (k, v) in map {
+ out.insert(k.clone(), translate_config(v, warnings, n8n_name));
+ }
+ Value::Object(out)
+ }
+ other => other.clone(),
+ }
+}
+
+/// Translates a single n8n expression string. An n8n expression is a string
+/// beginning with `=` whose body is `{{ … }}`. The trivially-translatable case
+/// is a single `{{ $json. }}` reference, which becomes tinyflows'
+/// `=.` jq. Anything richer is returned unchanged with a warning.
+fn translate_expr(raw: &str, warnings: &mut Vec, n8n_name: &str) -> String {
+ // Only n8n expression strings start with `=`; plain values pass through.
+ if !raw.starts_with('=') {
+ return raw.to_string();
+ }
+ let body = raw[1..].trim();
+ let Some(inner) = body
+ .strip_prefix("{{")
+ .and_then(|b| b.strip_suffix("}}"))
+ .map(str::trim)
+ else {
+ // A `=`-prefixed non-`{{ }}` string: keep raw, warn.
+ warnings.push(untranslated_warning(n8n_name, raw));
+ return raw.to_string();
+ };
+
+ // Trivial single-reference case: `$json.foo.bar` (or `$json["foo"]`).
+ if let Some(path) = inner.strip_prefix("$json") {
+ if let Some(jq_path) = json_path_to_jq(path) {
+ return format!("={jq_path}");
+ }
+ }
+
+ warnings.push(untranslated_warning(n8n_name, raw));
+ raw.to_string()
+}
+
+/// Turns an n8n `$json` accessor tail (`.foo.bar`, `["foo"]`, or empty) into a
+/// jq path (`.foo.bar`, `.foo`, or `.`). Returns `None` for anything that
+/// isn't a plain dotted / bracketed-string path (arithmetic, function calls,
+/// bracket-index into arrays, etc.), so the caller falls back to raw + warn.
+fn json_path_to_jq(tail: &str) -> Option {
+ let tail = tail.trim();
+ if tail.is_empty() {
+ return Some(".".to_string());
+ }
+ let mut jq = String::new();
+ let mut rest = tail;
+ while !rest.is_empty() {
+ if let Some(after) = rest.strip_prefix('.') {
+ // `.identifier`
+ let end = after
+ .find(|c: char| !c.is_alphanumeric() && c != '_')
+ .unwrap_or(after.len());
+ if end == 0 {
+ return None;
+ }
+ jq.push('.');
+ jq.push_str(&after[..end]);
+ rest = &after[end..];
+ } else if let Some(after) = rest.strip_prefix('[') {
+ // `["identifier"]` or `['identifier']` — string keys only.
+ let close = after.find(']')?;
+ let key = after[..close].trim();
+ let key = key
+ .strip_prefix('"')
+ .and_then(|k| k.strip_suffix('"'))
+ .or_else(|| key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))?;
+ if key.is_empty() {
+ return None;
+ }
+ jq.push('.');
+ jq.push_str(&jq_field(key));
+ rest = &after[close + 1..];
+ } else {
+ return None;
+ }
+ }
+ Some(jq)
+}
+
+/// Renders a single jq field-access key: bare (`foo`) when `key` is a plain
+/// identifier (alphanumeric/underscore, not digit-leading), else quoted
+/// (`"first name"`) per jq's dot-plus-quoted-string syntax — required for any
+/// key containing spaces or punctuation, which `.foo bar` (unquoted) is not
+/// valid jq for.
+fn jq_field(key: &str) -> String {
+ let is_bare_identifier = !key.is_empty()
+ && !key.starts_with(|c: char| c.is_ascii_digit())
+ && key.chars().all(|c| c.is_alphanumeric() || c == '_');
+ if is_bare_identifier {
+ key.to_string()
+ } else {
+ format!("{:?}", key)
+ }
+}
+
+fn untranslated_warning(n8n_name: &str, raw: &str) -> String {
+ format!(
+ "Node '{n8n_name}' uses an n8n expression that was not automatically translated \
+ (`{raw}`) — it was kept as a raw string. Review and rewrite it as a tinyflows \
+ `=`-jq expression."
+ )
+}
+
+/// Ensures the graph has exactly one trigger, mutating `nodes` in place:
+/// - zero triggers → prepend a synthesized `manual` trigger (with a warning);
+/// - multiple triggers → keep the first, demote the rest to placeholders.
+fn reconcile_triggers(nodes: &mut Vec, warnings: &mut Vec) {
+ let trigger_idxs: Vec = nodes
+ .iter()
+ .enumerate()
+ .filter(|(_, n)| n.kind == NodeKind::Trigger)
+ .map(|(i, _)| i)
+ .collect();
+
+ match trigger_idxs.len() {
+ 0 => {
+ warnings.push(
+ "The n8n workflow had no importable trigger — a manual trigger was added so the \
+ flow is runnable. Attach a schedule or app-event trigger to run it automatically."
+ .to_string(),
+ );
+ // Collision-free synthetic id: an n8n graph may already have a
+ // (non-trigger) node literally named "trigger" — colliding with a
+ // hardcoded id would produce a duplicate-id graph and fail
+ // validation, turning an otherwise-recoverable import into a hard
+ // failure.
+ let mut trigger_id = "trigger".to_string();
+ let mut suffix = 2;
+ while nodes.iter().any(|n| n.id == trigger_id) {
+ trigger_id = format!("trigger_{suffix}");
+ suffix += 1;
+ }
+
+ nodes.insert(
+ 0,
+ Node {
+ id: trigger_id,
+ kind: NodeKind::Trigger,
+ type_version: 1,
+ name: "Manual Trigger".to_string(),
+ config: json!({ "trigger_kind": "manual" }),
+ ports: Vec::new(),
+ position: None,
+ },
+ );
+ }
+ 1 => {}
+ _ => {
+ // Keep the first trigger; demote the rest so `validate` accepts the
+ // graph. Their ids are unchanged, so edges stay wired.
+ for &idx in trigger_idxs.iter().skip(1) {
+ let node = &mut nodes[idx];
+ warnings.push(format!(
+ "The n8n workflow had more than one trigger; '{}' was imported as a \
+ placeholder because a tinyflows flow allows only one trigger.",
+ node.name
+ ));
+ let original = node.config.clone();
+ node.kind = NodeKind::Transform;
+ node.config = json!({
+ "_n8n_import": {
+ "original_type": "trigger",
+ "note": "Extra trigger demoted to a placeholder (a flow allows one trigger).",
+ },
+ "parameters": original,
+ });
+ }
+ }
+ }
+}
+
+/// Rewrites n8n's name-keyed `connections` map onto tinyflows edges (id-keyed),
+/// preserving output-port routing: an `if`/`condition` source routes output 0 →
+/// `true` and 1 → `false`; a `switch` source routes output _i_ → `"i"`; every
+/// other source uses `main`. Connections that reference an unknown node are
+/// dropped with a warning.
+fn map_connections(
+ connections: Option<&Value>,
+ name_to_id: &Map,
+ nodes: &[Node],
+ warnings: &mut Vec,
+) -> Vec {
+ let mut edges = Vec::new();
+ let Some(Value::Object(conns)) = connections else {
+ return edges;
+ };
+
+ for (src_name, outputs) in conns {
+ let Some(src_id) = name_to_id.get(src_name).and_then(Value::as_str) else {
+ continue;
+ };
+ let src_kind = nodes
+ .iter()
+ .find(|n| n.id == src_id)
+ .map(|n| n.kind.clone());
+ // n8n groups outputs by connection type (`main`, `ai_tool`, …); we only
+ // wire `main` — other connection families have no tinyflows analogue.
+ let Some(main) = outputs.get("main").and_then(Value::as_array) else {
+ continue;
+ };
+ for (port_index, port_targets) in main.iter().enumerate() {
+ let from_port = output_port_name(src_kind.as_ref(), port_index);
+ let Some(targets) = port_targets.as_array() else {
+ continue;
+ };
+ for target in targets {
+ let Some(tgt_name) = target.get("node").and_then(Value::as_str) else {
+ continue;
+ };
+ match name_to_id.get(tgt_name).and_then(Value::as_str) {
+ Some(tgt_id) => edges.push(Edge {
+ from_node: src_id.to_string(),
+ from_port: from_port.clone(),
+ to_node: tgt_id.to_string(),
+ to_port: "main".to_string(),
+ }),
+ None => warnings.push(format!(
+ "Connection from '{src_name}' to unknown node '{tgt_name}' was dropped."
+ )),
+ }
+ }
+ }
+ }
+ edges
+}
+
+/// The tinyflows output-port name for source `kind`'s n8n output index.
+fn output_port_name(kind: Option<&NodeKind>, index: usize) -> String {
+ match kind {
+ Some(NodeKind::Condition) => {
+ if index == 0 {
+ "true".to_string()
+ } else {
+ "false".to_string()
+ }
+ }
+ Some(NodeKind::Switch) => index.to_string(),
+ _ => {
+ if index == 0 {
+ "main".to_string()
+ } else {
+ index.to_string()
+ }
+ }
+ }
+}
+
+/// Parses n8n's `position: [x, y]` array into a tinyflows [`Position`].
+fn parse_position(value: Option<&Value>) -> Option {
+ let arr = value?.as_array()?;
+ let x = arr.first()?.as_f64()?;
+ let y = arr.get(1)?.as_f64()?;
+ Some(Position { x, y })
+}
+
+/// Derives a stable, id-safe slug from an n8n node name when the node carries
+/// no `id` of its own.
+fn slug(name: &str) -> String {
+ let s: String = name
+ .chars()
+ .map(|c| {
+ if c.is_alphanumeric() {
+ c.to_ascii_lowercase()
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ if s.is_empty() {
+ "node".to_string()
+ } else {
+ s
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn looks_like_n8n_detects_connections_and_typed_nodes() {
+ assert!(looks_like_n8n(&json!({ "connections": {} })));
+ assert!(looks_like_n8n(&json!({
+ "nodes": [{ "name": "x", "type": "n8n-nodes-base.httpRequest" }]
+ })));
+ // A native tinyflows graph is not mistaken for n8n.
+ assert!(!looks_like_n8n(&json!({
+ "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }],
+ "edges": []
+ })));
+ }
+
+ #[test]
+ fn maps_if_node_to_condition_with_true_false_ports() {
+ let wf = json!({
+ "name": "branch",
+ "nodes": [
+ { "id": "s", "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "position": [0, 0] },
+ { "id": "c", "name": "IF", "type": "n8n-nodes-base.if", "position": [200, 0] },
+ { "id": "a", "name": "Yes", "type": "n8n-nodes-base.httpRequest", "position": [400, -100] },
+ { "id": "b", "name": "No", "type": "n8n-nodes-base.httpRequest", "position": [400, 100] }
+ ],
+ "connections": {
+ "Schedule Trigger": { "main": [[{ "node": "IF", "type": "main", "index": 0 }]] },
+ "IF": { "main": [
+ [{ "node": "Yes", "type": "main", "index": 0 }],
+ [{ "node": "No", "type": "main", "index": 0 }]
+ ] }
+ }
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ let g = &result.graph;
+ assert_eq!(g.name, "branch");
+
+ let cond = g.node("c").expect("condition node");
+ assert_eq!(cond.kind, NodeKind::Condition);
+
+ let trig = g.node("s").expect("trigger node");
+ assert_eq!(trig.kind, NodeKind::Trigger);
+ assert_eq!(trig.config["trigger_kind"], json!("schedule"));
+ assert_eq!(trig.position, Some(Position { x: 0.0, y: 0.0 }));
+
+ // The IF node's two outputs route to `true`/`false` ports.
+ let true_edge = g
+ .edges
+ .iter()
+ .find(|e| e.from_node == "c" && e.to_node == "a")
+ .expect("true edge");
+ assert_eq!(true_edge.from_port, "true");
+ let false_edge = g
+ .edges
+ .iter()
+ .find(|e| e.from_node == "c" && e.to_node == "b")
+ .expect("false edge");
+ assert_eq!(false_edge.from_port, "false");
+
+ // Whole graph is structurally valid (exactly one trigger, real edges).
+ tinyflows::validate::validate(g).expect("valid graph");
+ }
+
+ #[test]
+ fn unmapped_type_becomes_annotated_placeholder_not_a_failure() {
+ let wf = json!({
+ "name": "exotic",
+ "nodes": [
+ { "id": "t", "name": "Manual", "type": "n8n-nodes-base.manualTrigger" },
+ { "id": "x", "name": "Airtable", "type": "n8n-nodes-base.airtable",
+ "parameters": { "operation": "append", "table": "leads" } }
+ ],
+ "connections": {
+ "Manual": { "main": [[{ "node": "Airtable", "type": "main", "index": 0 }]] }
+ }
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ let node = result.graph.node("x").expect("placeholder node");
+ assert_eq!(node.kind, NodeKind::Transform);
+ assert_eq!(
+ node.config["_n8n_import"]["original_type"],
+ json!("n8n-nodes-base.airtable")
+ );
+ // Original parameters are preserved for editing.
+ assert_eq!(node.config["parameters"]["table"], json!("leads"));
+ // The unmapped type produced a warning.
+ assert!(result.warnings.iter().any(|w| w.contains("airtable")));
+ tinyflows::validate::validate(&result.graph).expect("valid graph");
+ }
+
+ #[test]
+ fn synthesizes_manual_trigger_when_none_present() {
+ let wf = json!({
+ "name": "no-trigger",
+ "nodes": [
+ { "id": "h", "name": "HTTP", "type": "n8n-nodes-base.httpRequest" }
+ ],
+ "connections": {}
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ assert_eq!(
+ result
+ .graph
+ .nodes
+ .iter()
+ .filter(|n| n.kind == NodeKind::Trigger)
+ .count(),
+ 1
+ );
+ assert!(result.warnings.iter().any(|w| w.contains("manual trigger")));
+ tinyflows::validate::validate(&result.graph).expect("valid graph");
+ }
+
+ #[test]
+ fn synthesized_trigger_id_avoids_colliding_with_an_existing_node() {
+ // The n8n graph already has a (non-trigger) node literally id'd
+ // "trigger" — the synthesized manual trigger must not collide with it.
+ let wf = json!({
+ "name": "id-collision",
+ "nodes": [
+ { "id": "trigger", "name": "HTTP", "type": "n8n-nodes-base.httpRequest" }
+ ],
+ "connections": {}
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ let ids: Vec<&str> = result.graph.nodes.iter().map(|n| n.id.as_str()).collect();
+ // Both the original node and the synthesized trigger survive, under
+ // distinct ids.
+ assert_eq!(ids.len(), 2);
+ assert!(ids.contains(&"trigger"));
+ assert!(ids.iter().any(|id| *id != "trigger"));
+ assert_eq!(
+ result
+ .graph
+ .nodes
+ .iter()
+ .filter(|n| n.kind == NodeKind::Trigger)
+ .count(),
+ 1
+ );
+ tinyflows::validate::validate(&result.graph).expect("valid graph");
+ }
+
+ #[test]
+ fn demotes_extra_triggers_to_placeholders() {
+ let wf = json!({
+ "name": "two-triggers",
+ "nodes": [
+ { "id": "s", "name": "Schedule", "type": "n8n-nodes-base.scheduleTrigger" },
+ { "id": "w", "name": "Webhook", "type": "n8n-nodes-base.webhook" }
+ ],
+ "connections": {}
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ assert_eq!(
+ result
+ .graph
+ .nodes
+ .iter()
+ .filter(|n| n.kind == NodeKind::Trigger)
+ .count(),
+ 1
+ );
+ // The demoted trigger is now a placeholder transform.
+ let demoted = result.graph.node("w").expect("webhook node");
+ assert_eq!(demoted.kind, NodeKind::Transform);
+ tinyflows::validate::validate(&result.graph).expect("valid graph");
+ }
+
+ #[test]
+ fn translates_trivial_json_expression_to_jq() {
+ let mut warnings = Vec::new();
+ assert_eq!(
+ translate_expr("={{ $json.email }}", &mut warnings, "n"),
+ "=.email"
+ );
+ assert_eq!(
+ translate_expr("={{ $json.user.name }}", &mut warnings, "n"),
+ "=.user.name"
+ );
+ // A bracket key with a space isn't a bare jq identifier — must come out
+ // quoted (`."first name"`), not `.first name` (invalid jq).
+ assert_eq!(
+ translate_expr("={{ $json[\"first name\"] }}", &mut warnings, "n"),
+ "=.\"first name\""
+ );
+ assert_eq!(translate_expr("={{ $json }}", &mut warnings, "n"), "=.");
+ assert!(warnings.is_empty());
+ }
+
+ #[test]
+ fn jq_field_quotes_non_bare_identifiers() {
+ // Plain identifiers stay bare.
+ assert_eq!(jq_field("foo"), "foo");
+ assert_eq!(jq_field("foo_bar"), "foo_bar");
+ // Spaces, punctuation, and digit-leading keys aren't bare jq
+ // identifiers — jq requires the dot-plus-quoted-string form for these.
+ assert_eq!(jq_field("first name"), "\"first name\"");
+ assert_eq!(jq_field("foo-bar"), "\"foo-bar\"");
+ assert_eq!(jq_field("123key"), "\"123key\"");
+ }
+
+ #[test]
+ fn leaves_untranslatable_expression_raw_with_warning() {
+ let mut warnings = Vec::new();
+ let raw = "={{ $json.a + $json.b }}";
+ assert_eq!(translate_expr(raw, &mut warnings, "Math"), raw);
+ assert_eq!(warnings.len(), 1);
+ assert!(warnings[0].contains("not automatically translated"));
+ }
+
+ #[test]
+ fn plain_string_is_not_treated_as_expression() {
+ let mut warnings = Vec::new();
+ assert_eq!(translate_expr("hello", &mut warnings, "n"), "hello");
+ assert!(warnings.is_empty());
+ }
+
+ #[test]
+ fn http_request_maps_url_and_method() {
+ let mut warnings = Vec::new();
+ let cfg = map_http_request(
+ &json!({ "url": "https://api.example.com", "requestMethod": "POST" }),
+ &mut warnings,
+ "HTTP",
+ );
+ assert_eq!(cfg["url"], json!("https://api.example.com"));
+ assert_eq!(cfg["method"], json!("POST"));
+ // Expression in the url is translated in place.
+ let cfg2 = map_http_request(
+ &json!({ "url": "={{ $json.endpoint }}" }),
+ &mut warnings,
+ "HTTP",
+ );
+ assert_eq!(cfg2["url"], json!("=.endpoint"));
+ assert_eq!(cfg2["method"], json!("GET"));
+ }
+
+ #[test]
+ fn code_node_pulls_source_and_language() {
+ let mut warnings = Vec::new();
+ let cfg = map_code(&json!({ "jsCode": "return items;" }), &mut warnings, "Code");
+ assert_eq!(cfg["source"], json!("return items;"));
+ assert_eq!(cfg["language"], json!("javascript"));
+ }
+
+ #[test]
+ fn switch_ports_are_numeric_indices() {
+ assert_eq!(output_port_name(Some(&NodeKind::Switch), 0), "0");
+ assert_eq!(output_port_name(Some(&NodeKind::Switch), 2), "2");
+ assert_eq!(output_port_name(Some(&NodeKind::Condition), 0), "true");
+ assert_eq!(output_port_name(Some(&NodeKind::Condition), 1), "false");
+ assert_eq!(output_port_name(Some(&NodeKind::Merge), 0), "main");
+ }
+
+ #[test]
+ fn missing_nodes_array_is_an_error() {
+ let err = map_n8n_workflow(&json!({ "name": "x" })).unwrap_err();
+ assert!(err.contains("nodes"));
+ }
+
+ #[test]
+ fn drops_connection_to_unknown_node_with_warning() {
+ let wf = json!({
+ "name": "dangling",
+ "nodes": [
+ { "id": "t", "name": "Manual", "type": "n8n-nodes-base.manualTrigger" }
+ ],
+ "connections": {
+ "Manual": { "main": [[{ "node": "Ghost", "type": "main", "index": 0 }]] }
+ }
+ });
+ let result = map_n8n_workflow(&wf).expect("map");
+ assert!(result.graph.edges.is_empty());
+ assert!(result.warnings.iter().any(|w| w.contains("Ghost")));
+ }
+}
diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs
index 36596bc51..23e33b251 100644
--- a/src/openhuman/flows/ops.rs
+++ b/src/openhuman/flows/ops.rs
@@ -206,6 +206,83 @@ pub fn flows_validate(graph_json: Value) -> RpcOutcome,
+) -> Result, String> {
+ use crate::openhuman::flows::{n8n_import, FlowImport};
+
+ let requested = format
+ .as_deref()
+ .unwrap_or("auto")
+ .trim()
+ .to_ascii_lowercase();
+ let is_n8n = match requested.as_str() {
+ "n8n" => true,
+ "native" | "tinyflows" => false,
+ "auto" | "" => n8n_import::looks_like_n8n(&graph_json),
+ other => {
+ return Err(format!(
+ "unknown import format '{other}' (expected 'native' or 'n8n')"
+ ))
+ }
+ };
+ tracing::debug!(
+ target: "flows",
+ requested_format = %requested,
+ resolved = if is_n8n { "n8n" } else { "native" },
+ "[flows] flows_import: importing workflow definition"
+ );
+
+ let (candidate, mut warnings) = if is_n8n {
+ let mapped = n8n_import::map_n8n_workflow(&graph_json)?;
+ // Re-serialize the mapped graph so it re-enters the exact same
+ // migrate + validate path a native import takes (single source of truth
+ // for validity), rather than trusting the mapper's in-memory graph.
+ let value = serde_json::to_value(&mapped.graph).map_err(|e| e.to_string())?;
+ (value, mapped.warnings)
+ } else {
+ (graph_json, Vec::new())
+ };
+
+ let graph = validate_and_migrate_graph(candidate)?;
+ // Host-side trigger warnings apply to both formats (e.g. an imported
+ // webhook trigger that this host does not yet self-fire).
+ warnings.extend(graph_trigger_warnings(&graph));
+ tracing::debug!(
+ target: "flows",
+ node_count = graph.nodes.len(),
+ warning_count = warnings.len(),
+ "[flows] flows_import: import normalized and validated"
+ );
+ Ok(RpcOutcome::single_log(
+ FlowImport { graph, warnings },
+ "flow imported",
+ ))
+}
+
/// Creates a new flow from a name and a raw graph JSON value.
///
/// `store::create_flow` defaults new flows to `enabled = true` — this binds
@@ -234,6 +311,30 @@ pub async fn flows_create(
Ok(RpcOutcome::single_log(flow, "flow created"))
}
+/// Duplicates a saved flow: creates an independent copy of its graph under a
+/// new id/timestamps, with the name suffixed `" (copy)"`. The copy is created
+/// **disabled** (`enabled = false`) and therefore **not** schedule/app_event
+/// trigger-bound — unlike [`flows_create`], which binds a trigger for an
+/// enabled flow, this deliberately calls no [`bind_trigger`], so a duplicate
+/// can never immediately fire. Run history does not carry over. The user
+/// enables it explicitly (via `flows_set_enabled`) once they've reviewed the
+/// copy, at which point its trigger binds like any other flow.
+pub async fn flows_duplicate(config: &Config, id: &str) -> Result, String> {
+ let source = store::get_flow(config, id)
+ .map_err(|e| e.to_string())?
+ .ok_or_else(|| format!("flow '{id}' not found"))?;
+ let new_name = format!("{} (copy)", source.name);
+ tracing::debug!(target: "flows", source_id = %id, %new_name, "[flows] flows_duplicate: creating disabled, unbound copy");
+ let flow =
+ store::insert_duplicate_flow(config, &source, new_name).map_err(|e| e.to_string())?;
+ // Intentionally NO bind_trigger: a duplicate is disabled and must stay
+ // inert (no schedule/trigger dispatch) until the user enables it.
+ Ok(RpcOutcome::single_log(
+ flow,
+ format!("flow duplicated from {id}"),
+ ))
+}
+
/// Loads one flow by id.
pub async fn flows_get(config: &Config, id: &str) -> Result, String> {
let flow = store::get_flow(config, id)
@@ -242,6 +343,29 @@ pub async fn flows_get(config: &Config, id: &str) -> Result, St
Ok(RpcOutcome::single_log(flow, format!("flow loaded: {id}")))
}
+/// Loads a saved flow's portable [`WorkflowGraph`] by id, for the
+/// `sub_workflow`-by-`workflow_id` resolver capability
+/// (`tinyflows::caps::WorkflowResolver`, implemented in
+/// `src/openhuman/tinyflows/caps.rs`).
+///
+/// Returns `Ok(None)` when no flow with that id exists (the resolver turns that
+/// into a capability error naming the missing id), and `Err` only on a store
+/// failure. Kept sync (the underlying [`store::get_flow`] is sync) so the
+/// resolver can call it directly from its async method without a runtime hop.
+pub fn load_flow_graph(config: &Config, id: &str) -> Result