- {NODE_KINDS.map(kind => {
- const meta = nodeKindMeta(kind);
- const colors = COLOR_CLASSES[meta.color];
- const label = t(`flows.nodeKind.${kind}`, kind);
- return (
-
onAdd(kind)}
- onDragStart={event => {
- event.dataTransfer.setData(PALETTE_DND_MIME, kind);
- event.dataTransfer.effectAllowed = 'copy';
- }}
- title={t('flows.palette.addNode').replace('{kind}', label)}
- className={`flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left text-xs text-content transition-colors hover:bg-surface-hover ${colors.border}`}>
-
- {meta.emoji}
-
- {label}
-
- );
- })}
+
+ {NODE_GROUP_ORDER.map(group => (
+
+
+ {t(`flows.palette.group.${group}`)}
+
+ {PALETTE_ENTRIES_BY_GROUP[group].map(entry => {
+ const colors = COLOR_CLASSES[entry.color];
+ const label = t(entry.labelKey, entry.kind);
+ return (
+
onAdd(entry)}
+ onDragStart={event => {
+ event.dataTransfer.setData(PALETTE_DND_MIME, entry.key);
+ event.dataTransfer.effectAllowed = 'copy';
+ }}
+ title={t('flows.palette.addNode').replace('{kind}', label)}
+ className={`flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left text-xs text-content transition-colors hover:bg-surface-hover ${colors.tint} ${colors.border}`}>
+
+ {entry.emoji}
+
+ {label}
+
+ );
+ })}
+
+ ))}
);
diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.test.tsx
index 0246a662b..7b77fb971 100644
--- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.test.tsx
+++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.test.tsx
@@ -93,9 +93,40 @@ describe('FlowCanvas (editable)', () => {
expect(graph.edges).toEqual([]);
});
- it('disables the delete button when nothing is selected', () => {
+ it('does not render Delete/Validate in the top toolbar (moved onto node cards)', () => {
renderCanvas(
);
- expect(screen.getByTestId('flow-editor-delete')).toBeDisabled();
+ expect(screen.queryByTestId('flow-editor-delete')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('flow-editor-validate')).not.toBeInTheDocument();
+ // Undo/redo + Save still live in the toolbar.
+ expect(screen.getByTestId('flow-editor-undo')).toBeInTheDocument();
+ });
+
+ it('shows the onboarding hint on a near-empty canvas and hides it after a node is added', () => {
+ renderCanvas(
);
+ expect(screen.getByTestId('flow-editor-onboarding')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
+ expect(screen.queryByTestId('flow-editor-onboarding')).not.toBeInTheDocument();
+ });
+
+ it('undoes and redoes a palette add', () => {
+ renderCanvas(
);
+ // Undo starts disabled (empty history); redo too.
+ expect(screen.getByTestId('flow-editor-undo')).toBeDisabled();
+ expect(screen.getByTestId('flow-editor-redo')).toBeDisabled();
+
+ fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
+ expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
+ expect(screen.getByTestId('flow-editor-undo')).not.toBeDisabled();
+
+ // Undo removes the added node and enables redo.
+ fireEvent.click(screen.getByTestId('flow-editor-undo'));
+ expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
+ expect(screen.getByTestId('flow-editor-undo')).toBeDisabled();
+ expect(screen.getByTestId('flow-editor-redo')).not.toBeDisabled();
+
+ // Redo brings it back.
+ fireEvent.click(screen.getByTestId('flow-editor-redo'));
+ expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
});
it('exposes no Save button when onSave is not provided', () => {
diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx
index 79879cebc..6636e9aa2 100644
--- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx
+++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx
@@ -67,8 +67,8 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
// Make an edit so the graph is dirty (Save is only ever enabled when dirty).
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
- // Force validation immediately via the explicit button.
- fireEvent.click(screen.getByTestId('flow-editor-validate'));
+ // Validation runs automatically on the debounce after the edit (the manual
+ // Validate button now lives on the selected node card).
const errors = await screen.findByTestId('flow-editor-errors');
expect(errors).toHaveTextContent('invalid config for node t: missing schedule');
@@ -93,7 +93,7 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
renderCanvas();
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
- fireEvent.click(screen.getByTestId('flow-editor-validate'));
+ // Auto-validation (debounced) surfaces the warning.
const warnings = await screen.findByTestId('flow-editor-warnings');
expect(warnings).toHaveTextContent('does not fire automatically');
diff --git a/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx b/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx
index aff8acd33..ed186a257 100644
--- a/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx
+++ b/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx
@@ -63,6 +63,31 @@ describe('FlowCanvas', () => {
expect(screen.getByText('Reply')).toBeInTheDocument();
});
+ it("labels named output ports (e.g. a condition's true/false) instead of a plaintext dump", () => {
+ const conditionNode: FlowNode = {
+ id: 'c',
+ type: 'flowNode',
+ position: { x: 0, y: 0 },
+ data: {
+ kind: 'condition',
+ name: 'Check status',
+ config: {},
+ ports: [],
+ inputPorts: ['main'],
+ outputPorts: ['true', 'false'],
+ } satisfies FlowNodeData,
+ };
+ render(
);
+ expect(screen.getByText('true')).toBeInTheDocument();
+ expect(screen.getByText('false')).toBeInTheDocument();
+ });
+
+ it('does not label a lone implicit main port (just its handle dot)', () => {
+ render(
);
+ // A plain agent/trigger node with a single `main` in/out shows no "main" text.
+ expect(screen.queryByText('main')).not.toBeInTheDocument();
+ });
+
it('renders the minimap and zoom/pan controls', () => {
const { container } = render(
);
expect(container.querySelector('.react-flow__minimap')).not.toBeNull();
diff --git a/app/src/components/flows/canvas/canvasActions.ts b/app/src/components/flows/canvas/canvasActions.ts
new file mode 100644
index 000000000..b2a0b552b
--- /dev/null
+++ b/app/src/components/flows/canvas/canvasActions.ts
@@ -0,0 +1,24 @@
+/**
+ * Canvas actions context — lets a selected {@link FlowNodeComponent} card
+ * trigger the editor-level actions (delete this node, validate the graph)
+ * without threading callbacks through React Flow's node `data` (which is part
+ * of the serialized graph). `EditableFlowCanvas` provides it; the read-only
+ * viewer leaves it `null`, so node cards render no actions there.
+ */
+import { createContext, useContext } from 'react';
+
+export interface CanvasActions {
+ /** Remove a single node (and its incident edges) by id. */
+ deleteNode: (nodeId: string) => void;
+ /** Run a full-graph validation pass. */
+ validate: () => void;
+ /** True while a validation pass is in flight. */
+ validating: boolean;
+}
+
+export const CanvasActionsContext = createContext
(null);
+
+/** Returns the canvas actions, or `null` in the read-only viewer. */
+export function useCanvasActions(): CanvasActions | null {
+ return useContext(CanvasActionsContext);
+}
diff --git a/app/src/components/flows/canvas/flowCanvasStyles.css b/app/src/components/flows/canvas/flowCanvasStyles.css
index 0ba35e39d..411704908 100644
--- a/app/src/components/flows/canvas/flowCanvasStyles.css
+++ b/app/src/components/flows/canvas/flowCanvasStyles.css
@@ -10,6 +10,19 @@
* *standalone Artifacts*, not this Tailwind `darkMode: 'class'` app.
*/
+/*
+ * Contain React Flow's internal stacking. The `.react-flow` container sets no
+ * z-index of its own, so its descendants' z-indexes (edges, and especially the
+ * dragged `.react-flow__connectionline` at z-index 1001) leak into the shared
+ * context and can paint OVER our sibling overlays (node palette, toolbar, and
+ * the node-config "edit box" drawer at z-20) — the drawer then reads as clipped
+ * / behind the canvas. Isolating the subtree keeps those z-indexes internal, so
+ * the overlays reliably sit above the whole canvas.
+ */
+.flow-canvas .react-flow {
+ isolation: isolate;
+}
+
.flow-canvas .react-flow__background {
background-color: rgb(var(--surface-canvas));
}
@@ -59,6 +72,64 @@
border-radius: 0.75rem;
}
+/*
+ * MiniMap node + viewport mask: React Flow paints these with hard-coded light
+ * greys (an `#e2e2e2` node fill + a translucent light mask) that read wrong on
+ * the dark theme. CSS `fill` wins over the SVG presentation attribute React
+ * Flow sets, so route both through theme tokens instead. `.react-flow__minimap`
+ * itself is already themed above.
+ */
+.flow-canvas .react-flow__minimap-node {
+ fill: rgb(var(--line-strong));
+ stroke: none;
+}
+
+.flow-canvas .react-flow__minimap-mask {
+ fill: rgb(var(--surface-canvas) / 0.6);
+}
+
+/*
+ * Connection line: the ghost edge drawn while dragging a new connection. React
+ * Flow's default stroke is near-black; use the primary accent (matching a
+ * hovered/selected edge) so it's visible on both themes.
+ */
+.flow-canvas .react-flow__connectionline .react-flow__connection-path,
+.flow-canvas .react-flow__connectionline path {
+ stroke: rgb(var(--primary-500));
+ stroke-width: 1.5;
+}
+
+/*
+ * Drag-select rectangle + the bounding box around a multi-node selection: the
+ * defaults are a fixed blue; tint them with the primary accent at low opacity.
+ */
+.flow-canvas .react-flow__selection,
+.flow-canvas .react-flow__nodesselection-rect {
+ background: rgb(var(--primary-500) / 0.08);
+ border: 1px solid rgb(var(--primary-500) / 0.6);
+}
+
+/*
+ * Handle connection feedback: while dragging a new edge React Flow (v12) flags
+ * the source handle (`.connectingfrom`), the hovered target (`.connectingto`),
+ * and a target that would form a valid edge (`.valid`). Paint the source
+ * primary, an invalid hovered target coral, and a valid one sage — so the
+ * port-aware `isValidConnection` result reads at a glance on both themes. The
+ * `.valid` rules come last so they win over the coral `.connectingto` default.
+ */
+.flow-canvas .react-flow__handle.connectingfrom {
+ background: rgb(var(--primary-500));
+}
+
+.flow-canvas .react-flow__handle.connectingto {
+ background: rgb(var(--coral-500));
+}
+
+.flow-canvas .react-flow__handle.connectingto.valid,
+.flow-canvas .react-flow__handle.valid {
+ background: rgb(var(--sage-500));
+}
+
.flow-canvas .react-flow__attribution {
background: rgb(var(--surface) / 0.7);
color: rgb(var(--content-muted));
diff --git a/app/src/components/flows/canvas/nodeConfig/NodeConfigDrawer.tsx b/app/src/components/flows/canvas/nodeConfig/NodeConfigDrawer.tsx
index aaa20fbc9..228b5b81f 100644
--- a/app/src/components/flows/canvas/nodeConfig/NodeConfigDrawer.tsx
+++ b/app/src/components/flows/canvas/nodeConfig/NodeConfigDrawer.tsx
@@ -19,12 +19,15 @@ import createDebug from 'debug';
import { memo, useCallback, useMemo, useState } from 'react';
import { useEscapeKey } from '../../../../hooks/useEscapeKey';
-import type { FlowNode } from '../../../../lib/flows/graphAdapter';
+import type { FlowEdge, FlowNode } from '../../../../lib/flows/graphAdapter';
import { nodeKindMeta } from '../../../../lib/flows/nodeKindMeta';
+import { describeNode } from '../../../../lib/flows/nodeSummary';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import { JsonField } from './nodeConfigFields';
import { NODE_CONFIG_FORMS } from './nodeConfigForms';
+import { NodeConnections } from './NodeConnections';
+import { type UpstreamExpressionOption, upstreamExpressionOptions } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:drawer');
@@ -41,16 +44,26 @@ export interface NodeConfigDrawerProps {
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
/** Secret-free credential refs for the picker (loaded once by the canvas). */
connections: FlowConnection[];
+ /** All graph nodes — used to derive the upstream `=nodes.…` picker options. */
+ nodes?: FlowNode[];
+ /** All graph edges — the drawer shows the selected node's incident ones. */
+ edges?: FlowEdge[];
+ /** Node id → display name, for labelling the other end of each connection. */
+ nodeLabelById?: Record;
+ /** Remove a single edge by id (from the connections list). */
+ onRemoveEdge?: (edgeId: string) => void;
}
function NodeConfigBody({
node,
onChange,
connections,
+ upstreamOptions,
}: {
node: FlowNode;
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
connections: FlowConnection[];
+ upstreamOptions: UpstreamExpressionOption[];
}) {
const { t } = useT();
const config = useMemo(() => node.data.config ?? {}, [node.data.config]);
@@ -94,7 +107,12 @@ function NodeConfigBody({
)}
{Form && !rawMode ? (
-
+
) : (
{},
+}: NodeConfigDrawerProps) {
const { t } = useT();
useEscapeKey(() => {
@@ -117,10 +144,20 @@ function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDr
onClose();
}, node !== null);
+ // Upstream `=nodes.…` picker options for the selected node's expression
+ // fields — its transitive ancestors' outputs (Feature: `nodes` scope).
+ const upstreamOptions = useMemo(
+ () => (node ? upstreamExpressionOptions(node.id, nodes, edges) : []),
+ [node, nodes, edges]
+ );
+
if (!node) return null;
const meta = nodeKindMeta(node.data.kind);
const kindLabel = t(`flows.nodeKind.${node.data.kind}`, node.data.kind);
+ // Dynamic "what this node will do", derived from the live config — updates as
+ // the fields below are edited (same summary shown on the node card).
+ const summary = describeNode(node.data.kind, node.data.config ?? {}, node.data.outputPorts);
return (
// `pointer-events-none` wrapper so the drawer floats over the canvas
@@ -135,12 +172,16 @@ function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDr
{meta.emoji}
-
- {kindLabel}
-
+ {/* Kind eyebrow — hidden when it just repeats the name (a default,
+ unrenamed node), so the header doesn't show the title twice. */}
+ {node.data.name.trim() !== kindLabel && (
+
+ {kindLabel}
+
+ )}
-
+
+ {/* Live, config-derived description of what this node will do. */}
+ {summary && (
+
+ {summary}
+
+ )}
+ {/* Incoming/outgoing edge connections — inspect + remove them here. */}
+
{/* Keyed by node id so the JSON editor's local buffer re-seeds on switch. */}
-
+
diff --git a/app/src/components/flows/canvas/nodeConfig/NodeConnections.tsx b/app/src/components/flows/canvas/nodeConfig/NodeConnections.tsx
new file mode 100644
index 000000000..7179635bb
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/NodeConnections.tsx
@@ -0,0 +1,138 @@
+/**
+ * NodeConnections — the "Connections" section of the node-config drawer. Lists
+ * the selected node's incident edges as rows (Inputs = edges arriving at this
+ * node, Outputs = edges leaving it), each showing the other node's name + the
+ * ports involved, with a remove (✕) button. Handy for debugging a wiring at a
+ * glance and detaching an edge without hunting for it on the canvas.
+ *
+ * Presentational: the canvas owns the edge state and passes `onRemoveEdge`.
+ */
+import { useMemo } from 'react';
+
+import type { FlowEdge } from '../../../../lib/flows/graphAdapter';
+import { useT } from '../../../../lib/i18n/I18nContext';
+
+export interface NodeConnectionsProps {
+ nodeId: string;
+ edges: FlowEdge[];
+ nodeLabelById: Record
;
+ onRemoveEdge: (edgeId: string) => void;
+}
+
+/** A port name worth showing — `main` is the implicit default, so hide it. */
+function portSuffix(handle: string | null | undefined): string {
+ return handle && handle !== 'main' ? `:${handle}` : '';
+}
+
+function ConnectionRow({
+ edgeId,
+ label,
+ onRemove,
+ removeLabel,
+}: {
+ edgeId: string;
+ label: string;
+ onRemove: () => void;
+ removeLabel: string;
+}) {
+ return (
+
+ {label}
+
+ ✕
+
+
+ );
+}
+
+export function NodeConnections({
+ nodeId,
+ edges,
+ nodeLabelById,
+ onRemoveEdge,
+}: NodeConnectionsProps) {
+ const { t } = useT();
+
+ const { inputs, outputs } = useMemo(() => {
+ const label = (id: string) => nodeLabelById[id] ?? id;
+ return {
+ // Incoming: this node is the target. Read as "[:port] → :thisPort".
+ inputs: edges
+ .filter(e => e.target === nodeId)
+ .map(e => ({
+ id: e.id,
+ label: `${label(e.source)}${portSuffix(e.sourceHandle)} → ${portSuffix(e.targetHandle) || 'in'}`,
+ })),
+ // Outgoing: this node is the source. Read as "thisPort: → [:port]".
+ outputs: edges
+ .filter(e => e.source === nodeId)
+ .map(e => ({
+ id: e.id,
+ label: `${portSuffix(e.sourceHandle) || 'out'} → ${label(e.target)}${portSuffix(e.targetHandle)}`,
+ })),
+ };
+ }, [edges, nodeId, nodeLabelById]);
+
+ const removeLabel = t('flows.nodeConfig.connections.remove');
+ const hasAny = inputs.length > 0 || outputs.length > 0;
+
+ return (
+
+
+ {t('flows.nodeConfig.connections.title')}
+
+
+ {!hasAny && (
+
+ {t('flows.nodeConfig.connections.none')}
+
+ )}
+
+ {inputs.length > 0 && (
+
+
+ {t('flows.nodeConfig.connections.inputs')}
+
+
+ {inputs.map(edge => (
+ onRemoveEdge(edge.id)}
+ />
+ ))}
+
+
+ )}
+
+ {outputs.length > 0 && (
+
+
+ {t('flows.nodeConfig.connections.outputs')}
+
+
+ {outputs.map(edge => (
+ onRemoveEdge(edge.id)}
+ />
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/flows/canvas/nodeConfig/ScheduleField.tsx b/app/src/components/flows/canvas/nodeConfig/ScheduleField.tsx
new file mode 100644
index 000000000..eba5c9c01
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/ScheduleField.tsx
@@ -0,0 +1,197 @@
+/**
+ * ScheduleField — the friendly schedule builder for a `schedule`-kind trigger
+ * (replaces the raw cron text box). Instead of hand-writing a cron expression,
+ * the author picks a frequency (every N minutes / hours, or a daily time) and
+ * optional weekdays; the field compiles that to the same bare cron string the
+ * flows engine already stores in `trigger.config.schedule`. A live plain-English
+ * summary ("Every 5 minutes on Wed") sits above the controls, and an "Advanced"
+ * toggle swaps in a raw cron input for power users / expressions the builder
+ * doesn't model (which round-trip untouched).
+ *
+ * Controlled: it holds no schedule state of its own — the cron string in
+ * `value` is the single source of truth, derived back into the visual controls
+ * via {@link parseCron} on every render.
+ */
+import { useCallback, useEffect, useId, useMemo, useState } from 'react';
+
+import {
+ buildCron,
+ type CronFreq,
+ type CronSpec,
+ DEFAULT_CRON_SPEC,
+ describeCron,
+ formatTime,
+ parseCron,
+ WEEKDAY_INITIAL,
+ WEEKDAY_SHORT,
+} from '../../../../lib/flows/cron';
+import { useT } from '../../../../lib/i18n/I18nContext';
+import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
+
+export interface ScheduleFieldProps {
+ /** The cron string stored on `config.schedule`. */
+ value: string;
+ onChange: (value: string) => void;
+ testId?: string;
+}
+
+const FREQUENCIES: CronFreq[] = ['minutes', 'hours', 'daily'];
+
+export function ScheduleField({ value, onChange, testId }: ScheduleFieldProps) {
+ const { t } = useT();
+ const id = useId();
+ const parsed = useMemo(() => parseCron(value), [value]);
+ // Open in advanced mode only when the current value is a real cron the builder
+ // can't model; an empty or builder-shaped value starts in the visual editor.
+ const [advanced, setAdvanced] = useState(() => value.trim() !== '' && parsed === null);
+
+ // Seed a sensible default the first time the field mounts empty (picking the
+ // "schedule" trigger kind), so the summary + controls are immediately live.
+ useEffect(() => {
+ if (value.trim() === '') onChange(buildCron(DEFAULT_CRON_SPEC));
+ // Run once on mount; the guard keeps it from clobbering a real value.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const spec: CronSpec = parsed ?? DEFAULT_CRON_SPEC;
+
+ const patch = useCallback(
+ (next: Partial) => onChange(buildCron({ ...spec, ...next })),
+ [spec, onChange]
+ );
+
+ const toggleWeekday = useCallback(
+ (day: number) => {
+ const set = new Set(spec.weekdays);
+ if (set.has(day)) set.delete(day);
+ else set.add(day);
+ patch({ weekdays: [...set] });
+ },
+ [spec.weekdays, patch]
+ );
+
+ const enterAdvanced = () => setAdvanced(true);
+ const exitAdvanced = () => {
+ // Returning to the visual editor: if the raw cron doesn't parse, reset to a
+ // builder-shaped default so the controls have something valid to show.
+ if (!parseCron(value)) onChange(buildCron(DEFAULT_CRON_SPEC));
+ setAdvanced(false);
+ };
+
+ return (
+
+
+ {/* Live plain-English summary of the compiled cron. */}
+
+ {describeCron(value)}
+
+
+ {advanced ? (
+
onChange(e.target.value)}
+ />
+ ) : (
+
+ {/* Frequency + interval / time row. */}
+
+ patch({ freq: e.target.value as CronFreq })}>
+ {FREQUENCIES.map(f => (
+
+ {t(`flows.nodeConfig.trigger.scheduleFreq_${f}`)}
+
+ ))}
+
+
+ {(spec.freq === 'minutes' || spec.freq === 'hours') && (
+
+ {t('flows.nodeConfig.trigger.scheduleEvery')}
+ patch({ interval: Number(e.target.value) })}
+ />
+ {t(`flows.nodeConfig.trigger.scheduleUnit_${spec.freq}`)}
+
+ )}
+
+ {spec.freq === 'daily' && (
+
+ {t('flows.nodeConfig.trigger.scheduleAt')}
+ {
+ const [h, m] = e.target.value.split(':').map(Number);
+ patch({ hour: h || 0, minute: m || 0 });
+ }}
+ />
+
+ )}
+
+
+ {/* Weekday restriction — applies to every frequency; none = every day. */}
+
+
+ {t('flows.nodeConfig.trigger.scheduleDays')}
+
+
+ {WEEKDAY_INITIAL.map((initial, day) => {
+ const active = spec.weekdays.includes(day);
+ return (
+ toggleWeekday(day)}
+ className={`h-7 w-7 rounded-full text-[11px] font-semibold transition-colors ${
+ active
+ ? 'bg-primary-500 text-content-inverted'
+ : 'border border-line-strong text-content-muted hover:bg-surface-hover'
+ }`}>
+ {initial}
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {advanced
+ ? t('flows.nodeConfig.trigger.scheduleSimple')
+ : t('flows.nodeConfig.trigger.scheduleAdvanced')}
+
+
+
+ );
+}
diff --git a/app/src/components/flows/canvas/nodeConfig/__tests__/NodeConfigDrawer.test.tsx b/app/src/components/flows/canvas/nodeConfig/__tests__/NodeConfigDrawer.test.tsx
index 71ce366d8..b6e4d0922 100644
--- a/app/src/components/flows/canvas/nodeConfig/__tests__/NodeConfigDrawer.test.tsx
+++ b/app/src/components/flows/canvas/nodeConfig/__tests__/NodeConfigDrawer.test.tsx
@@ -45,6 +45,49 @@ describe('NodeConfigDrawer', () => {
expect(screen.getByTestId('node-config-http-method')).toBeInTheDocument();
});
+ it("lists the node's input/output connections and removes one", () => {
+ const onRemoveEdge = vi.fn();
+ render(
+
+ );
+ // Incoming edge shows the source node; outgoing shows the target node.
+ expect(screen.getByTestId('node-connections-inputs')).toHaveTextContent('Start');
+ expect(screen.getByTestId('node-connections-outputs')).toHaveTextContent('Reply');
+
+ fireEvent.click(screen.getByTestId('node-connection-remove-e-out'));
+ expect(onRemoveEdge).toHaveBeenCalledWith('e-out');
+ });
+
+ it('shows an empty connections state for an unconnected node', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('node-connections-empty')).toBeInTheDocument();
+ });
+
+ it('shows a dynamic, config-derived description of the node', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('node-config-summary')).toHaveTextContent('POST https://x.test/hook');
+ });
+
it('emits a name patch when the name is edited', () => {
const onChange = vi.fn();
render(
diff --git a/app/src/components/flows/canvas/nodeConfig/__tests__/ScheduleField.test.tsx b/app/src/components/flows/canvas/nodeConfig/__tests__/ScheduleField.test.tsx
new file mode 100644
index 000000000..c231c98a3
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/__tests__/ScheduleField.test.tsx
@@ -0,0 +1,58 @@
+/**
+ * Behavior tests for the friendly schedule builder. Asserts it compiles the
+ * visual controls (frequency, interval, weekday toggles) to a cron string, shows
+ * a live plain-English summary, seeds a default when empty, and round-trips a
+ * custom cron through the advanced text field. `useT()` falls back to the
+ * bundled English map with no provider mounted (same as the sibling tests).
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ScheduleField } from '../ScheduleField';
+
+function setup(value = '*/5 * * * *') {
+ const onChange = vi.fn();
+ render( );
+ return { onChange };
+}
+
+describe('ScheduleField', () => {
+ it('renders a plain-English summary of the current cron', () => {
+ setup('*/5 * * * 3');
+ expect(screen.getByTestId('sched-summary')).toHaveTextContent('Every 5 minutes on Wed');
+ });
+
+ it('seeds a default cron when mounted empty', () => {
+ const { onChange } = setup('');
+ // Mount effect writes the default daily-9am schedule.
+ expect(onChange).toHaveBeenCalledWith('0 9 * * *');
+ });
+
+ it('recompiles the cron when the interval changes', () => {
+ const { onChange } = setup('*/5 * * * *');
+ fireEvent.change(screen.getByTestId('sched-interval'), { target: { value: '10' } });
+ expect(onChange).toHaveBeenLastCalledWith('*/10 * * * *');
+ });
+
+ it('recompiles the cron when the frequency changes to daily', () => {
+ const { onChange } = setup('*/5 * * * *');
+ fireEvent.change(screen.getByTestId('sched-freq'), { target: { value: 'daily' } });
+ // Default daily time (09:00), keeping "every day".
+ expect(onChange).toHaveBeenLastCalledWith('0 9 * * *');
+ });
+
+ it('toggles a weekday into the cron', () => {
+ const { onChange } = setup('*/5 * * * *');
+ // Day index 3 = Wednesday.
+ fireEvent.click(screen.getByTestId('sched-day-3'));
+ expect(onChange).toHaveBeenLastCalledWith('*/5 * * * 3');
+ });
+
+ it('opens the advanced cron field for an unmodellable expression', () => {
+ const { onChange } = setup('0 9 1 * *'); // day-of-month set → advanced
+ const cron = screen.getByTestId('sched-cron');
+ expect(cron).toHaveValue('0 9 1 * *');
+ fireEvent.change(cron, { target: { value: '15 3 * * *' } });
+ expect(onChange).toHaveBeenLastCalledWith('15 3 * * *');
+ });
+});
diff --git a/app/src/components/flows/canvas/nodeConfig/__tests__/nodeConfigForms.test.tsx b/app/src/components/flows/canvas/nodeConfig/__tests__/nodeConfigForms.test.tsx
index 703a15e25..20a11f0dd 100644
--- a/app/src/components/flows/canvas/nodeConfig/__tests__/nodeConfigForms.test.tsx
+++ b/app/src/components/flows/canvas/nodeConfig/__tests__/nodeConfigForms.test.tsx
@@ -76,6 +76,33 @@ describe('TransformForm', () => {
});
});
+describe('AgentForm', () => {
+ it('offers model hints and patches config.model with the chosen hint', () => {
+ const { onChange } = renderForm('agent', {});
+ fireEvent.change(screen.getByTestId('node-config-agent-model'), {
+ target: { value: 'hint:coding' },
+ });
+ expect(onChange).toHaveBeenLastCalledWith({ model: 'hint:coding' });
+ });
+
+ it('reveals a custom model input when Custom is selected', () => {
+ const { onChange } = renderForm('agent', {});
+ // No custom text box until Custom is picked.
+ expect(screen.queryByTestId('node-config-agent-model-custom')).not.toBeInTheDocument();
+ fireEvent.change(screen.getByTestId('node-config-agent-model'), {
+ target: { value: '__custom__' },
+ });
+ const custom = screen.getByTestId('node-config-agent-model-custom');
+ fireEvent.change(custom, { target: { value: 'gpt-4o-mini' } });
+ expect(onChange).toHaveBeenLastCalledWith({ model: 'gpt-4o-mini' });
+ });
+
+ it('opens in custom mode when config.model is a raw model id', () => {
+ renderForm('agent', { config: { model: 'claude-sonnet-5' } });
+ expect(screen.getByTestId('node-config-agent-model-custom')).toHaveValue('claude-sonnet-5');
+ });
+});
+
describe('TriggerForm', () => {
it('reveals the cron schedule field only for the schedule kind and patches it', () => {
const { onChange } = renderForm('trigger', {});
@@ -88,10 +115,12 @@ describe('TriggerForm', () => {
expect(onChange).toHaveBeenLastCalledWith({ trigger_kind: 'schedule' });
});
- it('shows the schedule input when config already has a schedule trigger_kind', () => {
- const { onChange } = renderForm('trigger', { config: { trigger_kind: 'schedule' } });
- const schedule = screen.getByTestId('node-config-trigger-schedule');
- fireEvent.change(schedule, { target: { value: '0 9 * * 1' } });
- expect(onChange).toHaveBeenLastCalledWith({ schedule: '0 9 * * 1' });
+ it('shows the friendly schedule builder (with summary) for a schedule trigger', () => {
+ renderForm('trigger', { config: { trigger_kind: 'schedule', schedule: '*/5 * * * 3' } });
+ expect(screen.getByTestId('node-config-trigger-schedule')).toBeInTheDocument();
+ // Compiled plain-English summary instead of a raw cron box.
+ expect(screen.getByTestId('node-config-trigger-schedule-summary')).toHaveTextContent(
+ 'Every 5 minutes on Wed'
+ );
});
});
diff --git a/app/src/components/flows/canvas/nodeConfig/composioFields.tsx b/app/src/components/flows/canvas/nodeConfig/composioFields.tsx
new file mode 100644
index 000000000..ce1e98195
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/composioFields.tsx
@@ -0,0 +1,326 @@
+/**
+ * Composio-aware dropdown fields for the node-config forms. Instead of making
+ * users hand-type a toolkit slug, an action slug, or a trigger slug, these pick
+ * from the user's ACTIVE connections and the live Composio catalog:
+ *
+ * - {@link ComposioToolkitField} — the connected apps the user actually has
+ * (derived from the `FlowConnection[]` the canvas already loaded), so a
+ * `trigger` app_event picks its app from a dropdown.
+ * - {@link ComposioActionField} — the real action slugs for a toolkit
+ * (`composio_list_tools`), for a `tool_call` node's action.
+ * - {@link ComposioTriggerField} — the available trigger slugs for a toolkit
+ * (`composio_list_available_triggers`), for an app_event's trigger.
+ *
+ * The action/trigger lists are fetched on demand (per toolkit) and cached in
+ * local state; they keep the current value selectable even if the catalog fetch
+ * fails, and offer a "Custom…" escape hatch so an advanced/unavailable slug is
+ * never a dead end. All fetches are guarded — outside Tauri (or on error) the
+ * field degrades to the custom input rather than throwing.
+ */
+import { useEffect, useMemo, useState } from 'react';
+
+import { listAvailableTriggers, listTools } from '../../../../lib/composio/composioApi';
+import { useT } from '../../../../lib/i18n/I18nContext';
+import type { FlowConnection } from '../../../../services/api/flowsApi';
+import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
+
+/** Sentinel select value that reveals a raw text input. */
+const CUSTOM = '__custom__';
+
+/** Prettify a toolkit slug for display (`googlesheets` → `Googlesheets`). */
+function toolkitLabel(slug: string): string {
+ return slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : slug;
+}
+
+/** Distinct connected Composio toolkits from the canvas's loaded connections. */
+export function connectedToolkits(connections: FlowConnection[]): string[] {
+ const seen = new Set();
+ for (const c of connections) {
+ if (c.kind === 'composio' && c.toolkit) seen.add(c.toolkit);
+ }
+ return [...seen].sort();
+}
+
+// ── toolkit (app) picker ─────────────────────────────────────────────────────
+
+export interface ComposioToolkitFieldProps {
+ label: string;
+ hint?: string;
+ value: string;
+ onChange: (value: string) => void;
+ connections: FlowConnection[];
+ testId?: string;
+}
+
+export function ComposioToolkitField({
+ label,
+ hint,
+ value,
+ onChange,
+ connections,
+ testId,
+}: ComposioToolkitFieldProps) {
+ const { t } = useT();
+ const toolkits = useMemo(() => connectedToolkits(connections), [connections]);
+
+ if (toolkits.length === 0) {
+ return (
+
+
+ {t('flows.nodeConfig.composio.noConnections')}
+
+
+ );
+ }
+
+ // Keep a saved-but-now-disconnected toolkit selectable so editing an existing
+ // flow never silently drops it.
+ const options = toolkits.includes(value) || value === '' ? toolkits : [value, ...toolkits];
+
+ return (
+
+ onChange(e.target.value)}>
+ {t('flows.nodeConfig.composio.selectApp')}
+ {options.map(tk => (
+
+ {toolkitLabel(tk)}
+
+ ))}
+
+
+ );
+}
+
+// ── shared catalog-slug dropdown (actions + triggers) ────────────────────────
+
+interface CatalogSlugFieldProps {
+ label: string;
+ hint?: string;
+ value: string;
+ onChange: (value: string) => void;
+ /** The toolkit whose catalog to load; empty → prompt to pick a connection. */
+ toolkit: string;
+ /** Fetch the slugs for a toolkit. */
+ fetchSlugs: (toolkit: string) => Promise;
+ emptyPrompt: string;
+ testId?: string;
+}
+
+function CatalogSlugField({
+ label,
+ hint,
+ value,
+ onChange,
+ toolkit,
+ fetchSlugs,
+ emptyPrompt,
+ testId,
+}: CatalogSlugFieldProps) {
+ const { t } = useT();
+ const [slugs, setSlugs] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [failed, setFailed] = useState(false);
+ // Custom mode is entered explicitly, or forced when the catalog can't load so
+ // the field never traps the user with an empty dropdown.
+ const [custom, setCustom] = useState(false);
+
+ useEffect(() => {
+ if (!toolkit) {
+ setSlugs([]);
+ setFailed(false);
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ setFailed(false);
+ void (async () => {
+ try {
+ const result = await fetchSlugs(toolkit);
+ if (!cancelled) setSlugs(result);
+ } catch {
+ if (!cancelled) setFailed(true);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [toolkit, fetchSlugs]);
+
+ if (!toolkit) {
+ return (
+
+
+ {emptyPrompt}
+
+
+ );
+ }
+
+ const showCustomInput = custom || (failed && slugs.length === 0);
+ // Keep the current value selectable even if it's not in the fetched list.
+ const options = value && !slugs.includes(value) ? [value, ...slugs] : slugs;
+
+ return (
+
+ {showCustomInput ? (
+ onChange(e.target.value)}
+ />
+ ) : (
+ {
+ if (e.target.value === CUSTOM) {
+ setCustom(true);
+ return;
+ }
+ onChange(e.target.value);
+ }}>
+
+ {loading
+ ? t('flows.nodeConfig.composio.loading')
+ : t('flows.nodeConfig.composio.select')}
+
+ {options.map(slug => (
+
+ {slug}
+
+ ))}
+ {t('flows.nodeConfig.composio.custom')}
+
+ )}
+
+ );
+}
+
+// ── tool_call action ─────────────────────────────────────────────────────────
+
+async function fetchActionSlugs(toolkit: string): Promise {
+ const res = await listTools([toolkit]);
+ return res.tools
+ .map(tool => tool.function?.name)
+ .filter((name): name is string => typeof name === 'string' && name.length > 0);
+}
+
+/** Parsed argument schema of one Composio action (from its JSON-schema `parameters`). */
+export interface ComposioActionSchema {
+ /** Argument names the action requires (`parameters.required`). */
+ required: string[];
+ /** The remaining declared argument names (`parameters.properties` minus required). */
+ optional: string[];
+ /** Raw per-argument JSON-schema fragments (`parameters.properties`). */
+ properties: Record;
+}
+
+/** Extract a {@link ComposioActionSchema} from a tool's raw `parameters` object. */
+function parseActionSchema(parameters: Record | undefined): ComposioActionSchema {
+ const rawProps = parameters?.properties;
+ const properties =
+ rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps)
+ ? (rawProps as Record)
+ : {};
+ const rawRequired = parameters?.required;
+ const required = Array.isArray(rawRequired)
+ ? rawRequired.filter((name): name is string => typeof name === 'string')
+ : [];
+ const optional = Object.keys(properties).filter(name => !required.includes(name));
+ return { required, optional, properties };
+}
+
+/**
+ * Per-toolkit cache of the action → schema map, sharing one in-flight
+ * `composio_list_tools` call per toolkit across all consumers. Failed fetches
+ * are evicted so a transient error doesn't poison the session.
+ */
+const actionSchemaCache = new Map>>();
+
+/**
+ * Fetch the argument schema of one Composio action (`toolkit` + action `slug`)
+ * from the live catalog. Returns `null` when the action isn't in the catalog
+ * (e.g. a custom slug); rejects when the catalog fetch itself fails — callers
+ * degrade to the raw args editor in both cases.
+ */
+export async function fetchActionSchema(
+ toolkit: string,
+ slug: string
+): Promise {
+ let promise = actionSchemaCache.get(toolkit);
+ if (!promise) {
+ promise = listTools([toolkit]).then(res => {
+ const bySlug = new Map();
+ for (const tool of res.tools) {
+ const name = tool.function?.name;
+ if (typeof name !== 'string' || name.length === 0) continue;
+ bySlug.set(name, parseActionSchema(tool.function.parameters));
+ }
+ return bySlug;
+ });
+ actionSchemaCache.set(toolkit, promise);
+ promise.catch(() => actionSchemaCache.delete(toolkit));
+ }
+ const bySlug = await promise;
+ return bySlug.get(slug) ?? null;
+}
+
+export function ComposioActionField(props: {
+ label: string;
+ hint?: string;
+ value: string;
+ onChange: (value: string) => void;
+ toolkit: string;
+ testId?: string;
+}) {
+ const { t } = useT();
+ return (
+
+ );
+}
+
+// ── app_event trigger ────────────────────────────────────────────────────────
+
+async function fetchTriggerSlugs(toolkit: string): Promise {
+ const res = await listAvailableTriggers(toolkit);
+ return res.triggers
+ .map(trigger => trigger.slug)
+ .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
+}
+
+export function ComposioTriggerField(props: {
+ label: string;
+ hint?: string;
+ value: string;
+ onChange: (value: string) => void;
+ toolkit: string;
+ testId?: string;
+}) {
+ const { t } = useT();
+ return (
+
+ );
+}
diff --git a/app/src/components/flows/canvas/nodeConfig/nativeToolFields.tsx b/app/src/components/flows/canvas/nodeConfig/nativeToolFields.tsx
new file mode 100644
index 000000000..504e09d78
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/nativeToolFields.tsx
@@ -0,0 +1,111 @@
+/**
+ * NativeToolField — the tool picker for the "Tool" node (native OpenHuman tools,
+ * as opposed to the Composio "App action" node). Loads the agent's tool
+ * registry from `listRuntimeTools` (`openhuman.javascript_list_tools`) and lets
+ * the author pick one from a dropdown; the value stored on `config.slug` is
+ * `oh:`, which the flow engine routes to the native tool registry.
+ *
+ * Shows the selected tool's description + a peek at its parameters so the author
+ * knows what `args` to supply. Fetches are guarded — outside Tauri / on error it
+ * falls back to a raw text input so a slug is never a dead end.
+ */
+import { useEffect, useMemo, useState } from 'react';
+
+import { useT } from '../../../../lib/i18n/I18nContext';
+import { listRuntimeTools, type RuntimeTool } from '../../../../services/api/runtimeToolsApi';
+import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
+
+/** The `oh:` prefix that marks a native-tool slug (mirrors the Rust constant). */
+export const NATIVE_TOOL_PREFIX = 'oh:';
+
+export interface NativeToolFieldProps {
+ label: string;
+ hint?: string;
+ /** The full `config.slug`, e.g. `oh:web_search` (or empty / `oh:`). */
+ value: string;
+ onChange: (value: string) => void;
+ testId?: string;
+}
+
+/** Strip the `oh:` prefix to the bare tool name. */
+function toolName(slug: string): string {
+ return slug.startsWith(NATIVE_TOOL_PREFIX) ? slug.slice(NATIVE_TOOL_PREFIX.length) : slug;
+}
+
+export function NativeToolField({ label, hint, value, onChange, testId }: NativeToolFieldProps) {
+ const { t } = useT();
+ const [tools, setTools] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const list = await listRuntimeTools();
+ if (!cancelled) setTools(list);
+ } catch {
+ if (!cancelled) setFailed(true);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const current = toolName(value);
+ const selected = useMemo(() => tools.find(tool => tool.name === current), [tools, current]);
+
+ // Catalog unavailable → raw text input so the author can still enter a slug.
+ if (failed && tools.length === 0) {
+ return (
+
+
+ onChange(e.target.value ? `${NATIVE_TOOL_PREFIX}${e.target.value.trim()}` : '')
+ }
+ />
+
+ );
+ }
+
+ // Keep a saved-but-unknown tool selectable.
+ const names = current && !tools.some(tk => tk.name === current) ? [current] : [];
+ const options = [...names, ...tools.map(tk => tk.name)];
+
+ return (
+
+
+
onChange(e.target.value ? `${NATIVE_TOOL_PREFIX}${e.target.value}` : '')}>
+
+ {loading ? t('flows.nodeConfig.native.loading') : t('flows.nodeConfig.native.select')}
+
+ {options.map(name => (
+
+ {name}
+
+ ))}
+
+ {selected?.description && (
+
+ {selected.description}
+
+ )}
+
+
+ );
+}
diff --git a/app/src/components/flows/canvas/nodeConfig/nodeConfigFields.tsx b/app/src/components/flows/canvas/nodeConfig/nodeConfigFields.tsx
index a8955325b..daba64101 100644
--- a/app/src/components/flows/canvas/nodeConfig/nodeConfigFields.tsx
+++ b/app/src/components/flows/canvas/nodeConfig/nodeConfigFields.tsx
@@ -20,14 +20,15 @@ import { useCallback, useId, useMemo, useState } from 'react';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
+import type { UpstreamExpressionOption } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:fields');
-const INPUT_CLASS =
+export const INPUT_CLASS =
'w-full rounded-lg border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-content ' +
'placeholder-content-faint transition-colors focus:border-primary-500 focus:outline-none ' +
'focus:ring-2 focus:ring-primary-500/20 disabled:opacity-50';
-const MONO_CLASS = 'font-mono text-[13px]';
+export const MONO_CLASS = 'font-mono text-[13px]';
/** Read a string field off a free-form config object, defaulting to `''`. */
export function configString(config: Record, key: string): string {
@@ -166,6 +167,153 @@ export function SelectField({ label, hint, value, onChange, options, testId }: S
);
}
+/**
+ * Canonical model route hints (mirrors `AgentEditorPage`'s list, which in turn
+ * mirrors the Rust `ModelSpec::Hint(...)` slugs). Selecting one routes the
+ * agent node by capability tier; the workspace resolves the concrete model.
+ */
+export const AGENT_MODEL_HINTS = [
+ 'hint:reasoning',
+ 'hint:chat',
+ 'hint:agentic',
+ 'hint:burst',
+ 'hint:coding',
+ 'hint:summarization',
+ 'hint:vision',
+] as const;
+
+/** Sentinel select value for "type a raw model id" — never persisted. */
+const CUSTOM_MODEL = '__custom__';
+
+export interface ModelHintFieldProps {
+ label: string;
+ hint?: string;
+ value: string;
+ onChange: (value: string) => void;
+ testId?: string;
+}
+
+/**
+ * Model selector for the `agent` node: a dropdown of the workspace's model
+ * route hints (`hint:chat`, `hint:coding`, …) with an "inherit" default and a
+ * custom escape hatch for a raw BYOK model id. Writes `hint:` (or the raw
+ * id, or `''` to inherit) onto `config.model`. Mirrors the agent-editor model
+ * picker so hints stay consistent across the app.
+ */
+export function ModelHintField({ label, hint, value, onChange, testId }: ModelHintFieldProps) {
+ const { t } = useT();
+ const id = useId();
+ // A value that's neither empty nor a known hint is a raw custom model id, so
+ // the picker opens in custom mode showing it in the text box.
+ const isKnown = value === '' || (AGENT_MODEL_HINTS as readonly string[]).includes(value);
+ const [customMode, setCustomMode] = useState(value !== '' && !isKnown);
+
+ const handleSelect = useCallback(
+ (next: string) => {
+ if (next === CUSTOM_MODEL) {
+ setCustomMode(true);
+ // Entering custom from a hint/inherit starts with an empty raw id.
+ if (isKnown) onChange('');
+ return;
+ }
+ setCustomMode(false);
+ onChange(next);
+ },
+ [isKnown, onChange]
+ );
+
+ return (
+
+
+ handleSelect(e.target.value)}>
+ {t('flows.nodeConfig.agent.modelInherit')}
+
+ {AGENT_MODEL_HINTS.map(h => (
+
+ {h}
+
+ ))}
+
+ {t('flows.nodeConfig.agent.modelCustom')}
+
+ {customMode && (
+ onChange(e.target.value)}
+ />
+ )}
+
+
+ );
+}
+
+/**
+ * Compact "insert an upstream value" dropdown (Feature: `nodes` scope picker).
+ * Always renders with the empty placeholder selected; picking an option calls
+ * `onInsert(expression)` and snaps back to the placeholder, so it acts as a
+ * one-shot menu button rather than a stateful select (same sentinel-select
+ * pattern as `composioFields.tsx`). Renders nothing when there are no options.
+ */
+export function UpstreamInsertSelect({
+ options,
+ onInsert,
+ testId,
+ className,
+}: {
+ options: UpstreamExpressionOption[];
+ onInsert: (expression: string) => void;
+ testId?: string;
+ className?: string;
+}) {
+ const { t } = useT();
+ if (options.length === 0) return null;
+ return (
+ {
+ if (!e.target.value) return;
+ log('UpstreamInsertSelect: insert %s', e.target.value);
+ onInsert(e.target.value);
+ }}>
+
+ {t('flows.nodeConfig.upstream.insert', 'Insert…')}
+
+ {options.map(opt => (
+
+ {opt.label}
+
+ ))}
+
+ );
+}
+
+export interface ExpressionFieldProps extends TextFieldProps {
+ /**
+ * `=nodes.…` expressions from upstream nodes; when non-empty a compact
+ * insert dropdown renders beside the input (picking replaces the value).
+ */
+ upstreamOptions?: UpstreamExpressionOption[];
+ /** Non-blocking warning (amber border + message), e.g. "Required — not wired". */
+ warning?: string;
+}
+
/**
* A field whose value is commonly a tinyflows `=`-expression. Monospace input
* with a leading "Expression" badge + hint so authors recognize `=item.foo`
@@ -178,12 +326,18 @@ export function ExpressionField({
onChange,
placeholder,
testId,
-}: TextFieldProps) {
+ upstreamOptions,
+ warning,
+}: ExpressionFieldProps) {
const { t } = useT();
const id = useId();
+ const borderClass = warning
+ ? 'border-amber-400 focus-within:border-amber-500 focus-within:ring-amber-500/20'
+ : 'border-line-strong focus-within:border-primary-500 focus-within:ring-primary-500/20';
return (
-
+
onChange(e.target.value)}
/>
+ {upstreamOptions && upstreamOptions.length > 0 && (
+
+ )}
+ {warning && (
+
+ {warning}
+
+ )}
);
}
@@ -210,6 +376,8 @@ export interface KeyMapFieldProps {
value: Record
;
onChange: (value: Record) => void;
monoValues?: boolean;
+ /** When non-empty, each row's value gets an upstream-expression insert menu. */
+ upstreamOptions?: UpstreamExpressionOption[];
testId?: string;
}
@@ -224,6 +392,7 @@ export function KeyMapField({
value,
onChange,
monoValues,
+ upstreamOptions,
testId,
}: KeyMapFieldProps) {
const { t } = useT();
@@ -272,6 +441,17 @@ export function KeyMapField({
commit(next);
}}
/>
+ {upstreamOptions && upstreamOptions.length > 0 && (
+ {
+ const next = rows.slice();
+ next[i] = [k, expr];
+ commit(next);
+ }}
+ className="w-20 shrink-0 cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1 py-1 text-[11px] text-content-muted focus:outline-none"
+ />
+ )}
:` connection ref. */
+function toolkitFromConnectionRef(ref: string): string {
+ const parts = ref.split(':');
+ return parts[0] === 'composio' && parts.length >= 2 ? parts[1] : '';
+}
export interface NodeConfigFormProps {
config: Record;
/** Shallow-merge patch into the node's config (undefined values are still set). */
onChange: (patch: Record) => void;
connections: FlowConnection[];
+ /**
+ * `=nodes.…` expressions referencing this node's upstream ancestors, for the
+ * insert pickers on expression-bearing fields. Optional — absent (or empty)
+ * simply hides the pickers.
+ */
+ upstreamOptions?: UpstreamExpressionOption[];
}
export type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
@@ -43,9 +72,10 @@ export type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
const TRIGGER_KINDS = ['manual', 'schedule', 'webhook', 'app_event'] as const;
-function TriggerForm({ config, onChange }: NodeConfigFormProps) {
+function TriggerForm({ config, onChange, connections }: NodeConfigFormProps) {
const { t } = useT();
const kind = configString(config, 'trigger_kind') || 'manual';
+ const toolkit = configString(config, 'toolkit');
return (
{kind === 'schedule' && (
-
onChange({ schedule: v })}
- placeholder="0 9 * * 1"
testId="node-config-trigger-schedule"
/>
)}
{kind === 'app_event' && (
<>
- onChange({ toolkit: v })}
- placeholder="github"
+ value={toolkit}
+ // Changing the app clears a now-mismatched trigger slug.
+ onChange={v => onChange({ toolkit: v, trigger_slug: '' })}
+ connections={connections}
+ testId="node-config-trigger-toolkit"
/>
- onChange({ trigger_slug: v })}
- placeholder="GITHUB_STAR_ADDED"
+ toolkit={toolkit}
+ testId="node-config-trigger-slug"
/>
>
)}
@@ -97,7 +127,7 @@ function TriggerForm({ config, onChange }: NodeConfigFormProps) {
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
-function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps) {
+function HttpRequestForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
@@ -113,6 +143,7 @@ function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps)
value={configString(config, 'url')}
onChange={v => onChange({ url: v })}
placeholder="https://api.example.com/v1/resource"
+ upstreamOptions={upstreamOptions}
testId="node-config-http-url"
/>
onChange({ prompt: v })}
placeholder={t('flows.nodeConfig.agent.promptPlaceholder')}
rows={5}
testId="node-config-agent-prompt"
/>
- 0 && (
+ // Appends the picked `=nodes.…` expression to the prompt (a prompt is
+ // prose, so inserting must not clobber what's already written).
+
+ onChange({ prompt: prompt ? `${prompt} ${expr}` : expr })}
+ testId="node-config-agent-prompt-upstream"
+ className="cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1.5 py-1 text-[11px] text-content-muted focus:outline-none"
+ />
+
+ )}
+ onChange({ model: v })}
- placeholder="gpt-4o-mini"
testId="node-config-agent-model"
/>
): Record {
+ const args = config.args;
+ return args && typeof args === 'object' && !Array.isArray(args)
+ ? (args as Record)
+ : {};
+}
+
+function ToolCallForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
+ const slug = configString(config, 'slug');
+ // Two flavours of tool_call: a native OpenHuman "Tool" (provider=openhuman /
+ // slug `oh:...`) vs a Composio "App action". The palette seeds `provider`.
+ const isNative =
+ configString(config, 'provider') === 'openhuman' || slug.startsWith(NATIVE_TOOL_PREFIX);
+
+ // The connected account is chosen first; its toolkit scopes the action list.
+ const connectionRef = configString(config, 'connection_ref');
+ const toolkit = toolkitFromConnectionRef(connectionRef);
+
+ // Required-arg preflight rows (Composio only): once an action is selected,
+ // fetch its JSON-schema `parameters` so each required arg gets its own
+ // labeled ExpressionField row. `null` (fetch failed / unknown action /
+ // native tool) degrades gracefully to just the raw JsonField below. The
+ // fetched schema is tagged with its toolkit/slug key so switching action
+ // discards a stale schema without a synchronous setState in the effect.
+ const schemaKey = `${toolkit} ${slug}`;
+ const [fetchedSchema, setFetchedSchema] = useState<{
+ key: string;
+ schema: ComposioActionSchema | null;
+ } | null>(null);
+ const actionSchema = fetchedSchema?.key === schemaKey ? fetchedSchema.schema : null;
+ // Remount key for the raw args JsonField: it holds a local text buffer that
+ // is seeded once, so row edits bump this to re-seed it with the merged args.
+ const [argsSeed, setArgsSeed] = useState(0);
+ useEffect(() => {
+ if (isNative || !toolkit || !slug) return;
+ const key = `${toolkit} ${slug}`;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const schema = await fetchActionSchema(toolkit, slug);
+ if (!cancelled) {
+ log(
+ 'ToolCallForm: schema %s/%s → required=%d optional=%d',
+ toolkit,
+ slug,
+ schema?.required.length ?? -1,
+ schema?.optional.length ?? -1
+ );
+ setFetchedSchema({ key, schema });
+ }
+ } catch {
+ // Catalog fetch failed — keep `null` and fall back to the raw editor.
+ log('ToolCallForm: schema fetch failed for %s/%s — raw args only', toolkit, slug);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [isNative, toolkit, slug]);
+
+ if (isNative) {
+ return (
+
+ onChange({ slug: v })}
+ testId="node-config-tool-slug"
+ />
+ onChange({ args: v })}
+ testId="node-config-tool-args"
+ />
+
+ );
+ }
+
+ const args = configArgs(config);
+ const requiredArgs = actionSchema?.required ?? [];
+ const setArg = (name: string, value: string) => {
+ const next = { ...args };
+ if (value === '') {
+ delete next[name];
+ } else {
+ next[name] = value;
+ }
+ log('ToolCallForm: setArg %s (empty=%s)', name, value === '');
+ onChange({ args: next });
+ // Re-seed the raw editor so it reflects the row edit.
+ setArgsSeed(s => s + 1);
+ };
+
return (
-
onChange({ slug: v })}
- placeholder="GITHUB_CREATE_ISSUE"
- testId="node-config-tool-slug"
- />
onChange({ connection_ref: v })}
+ value={connectionRef}
+ // Changing the account can change the toolkit → clear a stale action.
+ onChange={v => onChange({ connection_ref: v, slug: '' })}
connections={connections}
+ kinds={['composio']}
testId="node-config-tool-credential"
/>
+ onChange({ slug: v })}
+ toolkit={toolkit}
+ testId="node-config-tool-slug"
+ />
+ {requiredArgs.map(name => {
+ const raw = args[name];
+ const value = typeof raw === 'string' ? raw : raw == null ? '' : JSON.stringify(raw);
+ const missing = value.trim() === '';
+ return (
+ setArg(name, v)}
+ upstreamOptions={upstreamOptions}
+ warning={
+ missing
+ ? t('flows.nodeConfig.tool.requiredMissing', 'Required — not wired')
+ : undefined
+ }
+ testId={`node-config-tool-arg-${name}`}
+ />
+ );
+ })}
0
+ ? t('flows.nodeConfig.tool.argsAdvancedLabel', 'All args (advanced)')
+ : t('flows.nodeConfig.tool.argsLabel')
+ }
value={config.args ?? null}
onChange={v => onChange({ args: v })}
testId="node-config-tool-args"
@@ -219,7 +386,7 @@ function ConditionForm({ config, onChange }: NodeConfigFormProps) {
// ── switch ────────────────────────────────────────────────────────────────────
-function SwitchForm({ config, onChange }: NodeConfigFormProps) {
+function SwitchForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
@@ -229,6 +396,7 @@ function SwitchForm({ config, onChange }: NodeConfigFormProps) {
value={configString(config, 'expression')}
onChange={v => onChange({ expression: v })}
placeholder="item.type"
+ upstreamOptions={upstreamOptions}
testId="node-config-switch-expression"
/>
@@ -254,6 +422,7 @@ function TransformForm({ config, onChange }: NodeConfigFormProps) {
value={configStringMap(config, 'set')}
onChange={v => onChange({ set: v })}
monoValues
+ upstreamOptions={upstreamOptions}
testId="node-config-transform-set"
/>
diff --git a/app/src/components/flows/canvas/nodeConfig/upstreamOptions.ts b/app/src/components/flows/canvas/nodeConfig/upstreamOptions.ts
new file mode 100644
index 000000000..e9bb77f85
--- /dev/null
+++ b/app/src/components/flows/canvas/nodeConfig/upstreamOptions.ts
@@ -0,0 +1,108 @@
+/**
+ * Upstream-output picker options for the node-config drawer (`nodes` scope).
+ *
+ * The tinyflows engine exposes every upstream node's output to expressions as
+ * `=nodes..item` (and `=nodes..item.`). This module
+ * walks the graph's edges backward from the selected node to find its
+ * transitive ancestors and turns each into insertable expression options,
+ * labelled with the node's human name so authors pick "Extract recipient →
+ * email" instead of hand-typing node ids.
+ *
+ * Per-field options are only offered where the upstream node's output shape is
+ * statically knowable from its config:
+ * - `agent` with `config.output_parser.schema.properties` → one per property;
+ * - `transform` with `config.set` → one per set key;
+ * - everything else → just the node-level `item` option.
+ */
+import createDebug from 'debug';
+
+import type { FlowEdge, FlowNode } from '../../../../lib/flows/graphAdapter';
+import type { NodeKind } from '../../../../lib/flows/types';
+
+const log = createDebug('app:flows:nodeConfig:upstreamOptions');
+
+/** One insertable `=nodes.…` expression, labelled for the picker dropdown. */
+export interface UpstreamExpressionOption {
+ /** The full expression to insert, e.g. `=nodes.extract.item.email`. */
+ value: string;
+ /** Human label, e.g. "Extract recipient → email". */
+ label: string;
+}
+
+/** Narrow an unknown to a plain (non-array) object, else `null`. */
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+/**
+ * Output field names statically knowable from an upstream node's config.
+ * Empty when the shape can't be derived — the caller then offers only the
+ * node-level `item` option.
+ */
+function knownOutputFields(kind: NodeKind, config: Record): string[] {
+ if (kind === 'agent') {
+ const schema = asRecord(asRecord(config.output_parser)?.schema);
+ const properties = asRecord(schema?.properties);
+ return properties ? Object.keys(properties) : [];
+ }
+ if (kind === 'transform') {
+ const set = asRecord(config.set);
+ return set ? Object.keys(set) : [];
+ }
+ return [];
+}
+
+/**
+ * Build the `=nodes.…` picker options for the node identified by `nodeId`:
+ * a breadth-first backward walk over `edges` collects the node's transitive
+ * upstream ancestors (nearest first, cycle-safe), then each ancestor yields a
+ * node-level option plus one option per statically-known output field.
+ */
+export function upstreamExpressionOptions(
+ nodeId: string,
+ nodes: FlowNode[],
+ edges: FlowEdge[]
+): UpstreamExpressionOption[] {
+ const sourcesByTarget = new Map();
+ for (const edge of edges) {
+ const sources = sourcesByTarget.get(edge.target) ?? [];
+ sources.push(edge.source);
+ sourcesByTarget.set(edge.target, sources);
+ }
+
+ // BFS backward from the selected node — ancestors surface nearest-first.
+ const visited = new Set([nodeId]);
+ const ancestors: string[] = [];
+ const queue: string[] = [nodeId];
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (current === undefined) break;
+ for (const source of sourcesByTarget.get(current) ?? []) {
+ if (visited.has(source)) continue;
+ visited.add(source);
+ ancestors.push(source);
+ queue.push(source);
+ }
+ }
+
+ const nodeById = new Map(nodes.map(node => [node.id, node]));
+ const options: UpstreamExpressionOption[] = [];
+ for (const id of ancestors) {
+ const node = nodeById.get(id);
+ if (!node) continue;
+ const name = node.data.name.trim() || id;
+ options.push({ value: `=nodes.${id}.item`, label: `${name} → item` });
+ for (const field of knownOutputFields(node.data.kind, node.data.config ?? {})) {
+ options.push({ value: `=nodes.${id}.item.${field}`, label: `${name} → ${field}` });
+ }
+ }
+ log(
+ 'upstreamExpressionOptions: node=%s ancestors=%d options=%d',
+ nodeId,
+ ancestors.length,
+ options.length
+ );
+ return options;
+}
diff --git a/app/src/components/flows/workflowCopilotThreads.ts b/app/src/components/flows/workflowCopilotThreads.ts
new file mode 100644
index 000000000..d7400ec9a
--- /dev/null
+++ b/app/src/components/flows/workflowCopilotThreads.ts
@@ -0,0 +1,29 @@
+/**
+ * Session-lived cache of each workflow's copilot chat thread id, keyed by flow
+ * (a persisted flow id, or `'draft'` for an unsaved draft). The copilot panel
+ * unmounts when closed and `FlowEditor` remounts when switching flows, so
+ * without this the `workflow_builder` thread — and its transcript — would be
+ * lost on every open/close. Persisting the thread id lets the panel reseed the
+ * same thread (its messages live in the Redux `messagesByThreadId` store), so
+ * reopening the copilot restores the conversation for that workflow.
+ *
+ * Module-level (session) scope is deliberate: it survives component remounts
+ * without coupling to Redux, and a fresh session legitimately starts a new
+ * authoring conversation.
+ */
+const copilotThreadByFlow = new Map();
+
+/** Cache key for a flow: its persisted id, or `'draft'` for an unsaved draft. */
+export function copilotThreadKey(flowId: string | null): string {
+ return flowId ?? 'draft';
+}
+
+export function getCopilotThreadId(flowId: string | null): string | null {
+ return copilotThreadByFlow.get(copilotThreadKey(flowId)) ?? null;
+}
+
+export function setCopilotThreadId(flowId: string | null, threadId: string | null): void {
+ const key = copilotThreadKey(flowId);
+ if (threadId) copilotThreadByFlow.set(key, threadId);
+ else copilotThreadByFlow.delete(key);
+}
diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts
index 81dbd7f03..f510908f5 100644
--- a/app/src/hooks/useWorkflowBuilderChat.test.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.test.ts
@@ -15,12 +15,16 @@ const dispatch = vi.hoisted(() => vi.fn());
const selectorState = vi.hoisted(() => ({
activeThreadIds: {} as Record,
proposals: {} as Record,
+ messagesByThreadId: {} as Record,
}));
vi.mock('../store/hooks', () => ({
useAppDispatch: () => dispatch,
useAppSelector: (sel: (s: unknown) => unknown) =>
sel({
- thread: { activeThreadIds: selectorState.activeThreadIds },
+ thread: {
+ activeThreadIds: selectorState.activeThreadIds,
+ messagesByThreadId: selectorState.messagesByThreadId,
+ },
chatRuntime: { pendingWorkflowProposalsByThread: selectorState.proposals },
}),
}));
@@ -45,6 +49,7 @@ describe('useWorkflowBuilderChat', () => {
chatSend.mockReset().mockResolvedValue(undefined);
selectorState.activeThreadIds = {};
selectorState.proposals = {};
+ selectorState.messagesByThreadId = {};
dispatch.mockReset().mockImplementation((action: { type: string }) => {
if (action.type === 'createNewThread') {
return { unwrap: () => Promise.resolve({ id: 'builder-1' }) };
diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts
index 741aee22d..deedac509 100644
--- a/app/src/hooks/useWorkflowBuilderChat.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.ts
@@ -54,6 +54,13 @@ export interface UseWorkflowBuilderChat {
sending: boolean;
/** The latest proposal the agent returned on this thread, or `null`. */
proposal: WorkflowProposal | null;
+ /**
+ * The dedicated thread's transcript (user + agent turns) so a caller can
+ * render the full conversation, not just the latest proposal. Empty until the
+ * first send. Sourced from the same `messagesByThreadId` store the main chat
+ * transcript reads.
+ */
+ messages: ThreadMessage[];
/** Last send error (thread create / RPC failure), or `null`. */
error: string | null;
/** Send a builder turn, creating the dedicated thread on first use. */
@@ -62,6 +69,8 @@ export interface UseWorkflowBuilderChat {
clearProposal: () => void;
}
+const EMPTY_MESSAGES: ThreadMessage[] = [];
+
/**
* @param seedThreadId Optional existing thread to bind to instead of creating a
* fresh one — lets a caller reuse a thread across mounts (unused today; the
@@ -78,12 +87,18 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
const proposalsByThread = useAppSelector(
state => state.chatRuntime.pendingWorkflowProposalsByThread
);
+ const messagesByThreadId = useAppSelector(state => state.thread.messagesByThreadId);
const proposal = useMemo(
() => (threadId ? (proposalsByThread[threadId] ?? null) : null),
[threadId, proposalsByThread]
);
+ const messages = useMemo(
+ () => (threadId ? (messagesByThreadId[threadId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES),
+ [threadId, messagesByThreadId]
+ );
+
// "Sending" = we're mid-dispatch OR the runtime still marks the thread active.
const runtimeActive = threadId ? Boolean(activeThreadIds[threadId]) : false;
const sending = localSending || runtimeActive;
@@ -157,5 +172,5 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
if (threadId) dispatch(clearWorkflowProposalForThread({ threadId }));
}, [dispatch, threadId]);
- return { threadId, sending, proposal, error, send, clearProposal };
+ return { threadId, sending, proposal, messages, error, send, clearProposal };
}
diff --git a/app/src/lib/flows/cron.test.ts b/app/src/lib/flows/cron.test.ts
new file mode 100644
index 000000000..f12d9e11e
--- /dev/null
+++ b/app/src/lib/flows/cron.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Unit tests for the cron builder helper. Covers build → parse round-trips for
+ * the three supported shapes (minutes / hours / daily, with and without
+ * weekday restrictions), plaintext descriptions, and the graceful fallbacks for
+ * cron strings the visual builder doesn't model.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { buildCron, type CronSpec, DEFAULT_CRON_SPEC, describeCron, parseCron } from './cron';
+
+function spec(overrides: Partial): CronSpec {
+ return { ...DEFAULT_CRON_SPEC, ...overrides };
+}
+
+describe('buildCron', () => {
+ it('compiles every-N-minutes', () => {
+ expect(buildCron(spec({ freq: 'minutes', interval: 5 }))).toBe('*/5 * * * *');
+ });
+
+ it('compiles every-N-minutes restricted to weekdays', () => {
+ expect(buildCron(spec({ freq: 'minutes', interval: 5, weekdays: [3] }))).toBe('*/5 * * * 3');
+ });
+
+ it('compiles every-N-hours at a minute', () => {
+ expect(buildCron(spec({ freq: 'hours', interval: 2, minute: 30 }))).toBe('30 */2 * * *');
+ });
+
+ it('compiles daily at a time', () => {
+ expect(buildCron(spec({ freq: 'daily', hour: 9, minute: 30 }))).toBe('30 9 * * *');
+ });
+
+ it('compiles a weekly time on selected days (deduped + sorted)', () => {
+ expect(buildCron(spec({ freq: 'daily', hour: 14, minute: 0, weekdays: [5, 1, 3, 1] }))).toBe(
+ '0 14 * * 1,3,5'
+ );
+ });
+
+ it('clamps out-of-range values', () => {
+ expect(buildCron(spec({ freq: 'minutes', interval: 999 }))).toBe('*/59 * * * *');
+ expect(buildCron(spec({ freq: 'daily', hour: 30, minute: -5 }))).toBe('0 23 * * *');
+ });
+});
+
+describe('parseCron', () => {
+ it('round-trips each supported shape', () => {
+ for (const expr of [
+ '*/5 * * * *',
+ '*/5 * * * 3',
+ '30 */2 * * *',
+ '30 9 * * *',
+ '0 14 * * 1,3,5',
+ ]) {
+ const parsed = parseCron(expr);
+ expect(parsed).not.toBeNull();
+ expect(buildCron(parsed!)).toBe(expr);
+ }
+ });
+
+ it('maps cron Sunday (7) to 0', () => {
+ expect(parseCron('0 9 * * 7')?.weekdays).toEqual([0]);
+ });
+
+ it('returns null for shapes the builder does not model', () => {
+ expect(parseCron('0 9 1 * *')).toBeNull(); // day-of-month set
+ expect(parseCron('0 9 * 6 *')).toBeNull(); // month set
+ expect(parseCron('0 9 * * MON')).toBeNull(); // named weekday
+ expect(parseCron('not a cron')).toBeNull();
+ expect(parseCron('0 9 * *')).toBeNull(); // wrong field count
+ });
+});
+
+describe('describeCron', () => {
+ it('describes the common shapes in plain language', () => {
+ expect(describeCron('*/5 * * * *')).toBe('Every 5 minutes');
+ expect(describeCron('*/1 * * * *')).toBe('Every minute');
+ expect(describeCron('*/5 * * * 3')).toBe('Every 5 minutes on Wed');
+ expect(describeCron('0 */2 * * *')).toBe('Every 2 hours');
+ expect(describeCron('30 9 * * *')).toBe('Every day at 09:30');
+ expect(describeCron('0 14 * * 1,3,5')).toBe('At 14:00 on Mon, Wed, Fri');
+ });
+
+ it('collapses full weekday sets to friendly phrases', () => {
+ expect(describeCron('0 9 * * 1,2,3,4,5')).toBe('At 09:00 on weekdays');
+ expect(describeCron('0 9 * * 0,6')).toBe('At 09:00 on weekends');
+ expect(describeCron('0 9 * * 0,1,2,3,4,5,6')).toBe('Every day at 09:00');
+ });
+
+ it('falls back for custom / empty expressions', () => {
+ expect(describeCron('0 9 1 * *')).toBe('Custom schedule (0 9 1 * *)');
+ expect(describeCron('')).toBe('No schedule set');
+ });
+});
diff --git a/app/src/lib/flows/cron.ts b/app/src/lib/flows/cron.ts
new file mode 100644
index 000000000..a3244e7d6
--- /dev/null
+++ b/app/src/lib/flows/cron.ts
@@ -0,0 +1,174 @@
+/**
+ * Small cron helper for the schedule-trigger builder (`ScheduleField`). The
+ * flows engine stores `trigger.config.schedule` as a bare 5-field cron string
+ * (`minute hour day-of-month month day-of-week`) — `crate::openhuman::cron::
+ * Schedule` deserializes a bare string as `Cron { expr }` — so the visual
+ * builder compiles to and parses from that same string, staying compatible with
+ * existing saved flows and the workflow-builder agent.
+ *
+ * Scope: the builder covers the three common shapes (every N minutes, every N
+ * hours, daily/weekly at a time), each optionally restricted to selected
+ * weekdays. Any other cron string round-trips untouched through the advanced
+ * text field; {@link parseCron} returns `null` for it (→ advanced mode) and
+ * {@link describeCron} falls back to a generic label.
+ */
+
+/** How often the schedule fires. */
+export type CronFreq = 'minutes' | 'hours' | 'daily';
+
+/** Structured schedule the visual builder edits; compiles to a cron string. */
+export interface CronSpec {
+ freq: CronFreq;
+ /** Interval for `minutes` (1–59) / `hours` (1–23). Ignored for `daily`. */
+ interval: number;
+ /** Hour of day 0–23 (`daily`). */
+ hour: number;
+ /** Minute of hour 0–59 (`daily` + `hours`' "at minute"). */
+ minute: number;
+ /** Selected weekdays, 0=Sun … 6=Sat. Empty = every day. */
+ weekdays: number[];
+}
+
+/** Short weekday names indexed 0=Sun … 6=Sat. */
+export const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
+/** Single-letter weekday initials for compact toggles (Sun-first). */
+export const WEEKDAY_INITIAL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as const;
+
+export const DEFAULT_CRON_SPEC: CronSpec = {
+ freq: 'daily',
+ interval: 1,
+ hour: 9,
+ minute: 0,
+ weekdays: [],
+};
+
+function clamp(n: number, lo: number, hi: number): number {
+ if (!Number.isFinite(n)) return lo;
+ return Math.min(hi, Math.max(lo, Math.floor(n)));
+}
+
+/** Normalize a weekday list: dedupe, map cron's `7`→`0` (Sun), keep 0–6, sort. */
+function normalizeWeekdays(days: number[]): number[] {
+ return [...new Set(days.map(d => (d === 7 ? 0 : d)))]
+ .filter(d => d >= 0 && d <= 6)
+ .sort((a, b) => a - b);
+}
+
+/** Compile a {@link CronSpec} to a 5-field cron expression. */
+export function buildCron(spec: CronSpec): string {
+ const days = normalizeWeekdays(spec.weekdays);
+ const dow = days.length > 0 ? days.join(',') : '*';
+ if (spec.freq === 'minutes') {
+ return `*/${clamp(spec.interval, 1, 59)} * * * ${dow}`;
+ }
+ if (spec.freq === 'hours') {
+ return `${clamp(spec.minute, 0, 59)} */${clamp(spec.interval, 1, 23)} * * ${dow}`;
+ }
+ return `${clamp(spec.minute, 0, 59)} ${clamp(spec.hour, 0, 23)} * * ${dow}`;
+}
+
+/** Parse a step field ("star-slash-N"); returns `null` if it isn't one. */
+function parseStep(field: string): { step: number } | null {
+ const m = /^\*\/(\d+)$/.exec(field);
+ return m ? { step: Number(m[1]) } : null;
+}
+
+function parseWeekdayField(field: string): number[] | null {
+ if (field === '*') return [];
+ const parts = field.split(',').map(p => p.trim());
+ const nums: number[] = [];
+ for (const p of parts) {
+ if (!/^\d+$/.test(p)) return null; // named days (MON) etc. → advanced
+ nums.push(Number(p));
+ }
+ const norm = normalizeWeekdays(nums);
+ return norm.length > 0 ? norm : null;
+}
+
+/**
+ * Parse a cron string back into a {@link CronSpec}, or `null` when it's outside
+ * the builder's covered shapes (→ the caller falls back to the advanced text
+ * field). Only recognizes the exact forms {@link buildCron} emits: a `*`
+ * day-of-month and month, with a stepped minute/hour and a numeric weekday list.
+ */
+export function parseCron(expr: string): CronSpec | null {
+ const fields = expr.trim().split(/\s+/);
+ if (fields.length !== 5) return null;
+ const [min, hour, dom, mon, dowField] = fields;
+ if (dom !== '*' || mon !== '*') return null;
+
+ const weekdays = parseWeekdayField(dowField);
+ if (weekdays === null) return null;
+
+ // Every N minutes: `*/N * * * dow`
+ const minStep = parseStep(min);
+ if (minStep && hour === '*') {
+ return { ...DEFAULT_CRON_SPEC, freq: 'minutes', interval: minStep.step, weekdays };
+ }
+
+ // Every N hours: `M */N * * dow`
+ const hourStep = parseStep(hour);
+ if (hourStep && /^\d+$/.test(min)) {
+ return {
+ ...DEFAULT_CRON_SPEC,
+ freq: 'hours',
+ interval: hourStep.step,
+ minute: Number(min),
+ weekdays,
+ };
+ }
+
+ // Daily / weekly at a time: `M H * * dow`
+ if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
+ return {
+ ...DEFAULT_CRON_SPEC,
+ freq: 'daily',
+ hour: Number(hour),
+ minute: Number(min),
+ weekdays,
+ };
+ }
+
+ return null;
+}
+
+/** Zero-padded `HH:MM`. */
+export function formatTime(hour: number, minute: number): string {
+ return `${String(clamp(hour, 0, 23)).padStart(2, '0')}:${String(clamp(minute, 0, 59)).padStart(2, '0')}`;
+}
+
+/** Human phrase for a weekday set: "every day" / "weekdays" / "weekends" / "Mon, Wed". */
+function describeWeekdays(days: number[]): string {
+ const norm = normalizeWeekdays(days);
+ if (norm.length === 0 || norm.length === 7) return 'every day';
+ if (norm.join(',') === '1,2,3,4,5') return 'weekdays';
+ if (norm.join(',') === '0,6') return 'weekends';
+ return norm.map(d => WEEKDAY_SHORT[d]).join(', ');
+}
+
+/**
+ * A plain-language summary of a cron string ("Every 5 minutes on Wednesday",
+ * "Every day at 09:00"). Falls back to a generic label for expressions the
+ * builder doesn't model, so an advanced user's custom cron still gets a
+ * (non-misleading) description.
+ */
+export function describeCron(expr: string): string {
+ const spec = parseCron(expr);
+ if (!spec) {
+ return expr.trim() ? `Custom schedule (${expr.trim()})` : 'No schedule set';
+ }
+ const daysPhrase = describeWeekdays(spec.weekdays);
+ const onDays = daysPhrase === 'every day' ? '' : ` on ${daysPhrase}`;
+
+ if (spec.freq === 'minutes') {
+ const unit = spec.interval === 1 ? 'minute' : `${spec.interval} minutes`;
+ return `Every ${unit}${onDays}`;
+ }
+ if (spec.freq === 'hours') {
+ const unit = spec.interval === 1 ? 'hour' : `${spec.interval} hours`;
+ return `Every ${unit}${onDays}`;
+ }
+ // daily / weekly
+ const time = formatTime(spec.hour, spec.minute);
+ return daysPhrase === 'every day' ? `Every day at ${time}` : `At ${time} on ${daysPhrase}`;
+}
diff --git a/app/src/lib/flows/nodeKindMeta.ts b/app/src/lib/flows/nodeKindMeta.ts
index fd93b48af..1c75cb1da 100644
--- a/app/src/lib/flows/nodeKindMeta.ts
+++ b/app/src/lib/flows/nodeKindMeta.ts
@@ -15,9 +15,17 @@ import type { NodeKind } from './types';
export type NodeColor = 'sage' | 'primary' | 'amber' | 'coral' | 'neutral';
+/**
+ * Palette grouping for the node kinds: `triggers` (what starts a run),
+ * `actions` (do work / call out), `logic` (route, branch, reshape data). Used
+ * by {@link NodePalette} to render labelled sections instead of a flat list.
+ */
+export type NodeGroup = 'triggers' | 'actions' | 'logic';
+
export interface NodeKindMeta {
emoji: string;
color: NodeColor;
+ group: NodeGroup;
}
/**
@@ -40,20 +48,88 @@ export const NODE_KINDS: NodeKind[] = [
'sub_workflow',
];
-/** Per-kind emoji + border/chip color. See the module doc for the color model. */
+/** Per-kind emoji + border/chip color + palette group. See the module doc. */
export const NODE_KIND_META: Record = {
- trigger: { emoji: '⚡', color: 'sage' },
- agent: { emoji: '🤖', color: 'primary' },
- tool_call: { emoji: '🔧', color: 'amber' },
- http_request: { emoji: '🌐', color: 'coral' },
- code: { emoji: '📝', color: 'sage' },
- condition: { emoji: '🔀', color: 'primary' },
- switch: { emoji: '🔁', color: 'amber' },
- merge: { emoji: '🔗', color: 'coral' },
- split_out: { emoji: '📤', color: 'sage' },
- transform: { emoji: '♻️', color: 'primary' },
- output_parser: { emoji: '📋', color: 'amber' },
- sub_workflow: { emoji: '🧩', color: 'coral' },
+ trigger: { emoji: '⚡', color: 'sage', group: 'triggers' },
+ agent: { emoji: '🤖', color: 'primary', group: 'actions' },
+ tool_call: { emoji: '🔧', color: 'amber', group: 'actions' },
+ http_request: { emoji: '🌐', color: 'coral', group: 'actions' },
+ code: { emoji: '📝', color: 'sage', group: 'actions' },
+ sub_workflow: { emoji: '🧩', color: 'coral', group: 'actions' },
+ condition: { emoji: '🔀', color: 'primary', group: 'logic' },
+ switch: { emoji: '🔁', color: 'amber', group: 'logic' },
+ merge: { emoji: '🔗', color: 'coral', group: 'logic' },
+ split_out: { emoji: '📤', color: 'sage', group: 'logic' },
+ transform: { emoji: '♻️', color: 'primary', group: 'logic' },
+ output_parser: { emoji: '📋', color: 'amber', group: 'logic' },
+};
+
+/** Palette group render order + the kinds in each, derived from NODE_KIND_META. */
+export const NODE_GROUP_ORDER: NodeGroup[] = ['triggers', 'actions', 'logic'];
+
+export const NODE_KINDS_BY_GROUP: Record = {
+ triggers: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'triggers'),
+ actions: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'actions'),
+ logic: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'logic'),
+};
+
+/**
+ * One palette entry. Usually 1:1 with a `NodeKind`, but `tool_call` splits into
+ * TWO entries — an "App action" (Composio OAuth) node and a "Tool" (native
+ * OpenHuman) node — distinguished by the `preset` config (`provider`) merged
+ * onto the new node. `key` is the palette/testid id; `labelKey` its i18n label.
+ */
+export interface PaletteEntry {
+ key: string;
+ kind: NodeKind;
+ group: NodeGroup;
+ emoji: string;
+ color: NodeColor;
+ labelKey: string;
+ /** Default config merged onto a node created from this entry. */
+ preset?: Record;
+}
+
+export const PALETTE_ENTRIES: PaletteEntry[] = NODE_KINDS.flatMap((kind): PaletteEntry[] => {
+ const meta = NODE_KIND_META[kind];
+ if (kind === 'tool_call') {
+ return [
+ {
+ key: 'tool_call',
+ kind: 'tool_call',
+ group: 'actions',
+ emoji: '🔌',
+ color: 'amber',
+ labelKey: 'flows.palette.appAction',
+ preset: { provider: 'composio' },
+ },
+ {
+ key: 'oh_tool',
+ kind: 'tool_call',
+ group: 'actions',
+ emoji: '🛠️',
+ color: 'primary',
+ labelKey: 'flows.palette.ohTool',
+ preset: { provider: 'openhuman' },
+ },
+ ];
+ }
+ return [
+ {
+ key: kind,
+ kind,
+ group: meta.group,
+ emoji: meta.emoji,
+ color: meta.color,
+ labelKey: `flows.nodeKind.${kind}`,
+ },
+ ];
+});
+
+export const PALETTE_ENTRIES_BY_GROUP: Record = {
+ triggers: PALETTE_ENTRIES.filter(e => e.group === 'triggers'),
+ actions: PALETTE_ENTRIES.filter(e => e.group === 'actions'),
+ logic: PALETTE_ENTRIES.filter(e => e.group === 'logic'),
};
/**
@@ -64,35 +140,36 @@ export const NODE_KIND_META: Record = {
* here so an unrecognized kind renders as a plain neutral node instead of
* crashing the whole canvas (there's no error boundary around ``).
*/
-export const DEFAULT_NODE_META: NodeKindMeta = { emoji: '❔', color: 'neutral' };
+export const DEFAULT_NODE_META: NodeKindMeta = { emoji: '❔', color: 'neutral', group: 'actions' };
/** Resolve a kind's metadata, falling back to {@link DEFAULT_NODE_META}. */
export function nodeKindMeta(kind: NodeKind): NodeKindMeta {
return NODE_KIND_META[kind] ?? DEFAULT_NODE_META;
}
-export const COLOR_CLASSES: Record = {
+// `tint` is a faint wash of the accent for the node card body, so the whole
+// node reads as gently colour-coded rather than a stark white box under the
+// stronger header `chip`.
+export const COLOR_CLASSES: Record = {
sage: {
border: 'border-sage-400 dark:border-sage-500/60',
chip: 'bg-sage-100 dark:bg-sage-500/20',
+ tint: 'bg-sage-50/60 dark:bg-sage-500/[0.07]',
},
primary: {
border: 'border-primary-400 dark:border-primary-500/60',
chip: 'bg-primary-100 dark:bg-primary-500/20',
+ tint: 'bg-primary-50/60 dark:bg-primary-500/[0.07]',
},
amber: {
border: 'border-amber-400 dark:border-amber-500/60',
chip: 'bg-amber-100 dark:bg-amber-500/20',
+ tint: 'bg-amber-50/60 dark:bg-amber-500/[0.07]',
},
coral: {
border: 'border-coral-400 dark:border-coral-500/60',
chip: 'bg-coral-100 dark:bg-coral-500/20',
+ tint: 'bg-coral-50/60 dark:bg-coral-500/[0.07]',
},
- neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle' },
+ neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle', tint: 'bg-surface' },
};
-
-/** Even vertical offsets (in %) for `count` handles along one side of a card. */
-export function handleOffsets(count: number): number[] {
- if (count <= 1) return [50];
- return Array.from({ length: count }, (_, i) => ((i + 1) / (count + 1)) * 100);
-}
diff --git a/app/src/lib/flows/nodeSummary.test.ts b/app/src/lib/flows/nodeSummary.test.ts
new file mode 100644
index 000000000..693f50e41
--- /dev/null
+++ b/app/src/lib/flows/nodeSummary.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Unit tests for describeNode — the dynamic per-node card summary. Asserts the
+ * config-driven text for representative kinds plus the generic fallbacks when
+ * config isn't filled in.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { describeNode } from './nodeSummary';
+
+describe('describeNode', () => {
+ it('describes a schedule trigger via its cron', () => {
+ expect(describeNode('trigger', { trigger_kind: 'schedule', schedule: '*/5 * * * 3' })).toBe(
+ 'Every 5 minutes on Wed'
+ );
+ expect(describeNode('trigger', { trigger_kind: 'manual' })).toBe('Runs on demand');
+ });
+
+ it('describes an http_request from method + url', () => {
+ expect(describeNode('http_request', { method: 'POST', url: 'https://api.x.com/v1' })).toBe(
+ 'POST https://api.x.com/v1'
+ );
+ expect(describeNode('http_request', {})).toBe('GET request (set a URL)');
+ });
+
+ it('describes an agent by model hint', () => {
+ expect(describeNode('agent', { model: 'hint:coding' })).toBe('Asks the coding');
+ expect(describeNode('agent', { prompt: 'Summarize the thread', model: '' })).toContain(
+ 'Summarize the thread'
+ );
+ });
+
+ it('describes branch nodes and reflects output routes', () => {
+ expect(describeNode('condition', { field: 'status' })).toBe('If status → true / false');
+ expect(describeNode('switch', { expression: 'item.type' }, ['a', 'b', 'default'])).toBe(
+ 'Routes by item.type (3 routes)'
+ );
+ });
+
+ it('falls back for tool_call / transform with empty config', () => {
+ expect(describeNode('tool_call', {})).toBe('Runs an app action (pick one)');
+ expect(describeNode('transform', { set: { a: '=1', b: '=2' } })).toBe(
+ 'Sets 2 fields on each item'
+ );
+ });
+
+ it('returns empty string for an unknown kind', () => {
+ expect(describeNode('time_travel', {})).toBe('');
+ });
+});
diff --git a/app/src/lib/flows/nodeSummary.ts b/app/src/lib/flows/nodeSummary.ts
new file mode 100644
index 000000000..5a84f8bd4
--- /dev/null
+++ b/app/src/lib/flows/nodeSummary.ts
@@ -0,0 +1,95 @@
+/**
+ * describeNode — a short, dynamic plain-language line for a workflow node card
+ * ("GET https://…", "Every 5 minutes", "If status → true / false"), derived
+ * from the node's live config so the card explains what it will do at a glance
+ * without opening the config drawer. Falls back to a generic per-kind label
+ * when the config isn't filled in yet.
+ *
+ * Pure + dependency-light (only {@link describeCron}) so it's trivially
+ * testable and can be called on every render of `FlowNodeComponent`.
+ */
+import { describeCron } from './cron';
+import type { NodeKind } from './types';
+
+function str(config: Record, key: string): string {
+ const v = config[key];
+ return typeof v === 'string' ? v.trim() : '';
+}
+
+function truncate(value: string, max = 52): string {
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
+}
+
+/**
+ * @param kind the node kind (may be an unknown string for a future kind)
+ * @param config the node's free-form config object
+ * @param outputPorts effective output ports (used to hint branch routing)
+ */
+export function describeNode(
+ kind: NodeKind | string,
+ config: Record,
+ outputPorts: string[] = []
+): string {
+ switch (kind) {
+ case 'trigger': {
+ const tk = str(config, 'trigger_kind') || 'manual';
+ if (tk === 'manual') return 'Runs on demand';
+ if (tk === 'schedule') return describeCron(str(config, 'schedule'));
+ if (tk === 'webhook') return 'Runs on an incoming webhook';
+ if (tk === 'app_event') {
+ const parts = [str(config, 'toolkit'), str(config, 'trigger_slug')].filter(Boolean);
+ return parts.length ? `On ${parts.join(' · ')}` : 'Runs on an app event';
+ }
+ return `Trigger: ${tk}`;
+ }
+ case 'agent': {
+ const prompt = str(config, 'prompt');
+ const model = str(config, 'model');
+ const modelLabel = model ? model.replace(/^hint:/, '') : 'default model';
+ return prompt ? `“${truncate(prompt, 40)}” · ${modelLabel}` : `Asks the ${modelLabel}`;
+ }
+ case 'tool_call': {
+ const slug = str(config, 'slug');
+ if (str(config, 'provider') === 'openhuman' || slug.startsWith('oh:')) {
+ const name = slug.replace(/^oh:/, '');
+ return name ? `Runs ${name}` : 'Runs an OpenHuman tool (pick one)';
+ }
+ return slug ? `Runs ${slug}` : 'Runs an app action (pick one)';
+ }
+ case 'http_request': {
+ const method = str(config, 'method') || 'GET';
+ const url = str(config, 'url');
+ return url ? `${method} ${truncate(url, 40)}` : `${method} request (set a URL)`;
+ }
+ case 'code': {
+ const lang = str(config, 'language') || 'javascript';
+ return `Runs ${lang} code`;
+ }
+ case 'condition': {
+ const field = str(config, 'field');
+ return field ? `If ${field} → true / false` : 'Branches to true / false';
+ }
+ case 'switch': {
+ const expr = str(config, 'expression') || str(config, 'field');
+ const routes = outputPorts.length > 0 ? ` (${outputPorts.length} routes)` : '';
+ return expr ? `Routes by ${expr}${routes}` : `Routes by a value${routes}`;
+ }
+ case 'merge':
+ return 'Merges parallel branches';
+ case 'split_out': {
+ const path = str(config, 'path');
+ return path ? `Splits each ${path}` : 'Splits a list into items';
+ }
+ case 'transform': {
+ const set = config.set;
+ const n = set && typeof set === 'object' && !Array.isArray(set) ? Object.keys(set).length : 0;
+ return n > 0 ? `Sets ${n} field${n > 1 ? 's' : ''} on each item` : 'Reshapes each item';
+ }
+ case 'output_parser':
+ return 'Parses the previous output';
+ case 'sub_workflow':
+ return 'Runs a nested workflow';
+ default:
+ return '';
+ }
+}
diff --git a/app/src/lib/flows/workflowBuilderPrompt.ts b/app/src/lib/flows/workflowBuilderPrompt.ts
index 2388d1b5a..728e487af 100644
--- a/app/src/lib/flows/workflowBuilderPrompt.ts
+++ b/app/src/lib/flows/workflowBuilderPrompt.ts
@@ -23,6 +23,14 @@ import type { WorkflowGraph } from './types';
const DELEGATE_DIRECTIVE =
'Use the workflow builder to design a tinyflows automation and return a workflow proposal for me to review. Do not save, enable, or run anything.';
+/**
+ * Revise variant: still "propose, never persist" for saving/enabling, but the
+ * copilot may run an ALREADY-SAVED flow to test it — only when I ask and after
+ * confirming with me first (the specialist's own prompt enforces the ask).
+ */
+const DELEGATE_DIRECTIVE_REVISE =
+ 'Use the workflow builder to revise this tinyflows automation and return the revised proposal. Do not save or enable anything (I save via the UI). You may run_workflow the SAVED flow to test it, but ONLY if I ask and only after you confirm with me first.';
+
/** Serialize a graph compactly for injection as agent context. */
function serializeGraph(graph: WorkflowGraph): string {
try {
@@ -47,19 +55,28 @@ export function buildCreatePrompt(description: string): string {
* than starting over. `instruction` is the user's change request ("add a Slack
* notification on failure", "make the schedule weekdays only").
*/
-export function buildRevisePrompt(instruction: string, graph: WorkflowGraph): string {
+export function buildRevisePrompt(
+ instruction: string,
+ graph: WorkflowGraph,
+ flowId?: string | null
+): string {
const trimmed = instruction.trim();
- return [
- DELEGATE_DIRECTIVE,
+ const lines = [
+ DELEGATE_DIRECTIVE_REVISE,
'',
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
'```json',
serializeGraph(graph),
'```',
- '',
- 'Revise it as follows and return the full revised proposal:',
- trimmed,
- ].join('\n');
+ ];
+ if (flowId) {
+ lines.push(
+ '',
+ `This workflow is saved with flow id \`${flowId}\` — if I ask you to run/test it, you may run_workflow that id, but confirm with me first.`
+ );
+ }
+ lines.push('', 'Revise it as follows and return the full revised proposal:', trimmed);
+ return lines.join('\n');
}
/** Context for a repair turn opened from a failed run's inspector. */
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 05ab35aef..8f95b0d3b 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -3614,6 +3614,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'مكتمل',
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
'flowRuns.status.failed': 'فشل',
+ 'flowRuns.status.cancelled': 'ملغى',
'flows.page.title': 'سير العمل',
'flows.page.description': 'أتمتة محفوظة يمكنك تفعيلها وتشغيلها ومتابعتها.',
@@ -3730,6 +3731,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'ابقَ',
'flows.editor.leaveDiscard': 'غادر',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'تحذيرات التعبير',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'تم حله إلى null',
+ 'flows.runs.sidebarTitle': 'التشغيلات',
+ 'flows.runs.refresh': 'تحديث التشغيلات',
+ 'flows.palette.appAction': 'إجراء تطبيق',
+ 'flows.palette.ohTool': 'أداة',
+ 'flows.editor.deleteNode': 'حذف',
+ 'flows.nodeConfig.connections.title': 'الاتصالات',
+ 'flows.nodeConfig.connections.inputs': 'المدخلات',
+ 'flows.nodeConfig.connections.outputs': 'المخرجات',
+ 'flows.nodeConfig.connections.none': 'غير متصل بأي عقدة أخرى بعد.',
+ 'flows.nodeConfig.connections.remove': 'إزالة الاتصال',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'تعبير cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'التكرار',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'كل N دقيقة',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'كل N ساعة',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'في وقت محدد كل يوم',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'كل',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'الفاصل',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'دقيقة',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'ساعة',
+ 'flows.nodeConfig.trigger.scheduleAt': 'عند',
+ 'flows.nodeConfig.trigger.scheduleTime': 'وقت اليوم',
+ 'flows.nodeConfig.trigger.scheduleDays': 'في الأيام (اختياري — اتركه فارغًا لكل يوم)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'متقدم (تحرير cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'العودة إلى الجدولة البسيطة',
+ 'flows.nodeConfig.trigger.pickApp': 'اختر تطبيقًا متصلًا أولًا.',
+ 'flows.nodeConfig.tool.pickConnection': 'اختر اتصالًا أولًا.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'لا توجد تطبيقات متصلة بعد. صِل تطبيقًا من الإعدادات → التطبيقات، ثم اختره هنا.',
+ 'flows.nodeConfig.composio.selectApp': 'اختر تطبيقًا…',
+ 'flows.nodeConfig.composio.select': 'اختر…',
+ 'flows.nodeConfig.composio.loading': 'جارٍ التحميل…',
+ 'flows.nodeConfig.composio.custom': 'إدخال يدوي…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': 'اختر مستوى القدرة — سيحدد مساحة العمل النموذج.',
+ 'flows.nodeConfig.agent.modelInherit': 'الافتراضي (موروث)',
+ 'flows.nodeConfig.agent.modelHints': 'تلميحات النموذج',
+ 'flows.nodeConfig.agent.modelCustom': 'نموذج مخصص…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'مثل gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'مطلوب',
+ 'flows.nodeConfig.tool.requiredMissing': 'مطلوب — غير موصول',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'كل الوسيطات (متقدم)',
+ 'flows.nodeConfig.upstream.insert': 'إدراج…',
+ 'flows.nodeConfig.upstream.insertLabel': 'إدراج قيمة من خطوة سابقة',
+ 'flows.nodeConfig.native.toolLabel': 'أداة',
+ 'flows.nodeConfig.native.toolHint': 'إحدى أدوات المساعد المدمجة (البحث، الوسائط، الملفات، …).',
+ 'flows.nodeConfig.native.select': 'اختر أداة…',
+ 'flows.nodeConfig.native.loading': 'جارٍ تحميل الأدوات…',
'flows.nodeConfig.close': 'إغلاق الإعدادات',
'flows.nodeConfig.nameLabel': 'الاسم',
'flows.nodeConfig.namePlaceholder': 'اسم العقدة',
@@ -6658,6 +6708,27 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'نسخ',
'codeBlock.copied': 'تم النسخ!',
+ 'flows.editor.undo': 'تراجع',
+ 'flows.editor.redo': 'إعادة',
+ 'flows.editor.onboardingTitle': 'أنشئ سير عملك',
+ 'flows.editor.onboardingBody':
+ 'أضِف عقدة من اللوحة على اليسار، ثم اسحب بين النقاط على كل بطاقة لربطها.',
+ 'flows.palette.search': 'البحث عن العقد…',
+ 'flows.palette.noResults': 'لا توجد عقد مطابقة',
+ 'flows.palette.group.triggers': 'المشغّلات',
+ 'flows.palette.group.actions': 'الإجراءات',
+ 'flows.palette.group.logic': 'المنطق',
+ 'flows.list.duplicate': 'تكرار',
+ 'flows.list.duplicated': 'تم تكرار سير العمل',
+ 'flows.list.delete': 'حذف',
+ 'flows.list.deleted': 'تم حذف سير العمل',
+ 'flows.list.moreActions': 'المزيد من الإجراءات',
+ 'flows.delete.title': 'حذف سير العمل؟',
+ 'flows.delete.body': 'سيتم حذف "{name}" وسجل تشغيله نهائيًا. لا يمكن التراجع عن ذلك.',
+ 'flows.delete.cancel': 'إلغاء',
+ 'flows.delete.confirm': 'حذف',
+ 'flows.delete.deleting': 'جارٍ الحذف…',
+ 'flows.canvas.renameLabel': 'إعادة تسمية سير العمل',
};
export default messages;
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index a697c2a8a..11dc11cb1 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -3697,6 +3697,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'সম্পন্ন',
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
'flowRuns.status.failed': 'ব্যর্থ',
+ 'flowRuns.status.cancelled': 'বাতিল করা হয়েছে',
'flows.page.title': 'ওয়ার্কফ্লো',
'flows.page.description': 'সংরক্ষিত অটোমেশন যা আপনি সক্ষম, চালাতে এবং পর্যবেক্ষণ করতে পারেন।',
@@ -3819,6 +3820,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'থাকুন',
'flows.editor.leaveDiscard': 'চলে যান',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'এক্সপ্রেশন সতর্কতা',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'null হিসেবে সমাধান হয়েছে',
+ 'flows.runs.sidebarTitle': 'রান',
+ 'flows.runs.refresh': 'রান রিফ্রেশ করুন',
+ 'flows.palette.appAction': 'অ্যাপ অ্যাকশন',
+ 'flows.palette.ohTool': 'টুল',
+ 'flows.editor.deleteNode': 'মুছুন',
+ 'flows.nodeConfig.connections.title': 'সংযোগ',
+ 'flows.nodeConfig.connections.inputs': 'ইনপুট',
+ 'flows.nodeConfig.connections.outputs': 'আউটপুট',
+ 'flows.nodeConfig.connections.none': 'এখনও অন্য কোনো নোডের সাথে যুক্ত নয়।',
+ 'flows.nodeConfig.connections.remove': 'সংযোগ সরান',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron এক্সপ্রেশন',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'ফ্রিকোয়েন্সি',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'প্রতি N মিনিটে',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'প্রতি N ঘণ্টায়',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'প্রতিদিন নির্দিষ্ট সময়ে',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'প্রতি',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'ব্যবধান',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'মিনিট',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'ঘণ্টা',
+ 'flows.nodeConfig.trigger.scheduleAt': 'এ',
+ 'flows.nodeConfig.trigger.scheduleTime': 'দিনের সময়',
+ 'flows.nodeConfig.trigger.scheduleDays': 'দিনগুলোতে (ঐচ্ছিক — প্রতিদিনের জন্য খালি রাখুন)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'উন্নত (cron সম্পাদনা)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'সহজ সময়সূচিতে ফিরুন',
+ 'flows.nodeConfig.trigger.pickApp': 'আগে একটি সংযুক্ত অ্যাপ বেছে নিন।',
+ 'flows.nodeConfig.tool.pickConnection': 'আগে একটি সংযোগ বেছে নিন।',
+ 'flows.nodeConfig.composio.noConnections':
+ 'এখনও কোনো অ্যাপ সংযুক্ত নেই। Settings → Apps থেকে একটি সংযুক্ত করে এখানে বেছে নিন।',
+ 'flows.nodeConfig.composio.selectApp': 'একটি অ্যাপ বেছে নিন…',
+ 'flows.nodeConfig.composio.select': 'বেছে নিন…',
+ 'flows.nodeConfig.composio.loading': 'লোড হচ্ছে…',
+ 'flows.nodeConfig.composio.custom': 'ম্যানুয়ালি লিখুন…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': 'ক্ষমতার স্তর বেছে নিন — ওয়ার্কস্পেস মডেল নির্ধারণ করবে।',
+ 'flows.nodeConfig.agent.modelInherit': 'ডিফল্ট (উত্তরাধিকার)',
+ 'flows.nodeConfig.agent.modelHints': 'মডেল ইঙ্গিত',
+ 'flows.nodeConfig.agent.modelCustom': 'কাস্টম মডেল…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'যেমন gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'আবশ্যক',
+ 'flows.nodeConfig.tool.requiredMissing': 'আবশ্যক — যুক্ত নয়',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'সব আর্গুমেন্ট (উন্নত)',
+ 'flows.nodeConfig.upstream.insert': 'সন্নিবেশ করুন…',
+ 'flows.nodeConfig.upstream.insertLabel': 'আগের ধাপ থেকে একটি মান সন্নিবেশ করুন',
+ 'flows.nodeConfig.native.toolLabel': 'টুল',
+ 'flows.nodeConfig.native.toolHint': 'সহকারীর বিল্ট-ইন টুলগুলোর একটি (সার্চ, মিডিয়া, ফাইল, …)।',
+ 'flows.nodeConfig.native.select': 'একটি টুল বেছে নিন…',
+ 'flows.nodeConfig.native.loading': 'টুল লোড হচ্ছে…',
'flows.nodeConfig.close': 'সেটিংস বন্ধ করুন',
'flows.nodeConfig.nameLabel': 'নাম',
'flows.nodeConfig.namePlaceholder': 'নোডের নাম',
@@ -6808,6 +6858,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'কপি করুন',
'codeBlock.copied': 'কপি হয়েছে!',
+ 'flows.editor.undo': 'পূর্বাবস্থায় ফিরুন',
+ 'flows.editor.redo': 'পুনরায় করুন',
+ 'flows.editor.onboardingTitle': 'আপনার ওয়ার্কফ্লো তৈরি করুন',
+ 'flows.editor.onboardingBody':
+ 'বাঁদিকের প্যালেট থেকে একটি নোড যোগ করুন, তারপর প্রতিটি কার্ডের বিন্দুগুলোর মধ্যে টেনে সেগুলো সংযুক্ত করুন।',
+ 'flows.palette.search': 'নোড খুঁজুন…',
+ 'flows.palette.noResults': 'কোনো মিলে যাওয়া নোড নেই',
+ 'flows.palette.group.triggers': 'ট্রিগার',
+ 'flows.palette.group.actions': 'ক্রিয়া',
+ 'flows.palette.group.logic': 'লজিক',
+ 'flows.list.duplicate': 'অনুলিপি করুন',
+ 'flows.list.duplicated': 'ওয়ার্কফ্লো অনুলিপি হয়েছে',
+ 'flows.list.delete': 'মুছুন',
+ 'flows.list.deleted': 'ওয়ার্কফ্লো মুছে ফেলা হয়েছে',
+ 'flows.list.moreActions': 'আরও ক্রিয়া',
+ 'flows.delete.title': 'ওয়ার্কফ্লো মুছবেন?',
+ 'flows.delete.body':
+ '"{name}" এবং এর রান ইতিহাস স্থায়ীভাবে মুছে ফেলা হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।',
+ 'flows.delete.cancel': 'বাতিল করুন',
+ 'flows.delete.confirm': 'মুছুন',
+ 'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…',
+ 'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন',
};
export default messages;
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 1b26507e0..4f3ec61da 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -3787,6 +3787,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Abgeschlossen',
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
'flowRuns.status.failed': 'Fehlgeschlagen',
+ 'flowRuns.status.cancelled': 'Abgebrochen',
'flows.page.title': 'Workflows',
'flows.page.description':
@@ -3915,6 +3916,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Bleiben',
'flows.editor.leaveDiscard': 'Verlassen',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Ausdruckswarnungen',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'wurde zu null aufgelöst',
+ 'flows.runs.sidebarTitle': 'Läufe',
+ 'flows.runs.refresh': 'Läufe aktualisieren',
+ 'flows.palette.appAction': 'App-Aktion',
+ 'flows.palette.ohTool': 'Werkzeug',
+ 'flows.editor.deleteNode': 'Löschen',
+ 'flows.nodeConfig.connections.title': 'Verbindungen',
+ 'flows.nodeConfig.connections.inputs': 'Eingaben',
+ 'flows.nodeConfig.connections.outputs': 'Ausgaben',
+ 'flows.nodeConfig.connections.none': 'Noch mit keinem anderen Knoten verbunden.',
+ 'flows.nodeConfig.connections.remove': 'Verbindung entfernen',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron-Ausdruck',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Häufigkeit',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Alle N Minuten',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Alle N Stunden',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'Jeden Tag zu einer Uhrzeit',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'alle',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Intervall',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'Min.',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'Std.',
+ 'flows.nodeConfig.trigger.scheduleAt': 'um',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Tageszeit',
+ 'flows.nodeConfig.trigger.scheduleDays': 'An Tagen (optional — leer lassen für jeden Tag)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Erweitert (Cron bearbeiten)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Zur einfachen Planung zurück',
+ 'flows.nodeConfig.trigger.pickApp': 'Wähle zuerst eine verbundene App aus.',
+ 'flows.nodeConfig.tool.pickConnection': 'Wähle zuerst eine Verbindung aus.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Noch keine Apps verbunden. Verbinde eine unter Einstellungen → Apps und wähle sie dann hier aus.',
+ 'flows.nodeConfig.composio.selectApp': 'App auswählen…',
+ 'flows.nodeConfig.composio.select': 'Auswählen…',
+ 'flows.nodeConfig.composio.loading': 'Wird geladen…',
+ 'flows.nodeConfig.composio.custom': 'Manuell eingeben…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Wähle eine Fähigkeitsstufe — der Workspace löst das Modell auf.',
+ 'flows.nodeConfig.agent.modelInherit': 'Standard (erben)',
+ 'flows.nodeConfig.agent.modelHints': 'Modellhinweise',
+ 'flows.nodeConfig.agent.modelCustom': 'Benutzerdefiniertes Modell…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'z. B. gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'erforderlich',
+ 'flows.nodeConfig.tool.requiredMissing': 'Erforderlich — nicht verdrahtet',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Alle Argumente (erweitert)',
+ 'flows.nodeConfig.upstream.insert': 'Einfügen…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Wert aus einem vorherigen Schritt einfügen',
+ 'flows.nodeConfig.native.toolLabel': 'Werkzeug',
+ 'flows.nodeConfig.native.toolHint':
+ 'Eines der integrierten Assistentenwerkzeuge (Suche, Medien, Dateien, …).',
+ 'flows.nodeConfig.native.select': 'Werkzeug auswählen…',
+ 'flows.nodeConfig.native.loading': 'Werkzeuge werden geladen…',
'flows.nodeConfig.close': 'Einstellungen schließen',
'flows.nodeConfig.nameLabel': 'Name',
'flows.nodeConfig.namePlaceholder': 'Knotenname',
@@ -6997,6 +7049,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Kopieren',
'codeBlock.copied': 'Kopiert!',
+ 'flows.editor.undo': 'Rückgängig',
+ 'flows.editor.redo': 'Wiederherstellen',
+ 'flows.editor.onboardingTitle': 'Workflow erstellen',
+ 'flows.editor.onboardingBody':
+ 'Füge über die Palette links einen Knoten hinzu und ziehe dann zwischen den Punkten auf den Karten, um sie zu verbinden.',
+ 'flows.palette.search': 'Knoten suchen…',
+ 'flows.palette.noResults': 'Keine passenden Knoten',
+ 'flows.palette.group.triggers': 'Auslöser',
+ 'flows.palette.group.actions': 'Aktionen',
+ 'flows.palette.group.logic': 'Logik',
+ 'flows.list.duplicate': 'Duplizieren',
+ 'flows.list.duplicated': 'Workflow dupliziert',
+ 'flows.list.delete': 'Löschen',
+ 'flows.list.deleted': 'Workflow gelöscht',
+ 'flows.list.moreActions': 'Weitere Aktionen',
+ 'flows.delete.title': 'Workflow löschen?',
+ 'flows.delete.body':
+ '"{name}" und der zugehörige Ausführungsverlauf werden dauerhaft entfernt. Dies kann nicht rückgängig gemacht werden.',
+ 'flows.delete.cancel': 'Abbrechen',
+ 'flows.delete.confirm': 'Löschen',
+ 'flows.delete.deleting': 'Wird gelöscht…',
+ 'flows.canvas.renameLabel': 'Workflow umbenennen',
};
export default messages;
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index fd5c51faa..393932a47 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -4318,6 +4318,10 @@ const en: TranslationMap = {
'flowRuns.inspector.noSteps': 'No steps recorded yet.',
'flowRuns.inspector.output': 'Output',
'flowRuns.inspector.port': 'Port',
+ // Null-resolution diagnostics: `=`-expressions in a step's config that
+ // resolved to null during the run (likely a mis-wired reference).
+ 'flowRuns.inspector.diagnosticsTitle': 'Expression warnings',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'resolved to null',
'flowRuns.inspector.loading': 'Loading run…',
'flowRuns.inspector.loadError': 'Could not load this run',
// Phase 5c — "Fix with agent" opens the canvas copilot preloaded with this
@@ -4339,6 +4343,7 @@ const en: TranslationMap = {
'flowRuns.status.completed': 'Completed',
'flowRuns.status.pending_approval': 'Awaiting approval',
'flowRuns.status.failed': 'Failed',
+ 'flowRuns.status.cancelled': 'Cancelled',
// ── Workflows list page + nav tab (B5a) — the `flows::` domain's
// discoverable hub at /flows. Distinct from the legacy SKILL.md
@@ -4369,6 +4374,17 @@ const en: TranslationMap = {
'flows.list.view': 'View workflow',
'flows.list.export': 'Export',
'flows.list.exported': 'Workflow exported',
+ 'flows.list.duplicate': 'Duplicate',
+ 'flows.list.duplicated': 'Workflow duplicated',
+ 'flows.list.delete': 'Delete',
+ 'flows.list.deleted': 'Workflow deleted',
+ 'flows.list.moreActions': 'More actions',
+ 'flows.delete.title': 'Delete workflow?',
+ 'flows.delete.body':
+ '"{name}" and its run history will be permanently removed. This cannot be undone.',
+ 'flows.delete.cancel': 'Cancel',
+ 'flows.delete.confirm': 'Delete',
+ 'flows.delete.deleting': 'Deleting…',
'flows.page.import': 'Import',
'flows.import.invalidFile': 'That file is not valid workflow JSON.',
'flows.import.error': 'Could not import this workflow. Check the file and try again.',
@@ -4378,6 +4394,8 @@ const en: TranslationMap = {
'flows.runs.loading': 'Loading runs…',
'flows.runs.loadError': 'Could not load runs',
'flows.runs.empty': 'No runs yet',
+ 'flows.runs.sidebarTitle': 'Runs',
+ 'flows.runs.refresh': 'Refresh runs',
// ── Phase 5c: prompt-first authoring + canvas copilot ────────────────────
// The Flows prompt bar (describe a workflow → builder agent proposes it) and
@@ -4423,7 +4441,7 @@ const en: TranslationMap = {
'flows.copilot.removed': '{count} removed',
'flows.copilot.noChanges': 'No node changes in this proposal.',
'flows.copilot.accept': 'Apply to draft',
- 'flows.copilot.reject': 'Discard',
+ 'flows.copilot.reject': 'Dismiss',
'flows.copilot.previewHint': 'Reviewing a proposed draft — nothing is saved yet.',
'flows.copilot.repairDisplay': 'A run failed — please look at it and propose a fix.',
@@ -4436,6 +4454,7 @@ const en: TranslationMap = {
'flows.canvas.notFound': 'This workflow could not be found.',
'flows.canvas.draftMissing': 'No workflow draft to open. Propose one from chat first.',
'flows.canvas.backToList': 'Back to workflows',
+ 'flows.canvas.renameLabel': 'Rename workflow',
'flows.nodeKind.trigger': 'Trigger',
'flows.nodeKind.agent': 'Agent',
'flows.nodeKind.tool_call': 'Tool call',
@@ -4453,12 +4472,25 @@ const en: TranslationMap = {
// and editor toolbar layered on top of the read-only canvas above.
'flows.palette.title': 'Nodes',
'flows.palette.addNode': 'Add {kind} node',
+ 'flows.palette.search': 'Search nodes…',
+ 'flows.palette.noResults': 'No matching nodes',
+ 'flows.palette.group.triggers': 'Triggers',
+ 'flows.palette.group.actions': 'Actions',
+ 'flows.palette.group.logic': 'Logic',
+ 'flows.palette.appAction': 'App action',
+ 'flows.palette.ohTool': 'Tool',
'flows.editor.save': 'Save',
'flows.editor.saving': 'Saving…',
'flows.editor.run': 'Run',
'flows.editor.running': 'Running…',
'flows.editor.runFailed': 'Could not start run',
'flows.editor.deleteSelected': 'Delete selected',
+ 'flows.editor.deleteNode': 'Delete',
+ 'flows.editor.undo': 'Undo',
+ 'flows.editor.redo': 'Redo',
+ 'flows.editor.onboardingTitle': 'Build your workflow',
+ 'flows.editor.onboardingBody':
+ 'Add a node from the palette on the left, then drag between the dots on each card to connect them.',
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
'flows.editor.validate': 'Validate',
'flows.editor.validating': 'Validating…',
@@ -4475,6 +4507,11 @@ const en: TranslationMap = {
'flows.editor.leaveDiscard': 'Leave',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flows.nodeConfig.close': 'Close settings',
+ 'flows.nodeConfig.connections.title': 'Connections',
+ 'flows.nodeConfig.connections.inputs': 'Inputs',
+ 'flows.nodeConfig.connections.outputs': 'Outputs',
+ 'flows.nodeConfig.connections.none': 'Not connected to any other node yet.',
+ 'flows.nodeConfig.connections.remove': 'Remove connection',
'flows.nodeConfig.nameLabel': 'Name',
'flows.nodeConfig.namePlaceholder': 'Node name',
'flows.nodeConfig.editForm': 'Edit as form',
@@ -4498,10 +4535,33 @@ const en: TranslationMap = {
'flows.nodeConfig.trigger.kind_schedule': 'Schedule',
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
'flows.nodeConfig.trigger.kind_app_event': 'App event',
- 'flows.nodeConfig.trigger.scheduleLabel': 'Cron schedule',
+ 'flows.nodeConfig.trigger.scheduleLabel': 'Schedule',
'flows.nodeConfig.trigger.scheduleHint': 'Cron expression: minute hour day month weekday.',
- 'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
- 'flows.nodeConfig.trigger.triggerSlugLabel': 'Trigger slug',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron expression',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequency',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Every N minutes',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Every N hours',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'At a time each day',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'every',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Interval',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'hr',
+ 'flows.nodeConfig.trigger.scheduleAt': 'at',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Time of day',
+ 'flows.nodeConfig.trigger.scheduleDays': 'On days (optional — leave empty for every day)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Advanced (edit cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Back to simple schedule',
+ 'flows.nodeConfig.trigger.toolkitLabel': 'App',
+ 'flows.nodeConfig.trigger.triggerSlugLabel': 'Trigger',
+ 'flows.nodeConfig.trigger.pickApp': 'Pick a connected app first.',
+ 'flows.nodeConfig.tool.pickConnection': 'Pick a connection first.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'No connected apps yet. Connect one in Settings → Apps, then pick it here.',
+ 'flows.nodeConfig.composio.selectApp': 'Select an app…',
+ 'flows.nodeConfig.composio.select': 'Select…',
+ 'flows.nodeConfig.composio.loading': 'Loading…',
+ 'flows.nodeConfig.composio.custom': 'Enter manually…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.trigger.webhookHint':
'Webhook triggers are saved but not dispatched automatically yet.',
'flows.nodeConfig.http.methodLabel': 'Method',
@@ -4511,8 +4571,26 @@ const en: TranslationMap = {
'flows.nodeConfig.agent.promptLabel': 'Prompt',
'flows.nodeConfig.agent.promptPlaceholder': 'Instructions for the agent…',
'flows.nodeConfig.agent.modelLabel': 'Model',
- 'flows.nodeConfig.tool.slugLabel': 'Tool slug',
+ 'flows.nodeConfig.agent.modelHint': 'Pick a capability tier — the workspace resolves the model.',
+ 'flows.nodeConfig.agent.modelInherit': 'Default (inherit)',
+ 'flows.nodeConfig.agent.modelHints': 'Model hints',
+ 'flows.nodeConfig.agent.modelCustom': 'Custom model…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'e.g. gpt-4o-mini',
+ 'flows.nodeConfig.tool.slugLabel': 'Action',
'flows.nodeConfig.tool.argsLabel': 'Arguments (JSON)',
+ // Required-arg preflight rows for Composio actions (per-arg ExpressionField
+ // rows above the raw JSON editor, which stays as the advanced escape hatch).
+ 'flows.nodeConfig.tool.requiredMark': 'required',
+ 'flows.nodeConfig.tool.requiredMissing': 'Required — not wired',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'All args (advanced)',
+ // Upstream-output picker (`=nodes..item…` insert menu on expression fields).
+ 'flows.nodeConfig.upstream.insert': 'Insert…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Insert a value from a previous step',
+ 'flows.nodeConfig.native.toolLabel': 'Tool',
+ 'flows.nodeConfig.native.toolHint':
+ "One of the assistant's built-in tools (search, media, files, …).",
+ 'flows.nodeConfig.native.select': 'Select a tool…',
+ 'flows.nodeConfig.native.loading': 'Loading tools…',
'flows.nodeConfig.condition.fieldLabel': 'Field',
'flows.nodeConfig.condition.fieldHint':
'Key on the input item to test for truthiness. Routes to true or false.',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index e29b92bde..1f2cf38b4 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -3760,6 +3760,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completado',
'flowRuns.status.pending_approval': 'Esperando aprobación',
'flowRuns.status.failed': 'Fallido',
+ 'flowRuns.status.cancelled': 'Cancelado',
'flows.page.title': 'Flujos de trabajo',
'flows.page.description':
@@ -3882,6 +3883,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Quedarme',
'flows.editor.leaveDiscard': 'Salir',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Advertencias de expresión',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'se resolvió como null',
+ 'flows.runs.sidebarTitle': 'Ejecuciones',
+ 'flows.runs.refresh': 'Actualizar ejecuciones',
+ 'flows.palette.appAction': 'Acción de app',
+ 'flows.palette.ohTool': 'Herramienta',
+ 'flows.editor.deleteNode': 'Eliminar',
+ 'flows.nodeConfig.connections.title': 'Conexiones',
+ 'flows.nodeConfig.connections.inputs': 'Entradas',
+ 'flows.nodeConfig.connections.outputs': 'Salidas',
+ 'flows.nodeConfig.connections.none': 'Aún no está conectado a ningún otro nodo.',
+ 'flows.nodeConfig.connections.remove': 'Quitar conexión',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Expresión cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frecuencia',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Cada N minutos',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Cada N horas',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'A una hora cada día',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'cada',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Intervalo',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
+ 'flows.nodeConfig.trigger.scheduleAt': 'a las',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Hora del día',
+ 'flows.nodeConfig.trigger.scheduleDays': 'En días (opcional — deja vacío para todos los días)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Avanzado (editar cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Volver al horario simple',
+ 'flows.nodeConfig.trigger.pickApp': 'Primero elige una app conectada.',
+ 'flows.nodeConfig.tool.pickConnection': 'Primero elige una conexión.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Aún no hay apps conectadas. Conecta una en Ajustes → Apps y luego selecciónala aquí.',
+ 'flows.nodeConfig.composio.selectApp': 'Selecciona una app…',
+ 'flows.nodeConfig.composio.select': 'Seleccionar…',
+ 'flows.nodeConfig.composio.loading': 'Cargando…',
+ 'flows.nodeConfig.composio.custom': 'Introducir manualmente…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Elige un nivel de capacidad; el espacio de trabajo resuelve el modelo.',
+ 'flows.nodeConfig.agent.modelInherit': 'Predeterminado (heredar)',
+ 'flows.nodeConfig.agent.modelHints': 'Sugerencias de modelo',
+ 'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ej., gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'obligatorio',
+ 'flows.nodeConfig.tool.requiredMissing': 'Obligatorio — no conectado',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Todos los argumentos (avanzado)',
+ 'flows.nodeConfig.upstream.insert': 'Insertar…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Insertar un valor de un paso anterior',
+ 'flows.nodeConfig.native.toolLabel': 'Herramienta',
+ 'flows.nodeConfig.native.toolHint':
+ 'Una de las herramientas integradas del asistente (búsqueda, medios, archivos, …).',
+ 'flows.nodeConfig.native.select': 'Selecciona una herramienta…',
+ 'flows.nodeConfig.native.loading': 'Cargando herramientas…',
'flows.nodeConfig.close': 'Cerrar ajustes',
'flows.nodeConfig.nameLabel': 'Nombre',
'flows.nodeConfig.namePlaceholder': 'Nombre del nodo',
@@ -6944,6 +6996,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copiar',
'codeBlock.copied': '¡Copiado!',
+ 'flows.editor.undo': 'Deshacer',
+ 'flows.editor.redo': 'Rehacer',
+ 'flows.editor.onboardingTitle': 'Crea tu flujo de trabajo',
+ 'flows.editor.onboardingBody':
+ 'Añade un nodo desde la paleta de la izquierda y luego arrastra entre los puntos de cada tarjeta para conectarlos.',
+ 'flows.palette.search': 'Buscar nodos…',
+ 'flows.palette.noResults': 'No hay nodos coincidentes',
+ 'flows.palette.group.triggers': 'Disparadores',
+ 'flows.palette.group.actions': 'Acciones',
+ 'flows.palette.group.logic': 'Lógica',
+ 'flows.list.duplicate': 'Duplicar',
+ 'flows.list.duplicated': 'Flujo de trabajo duplicado',
+ 'flows.list.delete': 'Eliminar',
+ 'flows.list.deleted': 'Flujo de trabajo eliminado',
+ 'flows.list.moreActions': 'Más acciones',
+ 'flows.delete.title': '¿Eliminar el flujo de trabajo?',
+ 'flows.delete.body':
+ '"{name}" y su historial de ejecuciones se eliminarán de forma permanente. Esta acción no se puede deshacer.',
+ 'flows.delete.cancel': 'Cancelar',
+ 'flows.delete.confirm': 'Eliminar',
+ 'flows.delete.deleting': 'Eliminando…',
+ 'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo',
};
export default messages;
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 44bdc61fa..06267d2fb 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -3775,6 +3775,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Terminé',
'flowRuns.status.pending_approval': "En attente d'approbation",
'flowRuns.status.failed': 'Échoué',
+ 'flowRuns.status.cancelled': 'Annulé',
'flows.page.title': 'Workflows',
'flows.page.description':
@@ -3817,9 +3818,9 @@ const messages: TranslationMap = {
'Des automatisations que le Flow Scout juge utiles, selon votre façon de travailler.',
'flows.suggest.discover': 'Découvrir',
'flows.suggest.rediscover': 'Actualiser',
- 'flows.suggest.discovering': 'Recherche d\'automatisations…',
+ 'flows.suggest.discovering': "Recherche d'automatisations…",
'flows.suggest.empty':
- 'Aucune suggestion pour l\'instant. Lancez la découverte et le Flow Scout parcourra votre travail pour trouver des automatisations à mettre en place.',
+ "Aucune suggestion pour l'instant. Lancez la découverte et le Flow Scout parcourra votre travail pour trouver des automatisations à mettre en place.",
'flows.suggest.error': 'Impossible de lancer la découverte. Veuillez réessayer.',
'flows.suggest.why': 'Pourquoi',
'flows.suggest.build': 'Créer ceci',
@@ -3898,6 +3899,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Rester',
'flows.editor.leaveDiscard': 'Quitter',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': "Avertissements d'expression",
+ 'flowRuns.inspector.diagnosticResolvedNull': 'résolu en null',
+ 'flows.runs.sidebarTitle': 'Exécutions',
+ 'flows.runs.refresh': 'Actualiser les exécutions',
+ 'flows.palette.appAction': "Action d'app",
+ 'flows.palette.ohTool': 'Outil',
+ 'flows.editor.deleteNode': 'Supprimer',
+ 'flows.nodeConfig.connections.title': 'Connexions',
+ 'flows.nodeConfig.connections.inputs': 'Entrées',
+ 'flows.nodeConfig.connections.outputs': 'Sorties',
+ 'flows.nodeConfig.connections.none': 'Pas encore connecté à un autre nœud.',
+ 'flows.nodeConfig.connections.remove': 'Supprimer la connexion',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Expression cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Fréquence',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Toutes les N minutes',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Toutes les N heures',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'À une heure chaque jour',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'tous les',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Intervalle',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
+ 'flows.nodeConfig.trigger.scheduleAt': 'à',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Heure de la journée',
+ 'flows.nodeConfig.trigger.scheduleDays': 'Les jours (facultatif — laisser vide pour chaque jour)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Avancé (modifier le cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Revenir au planning simple',
+ 'flows.nodeConfig.trigger.pickApp': "Choisissez d'abord une app connectée.",
+ 'flows.nodeConfig.tool.pickConnection': "Choisissez d'abord une connexion.",
+ 'flows.nodeConfig.composio.noConnections':
+ "Aucune app connectée pour l'instant. Connectez-en une dans Réglages → Apps, puis choisissez-la ici.",
+ 'flows.nodeConfig.composio.selectApp': 'Sélectionner une app…',
+ 'flows.nodeConfig.composio.select': 'Sélectionner…',
+ 'flows.nodeConfig.composio.loading': 'Chargement…',
+ 'flows.nodeConfig.composio.custom': 'Saisir manuellement…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Choisissez un niveau de capacité — l’espace de travail résout le modèle.',
+ 'flows.nodeConfig.agent.modelInherit': 'Par défaut (hériter)',
+ 'flows.nodeConfig.agent.modelHints': 'Indications de modèle',
+ 'flows.nodeConfig.agent.modelCustom': 'Modèle personnalisé…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ex. gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'obligatoire',
+ 'flows.nodeConfig.tool.requiredMissing': 'Obligatoire — non câblé',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Tous les arguments (avancé)',
+ 'flows.nodeConfig.upstream.insert': 'Insérer…',
+ 'flows.nodeConfig.upstream.insertLabel': "Insérer une valeur d'une étape précédente",
+ 'flows.nodeConfig.native.toolLabel': 'Outil',
+ 'flows.nodeConfig.native.toolHint':
+ 'Un des outils intégrés de l’assistant (recherche, médias, fichiers, …).',
+ 'flows.nodeConfig.native.select': 'Sélectionner un outil…',
+ 'flows.nodeConfig.native.loading': 'Chargement des outils…',
'flows.nodeConfig.close': 'Fermer les réglages',
'flows.nodeConfig.nameLabel': 'Nom',
'flows.nodeConfig.namePlaceholder': 'Nom du nœud',
@@ -6968,6 +7020,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copier',
'codeBlock.copied': 'Copié !',
+ 'flows.editor.undo': 'Annuler',
+ 'flows.editor.redo': 'Rétablir',
+ 'flows.editor.onboardingTitle': 'Créez votre workflow',
+ 'flows.editor.onboardingBody':
+ 'Ajoutez un nœud depuis la palette à gauche, puis faites glisser entre les points de chaque carte pour les connecter.',
+ 'flows.palette.search': 'Rechercher des nœuds…',
+ 'flows.palette.noResults': 'Aucun nœud correspondant',
+ 'flows.palette.group.triggers': 'Déclencheurs',
+ 'flows.palette.group.actions': 'Actions',
+ 'flows.palette.group.logic': 'Logique',
+ 'flows.list.duplicate': 'Dupliquer',
+ 'flows.list.duplicated': 'Workflow dupliqué',
+ 'flows.list.delete': 'Supprimer',
+ 'flows.list.deleted': 'Workflow supprimé',
+ 'flows.list.moreActions': "Plus d'actions",
+ 'flows.delete.title': 'Supprimer le workflow ?',
+ 'flows.delete.body':
+ '"{name}" et son historique d\'exécution seront définitivement supprimés. Cette action est irréversible.',
+ 'flows.delete.cancel': 'Annuler',
+ 'flows.delete.confirm': 'Supprimer',
+ 'flows.delete.deleting': 'Suppression…',
+ 'flows.canvas.renameLabel': 'Renommer le workflow',
};
export default messages;
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index f2570fdeb..50bf0ddbb 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -3698,6 +3698,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'पूर्ण',
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
'flowRuns.status.failed': 'विफल',
+ 'flowRuns.status.cancelled': 'रद्द किया गया',
'flows.page.title': 'वर्कफ़्लो',
'flows.page.description': 'सहेजे गए ऑटोमेशन जिन्हें आप सक्षम, चला और मॉनिटर कर सकते हैं।',
@@ -3818,6 +3819,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'रुकें',
'flows.editor.leaveDiscard': 'छोड़ें',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'अभिव्यक्ति चेतावनियाँ',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'null में बदला',
+ 'flows.runs.sidebarTitle': 'रन',
+ 'flows.runs.refresh': 'रन रीफ़्रेश करें',
+ 'flows.palette.appAction': 'ऐप क्रिया',
+ 'flows.palette.ohTool': 'टूल',
+ 'flows.editor.deleteNode': 'हटाएँ',
+ 'flows.nodeConfig.connections.title': 'कनेक्शन',
+ 'flows.nodeConfig.connections.inputs': 'इनपुट',
+ 'flows.nodeConfig.connections.outputs': 'आउटपुट',
+ 'flows.nodeConfig.connections.none': 'अभी किसी दूसरे नोड से जुड़ा नहीं है।',
+ 'flows.nodeConfig.connections.remove': 'कनेक्शन हटाएँ',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron अभिव्यक्ति',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'आवृत्ति',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'हर N मिनट',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'हर N घंटे',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'हर दिन एक समय पर',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'हर',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'अंतराल',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'मिनट',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'घंटे',
+ 'flows.nodeConfig.trigger.scheduleAt': 'पर',
+ 'flows.nodeConfig.trigger.scheduleTime': 'दिन का समय',
+ 'flows.nodeConfig.trigger.scheduleDays': 'दिनों पर (वैकल्पिक — हर दिन के लिए खाली छोड़ें)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'उन्नत (cron संपादित करें)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'सरल शेड्यूल पर वापस',
+ 'flows.nodeConfig.trigger.pickApp': 'पहले कोई जुड़ा हुआ ऐप चुनें।',
+ 'flows.nodeConfig.tool.pickConnection': 'पहले कोई कनेक्शन चुनें।',
+ 'flows.nodeConfig.composio.noConnections':
+ 'अभी कोई ऐप जुड़ा नहीं है। Settings → Apps में एक जोड़ें, फिर यहाँ चुनें।',
+ 'flows.nodeConfig.composio.selectApp': 'ऐप चुनें…',
+ 'flows.nodeConfig.composio.select': 'चुनें…',
+ 'flows.nodeConfig.composio.loading': 'लोड हो रहा है…',
+ 'flows.nodeConfig.composio.custom': 'मैन्युअल दर्ज करें…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': 'क्षमता स्तर चुनें — वर्कस्पेस मॉडल तय करेगा।',
+ 'flows.nodeConfig.agent.modelInherit': 'डिफ़ॉल्ट (विरासत में)',
+ 'flows.nodeConfig.agent.modelHints': 'मॉडल संकेत',
+ 'flows.nodeConfig.agent.modelCustom': 'कस्टम मॉडल…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'जैसे gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'आवश्यक',
+ 'flows.nodeConfig.tool.requiredMissing': 'आवश्यक — वायर नहीं किया गया',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'सभी आर्ग्युमेंट (उन्नत)',
+ 'flows.nodeConfig.upstream.insert': 'डालें…',
+ 'flows.nodeConfig.upstream.insertLabel': 'पिछले चरण से मान डालें',
+ 'flows.nodeConfig.native.toolLabel': 'टूल',
+ 'flows.nodeConfig.native.toolHint': 'सहायक के बिल्ट-इन टूल में से एक (खोज, मीडिया, फ़ाइलें, …)।',
+ 'flows.nodeConfig.native.select': 'टूल चुनें…',
+ 'flows.nodeConfig.native.loading': 'टूल लोड हो रहे हैं…',
'flows.nodeConfig.close': 'सेटिंग बंद करें',
'flows.nodeConfig.nameLabel': 'नाम',
'flows.nodeConfig.namePlaceholder': 'नोड नाम',
@@ -6807,6 +6857,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'कॉपी करें',
'codeBlock.copied': 'कॉपी हो गया!',
+ 'flows.editor.undo': 'पूर्ववत करें',
+ 'flows.editor.redo': 'फिर से करें',
+ 'flows.editor.onboardingTitle': 'अपना वर्कफ़्लो बनाएं',
+ 'flows.editor.onboardingBody':
+ 'बाईं ओर के पैलेट से एक नोड जोड़ें, फिर उन्हें जोड़ने के लिए प्रत्येक कार्ड पर बिंदुओं के बीच खींचें।',
+ 'flows.palette.search': 'नोड खोजें…',
+ 'flows.palette.noResults': 'कोई मिलता-जुलता नोड नहीं',
+ 'flows.palette.group.triggers': 'ट्रिगर',
+ 'flows.palette.group.actions': 'क्रियाएं',
+ 'flows.palette.group.logic': 'लॉजिक',
+ 'flows.list.duplicate': 'डुप्लिकेट करें',
+ 'flows.list.duplicated': 'वर्कफ़्लो डुप्लिकेट किया गया',
+ 'flows.list.delete': 'हटाएं',
+ 'flows.list.deleted': 'वर्कफ़्लो हटाया गया',
+ 'flows.list.moreActions': 'और क्रियाएं',
+ 'flows.delete.title': 'वर्कफ़्लो हटाएं?',
+ 'flows.delete.body':
+ '"{name}" और इसका रन इतिहास स्थायी रूप से हटा दिया जाएगा। इसे पूर्ववत नहीं किया जा सकता।',
+ 'flows.delete.cancel': 'रद्द करें',
+ 'flows.delete.confirm': 'हटाएं',
+ 'flows.delete.deleting': 'हटाया जा रहा है…',
+ 'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें',
};
export default messages;
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 33d227340..8df270f88 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -3706,6 +3706,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Selesai',
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
'flowRuns.status.failed': 'Gagal',
+ 'flowRuns.status.cancelled': 'Dibatalkan',
'flows.page.title': 'Alur Kerja',
'flows.page.description': 'Otomatisasi tersimpan yang dapat Anda aktifkan, jalankan, dan pantau.',
@@ -3827,6 +3828,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Tetap di sini',
'flows.editor.leaveDiscard': 'Keluar',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Peringatan ekspresi',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'diselesaikan menjadi null',
+ 'flows.runs.sidebarTitle': 'Jalankan',
+ 'flows.runs.refresh': 'Segarkan daftar jalan',
+ 'flows.palette.appAction': 'Aksi aplikasi',
+ 'flows.palette.ohTool': 'Alat',
+ 'flows.editor.deleteNode': 'Hapus',
+ 'flows.nodeConfig.connections.title': 'Koneksi',
+ 'flows.nodeConfig.connections.inputs': 'Masukan',
+ 'flows.nodeConfig.connections.outputs': 'Keluaran',
+ 'flows.nodeConfig.connections.none': 'Belum terhubung ke node lain.',
+ 'flows.nodeConfig.connections.remove': 'Hapus koneksi',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Ekspresi cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frekuensi',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Setiap N menit',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Setiap N jam',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'Pada waktu tertentu setiap hari',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'setiap',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Interval',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'mnt',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'jam',
+ 'flows.nodeConfig.trigger.scheduleAt': 'pada',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Waktu dalam hari',
+ 'flows.nodeConfig.trigger.scheduleDays': 'Pada hari (opsional — kosongkan untuk setiap hari)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Lanjutan (edit cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Kembali ke jadwal sederhana',
+ 'flows.nodeConfig.trigger.pickApp': 'Pilih aplikasi yang terhubung terlebih dahulu.',
+ 'flows.nodeConfig.tool.pickConnection': 'Pilih koneksi terlebih dahulu.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Belum ada aplikasi terhubung. Hubungkan satu di Pengaturan → Apps, lalu pilih di sini.',
+ 'flows.nodeConfig.composio.selectApp': 'Pilih aplikasi…',
+ 'flows.nodeConfig.composio.select': 'Pilih…',
+ 'flows.nodeConfig.composio.loading': 'Memuat…',
+ 'flows.nodeConfig.composio.custom': 'Masukkan manual…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': 'Pilih tingkat kemampuan — workspace akan menentukan model.',
+ 'flows.nodeConfig.agent.modelInherit': 'Default (warisi)',
+ 'flows.nodeConfig.agent.modelHints': 'Petunjuk model',
+ 'flows.nodeConfig.agent.modelCustom': 'Model kustom…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'mis. gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'wajib',
+ 'flows.nodeConfig.tool.requiredMissing': 'Wajib — belum tersambung',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Semua argumen (lanjutan)',
+ 'flows.nodeConfig.upstream.insert': 'Sisipkan…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Sisipkan nilai dari langkah sebelumnya',
+ 'flows.nodeConfig.native.toolLabel': 'Alat',
+ 'flows.nodeConfig.native.toolHint': 'Salah satu alat bawaan asisten (pencarian, media, file, …).',
+ 'flows.nodeConfig.native.select': 'Pilih alat…',
+ 'flows.nodeConfig.native.loading': 'Memuat alat…',
'flows.nodeConfig.close': 'Tutup pengaturan',
'flows.nodeConfig.nameLabel': 'Nama',
'flows.nodeConfig.namePlaceholder': 'Nama node',
@@ -6834,6 +6884,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Salin',
'codeBlock.copied': 'Disalin!',
+ 'flows.editor.undo': 'Urungkan',
+ 'flows.editor.redo': 'Ulangi',
+ 'flows.editor.onboardingTitle': 'Bangun alur kerja Anda',
+ 'flows.editor.onboardingBody':
+ 'Tambahkan node dari palet di sebelah kiri, lalu seret di antara titik-titik pada setiap kartu untuk menghubungkannya.',
+ 'flows.palette.search': 'Cari node…',
+ 'flows.palette.noResults': 'Tidak ada node yang cocok',
+ 'flows.palette.group.triggers': 'Pemicu',
+ 'flows.palette.group.actions': 'Tindakan',
+ 'flows.palette.group.logic': 'Logika',
+ 'flows.list.duplicate': 'Duplikat',
+ 'flows.list.duplicated': 'Alur kerja diduplikasi',
+ 'flows.list.delete': 'Hapus',
+ 'flows.list.deleted': 'Alur kerja dihapus',
+ 'flows.list.moreActions': 'Tindakan lainnya',
+ 'flows.delete.title': 'Hapus alur kerja?',
+ 'flows.delete.body':
+ '"{name}" dan riwayat prosesnya akan dihapus secara permanen. Tindakan ini tidak dapat dibatalkan.',
+ 'flows.delete.cancel': 'Batal',
+ 'flows.delete.confirm': 'Hapus',
+ 'flows.delete.deleting': 'Menghapus…',
+ 'flows.canvas.renameLabel': 'Ganti nama alur kerja',
};
export default messages;
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index b71a40146..73f8a71cc 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -3755,6 +3755,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completato',
'flowRuns.status.pending_approval': 'In attesa di approvazione',
'flowRuns.status.failed': 'Non riuscito',
+ 'flowRuns.status.cancelled': 'Annullato',
'flows.page.title': 'Flussi di lavoro',
'flows.page.description': 'Automazioni salvate che puoi abilitare, eseguire e monitorare.',
@@ -3792,8 +3793,7 @@ const messages: TranslationMap = {
'flows.promptBar.error': 'Impossibile raggiungere il generatore di flussi. Riprova.',
'flows.promptBar.offline': 'Sei offline. Riconnettiti per creare un flusso.',
'flows.suggest.title': 'Consigliati per te',
- 'flows.suggest.subtitle':
- 'Automazioni che il Flow Scout ritiene utili, in base a come lavori.',
+ 'flows.suggest.subtitle': 'Automazioni che il Flow Scout ritiene utili, in base a come lavori.',
'flows.suggest.discover': 'Scopri',
'flows.suggest.rediscover': 'Aggiorna',
'flows.suggest.discovering': 'Ricerca di automazioni…',
@@ -3876,6 +3876,58 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Resta',
'flows.editor.leaveDiscard': 'Esci',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Avvisi espressione',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'risolto in null',
+ 'flows.runs.sidebarTitle': 'Esecuzioni',
+ 'flows.runs.refresh': 'Aggiorna esecuzioni',
+ 'flows.palette.appAction': 'Azione app',
+ 'flows.palette.ohTool': 'Strumento',
+ 'flows.editor.deleteNode': 'Elimina',
+ 'flows.nodeConfig.connections.title': 'Connessioni',
+ 'flows.nodeConfig.connections.inputs': 'Input',
+ 'flows.nodeConfig.connections.outputs': 'Output',
+ 'flows.nodeConfig.connections.none': 'Non ancora connesso ad altri nodi.',
+ 'flows.nodeConfig.connections.remove': 'Rimuovi connessione',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Espressione cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequenza',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Ogni N minuti',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Ogni N ore',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'A un orario ogni giorno',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'ogni',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Intervallo',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
+ 'flows.nodeConfig.trigger.scheduleAt': 'alle',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Ora del giorno',
+ 'flows.nodeConfig.trigger.scheduleDays':
+ 'Nei giorni (facoltativo — lascia vuoto per ogni giorno)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Avanzato (modifica cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Torna alla pianificazione semplice',
+ 'flows.nodeConfig.trigger.pickApp': "Scegli prima un'app connessa.",
+ 'flows.nodeConfig.tool.pickConnection': 'Scegli prima una connessione.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Nessuna app connessa. Collegane una in Impostazioni → Apps, poi sceglila qui.',
+ 'flows.nodeConfig.composio.selectApp': "Seleziona un'app…",
+ 'flows.nodeConfig.composio.select': 'Seleziona…',
+ 'flows.nodeConfig.composio.loading': 'Caricamento…',
+ 'flows.nodeConfig.composio.custom': 'Inserisci manualmente…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Scegli un livello di capacità — lo spazio di lavoro risolve il modello.',
+ 'flows.nodeConfig.agent.modelInherit': 'Predefinito (eredita)',
+ 'flows.nodeConfig.agent.modelHints': 'Suggerimenti modello',
+ 'flows.nodeConfig.agent.modelCustom': 'Modello personalizzato…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'es. gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'obbligatorio',
+ 'flows.nodeConfig.tool.requiredMissing': 'Obbligatorio — non collegato',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Tutti gli argomenti (avanzato)',
+ 'flows.nodeConfig.upstream.insert': 'Inserisci…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Inserisci un valore da un passaggio precedente',
+ 'flows.nodeConfig.native.toolLabel': 'Strumento',
+ 'flows.nodeConfig.native.toolHint':
+ "Uno degli strumenti integrati dell'assistente (ricerca, media, file, …).",
+ 'flows.nodeConfig.native.select': 'Seleziona uno strumento…',
+ 'flows.nodeConfig.native.loading': 'Caricamento strumenti…',
'flows.nodeConfig.close': 'Chiudi impostazioni',
'flows.nodeConfig.nameLabel': 'Nome',
'flows.nodeConfig.namePlaceholder': 'Nome del nodo',
@@ -6932,6 +6984,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copia',
'codeBlock.copied': 'Copiato!',
+ 'flows.editor.undo': 'Annulla',
+ 'flows.editor.redo': 'Ripristina',
+ 'flows.editor.onboardingTitle': 'Crea il tuo flusso di lavoro',
+ 'flows.editor.onboardingBody':
+ 'Aggiungi un nodo dalla palette a sinistra, poi trascina tra i punti di ogni scheda per collegarli.',
+ 'flows.palette.search': 'Cerca nodi…',
+ 'flows.palette.noResults': 'Nessun nodo corrispondente',
+ 'flows.palette.group.triggers': 'Trigger',
+ 'flows.palette.group.actions': 'Azioni',
+ 'flows.palette.group.logic': 'Logica',
+ 'flows.list.duplicate': 'Duplica',
+ 'flows.list.duplicated': 'Flusso di lavoro duplicato',
+ 'flows.list.delete': 'Elimina',
+ 'flows.list.deleted': 'Flusso di lavoro eliminato',
+ 'flows.list.moreActions': 'Altre azioni',
+ 'flows.delete.title': 'Eliminare il flusso di lavoro?',
+ 'flows.delete.body':
+ '"{name}" e la relativa cronologia delle esecuzioni verranno rimossi definitivamente. Questa azione non può essere annullata.',
+ 'flows.delete.cancel': 'Annulla',
+ 'flows.delete.confirm': 'Elimina',
+ 'flows.delete.deleting': 'Eliminazione…',
+ 'flows.canvas.renameLabel': 'Rinomina flusso di lavoro',
};
export default messages;
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 057e823f4..8e91a154a 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -3661,6 +3661,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '완료됨',
'flowRuns.status.pending_approval': '승인 대기 중',
'flowRuns.status.failed': '실패',
+ 'flowRuns.status.cancelled': '취소됨',
'flows.page.title': '워크플로',
'flows.page.description': '활성화, 실행, 모니터링할 수 있는 저장된 자동화입니다.',
@@ -3778,6 +3779,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': '머무르기',
'flows.editor.leaveDiscard': '나가기',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': '표현식 경고',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'null로 확인됨',
+ 'flows.runs.sidebarTitle': '실행',
+ 'flows.runs.refresh': '실행 새로고침',
+ 'flows.palette.appAction': '앱 작업',
+ 'flows.palette.ohTool': '도구',
+ 'flows.editor.deleteNode': '삭제',
+ 'flows.nodeConfig.connections.title': '연결',
+ 'flows.nodeConfig.connections.inputs': '입력',
+ 'flows.nodeConfig.connections.outputs': '출력',
+ 'flows.nodeConfig.connections.none': '아직 다른 노드에 연결되지 않았습니다.',
+ 'flows.nodeConfig.connections.remove': '연결 제거',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron 표현식',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': '빈도',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'N분마다',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'N시간마다',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': '매일 지정한 시간에',
+ 'flows.nodeConfig.trigger.scheduleEvery': '매',
+ 'flows.nodeConfig.trigger.scheduleInterval': '간격',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': '분',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': '시간',
+ 'flows.nodeConfig.trigger.scheduleAt': '에',
+ 'flows.nodeConfig.trigger.scheduleTime': '하루 중 시간',
+ 'flows.nodeConfig.trigger.scheduleDays': '요일 (선택 사항 — 매일이면 비워 두세요)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': '고급 (cron 편집)',
+ 'flows.nodeConfig.trigger.scheduleSimple': '간단한 일정으로 돌아가기',
+ 'flows.nodeConfig.trigger.pickApp': '먼저 연결된 앱을 선택하세요.',
+ 'flows.nodeConfig.tool.pickConnection': '먼저 연결을 선택하세요.',
+ 'flows.nodeConfig.composio.noConnections':
+ '아직 연결된 앱이 없습니다. 설정 → Apps에서 연결한 뒤 여기에서 선택하세요.',
+ 'flows.nodeConfig.composio.selectApp': '앱 선택…',
+ 'flows.nodeConfig.composio.select': '선택…',
+ 'flows.nodeConfig.composio.loading': '불러오는 중…',
+ 'flows.nodeConfig.composio.custom': '수동 입력…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': '기능 등급을 선택하세요. 작업 공간이 모델을 결정합니다.',
+ 'flows.nodeConfig.agent.modelInherit': '기본값 (상속)',
+ 'flows.nodeConfig.agent.modelHints': '모델 힌트',
+ 'flows.nodeConfig.agent.modelCustom': '사용자 지정 모델…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': '예: gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': '필수',
+ 'flows.nodeConfig.tool.requiredMissing': '필수 — 연결되지 않음',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': '모든 인수 (고급)',
+ 'flows.nodeConfig.upstream.insert': '삽입…',
+ 'flows.nodeConfig.upstream.insertLabel': '이전 단계의 값 삽입',
+ 'flows.nodeConfig.native.toolLabel': '도구',
+ 'flows.nodeConfig.native.toolHint': '도우미의 내장 도구 중 하나입니다(검색, 미디어, 파일 등).',
+ 'flows.nodeConfig.native.select': '도구 선택…',
+ 'flows.nodeConfig.native.loading': '도구 불러오는 중…',
'flows.nodeConfig.close': '설정 닫기',
'flows.nodeConfig.nameLabel': '이름',
'flows.nodeConfig.namePlaceholder': '노드 이름',
@@ -6732,6 +6782,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': '복사',
'codeBlock.copied': '복사됨!',
+ 'flows.editor.undo': '실행 취소',
+ 'flows.editor.redo': '다시 실행',
+ 'flows.editor.onboardingTitle': '워크플로 만들기',
+ 'flows.editor.onboardingBody':
+ '왼쪽 팔레트에서 노드를 추가한 다음 각 카드의 점 사이를 드래그하여 연결하세요.',
+ 'flows.palette.search': '노드 검색…',
+ 'flows.palette.noResults': '일치하는 노드가 없습니다',
+ 'flows.palette.group.triggers': '트리거',
+ 'flows.palette.group.actions': '작업',
+ 'flows.palette.group.logic': '로직',
+ 'flows.list.duplicate': '복제',
+ 'flows.list.duplicated': '워크플로를 복제했습니다',
+ 'flows.list.delete': '삭제',
+ 'flows.list.deleted': '워크플로를 삭제했습니다',
+ 'flows.list.moreActions': '추가 작업',
+ 'flows.delete.title': '워크플로를 삭제할까요?',
+ 'flows.delete.body':
+ '"{name}" 및 해당 실행 기록이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.',
+ 'flows.delete.cancel': '취소',
+ 'flows.delete.confirm': '삭제',
+ 'flows.delete.deleting': '삭제 중…',
+ 'flows.canvas.renameLabel': '워크플로 이름 바꾸기',
};
export default messages;
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index f181e91c9..8dcf2c247 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -3741,6 +3741,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Zakończono',
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
'flowRuns.status.failed': 'Niepowodzenie',
+ 'flowRuns.status.cancelled': 'Anulowano',
'flows.page.title': 'Przepływy pracy',
'flows.page.description':
@@ -3865,6 +3866,56 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Zostań',
'flows.editor.leaveDiscard': 'Wyjdź',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Ostrzeżenia wyrażeń',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'rozwiązano jako null',
+ 'flows.runs.sidebarTitle': 'Uruchomienia',
+ 'flows.runs.refresh': 'Odśwież uruchomienia',
+ 'flows.palette.appAction': 'Akcja aplikacji',
+ 'flows.palette.ohTool': 'Narzędzie',
+ 'flows.editor.deleteNode': 'Usuń',
+ 'flows.nodeConfig.connections.title': 'Połączenia',
+ 'flows.nodeConfig.connections.inputs': 'Wejścia',
+ 'flows.nodeConfig.connections.outputs': 'Wyjścia',
+ 'flows.nodeConfig.connections.none': 'Nie połączono jeszcze z żadnym innym węzłem.',
+ 'flows.nodeConfig.connections.remove': 'Usuń połączenie',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Wyrażenie cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Częstotliwość',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Co N minut',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Co N godzin',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'O wybranej porze każdego dnia',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'co',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Interwał',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'godz.',
+ 'flows.nodeConfig.trigger.scheduleAt': 'o',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Pora dnia',
+ 'flows.nodeConfig.trigger.scheduleDays': 'W dni (opcjonalnie — pozostaw puste dla każdego dnia)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Zaawansowane (edytuj cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Wróć do prostego harmonogramu',
+ 'flows.nodeConfig.trigger.pickApp': 'Najpierw wybierz połączoną aplikację.',
+ 'flows.nodeConfig.tool.pickConnection': 'Najpierw wybierz połączenie.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Brak połączonych aplikacji. Połącz jedną w Ustawienia → Apps, a potem wybierz ją tutaj.',
+ 'flows.nodeConfig.composio.selectApp': 'Wybierz aplikację…',
+ 'flows.nodeConfig.composio.select': 'Wybierz…',
+ 'flows.nodeConfig.composio.loading': 'Ładowanie…',
+ 'flows.nodeConfig.composio.custom': 'Wprowadź ręcznie…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': 'Wybierz poziom możliwości — obszar roboczy rozwiąże model.',
+ 'flows.nodeConfig.agent.modelInherit': 'Domyślnie (dziedzicz)',
+ 'flows.nodeConfig.agent.modelHints': 'Wskazówki modelu',
+ 'flows.nodeConfig.agent.modelCustom': 'Model niestandardowy…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'np. gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'wymagane',
+ 'flows.nodeConfig.tool.requiredMissing': 'Wymagane — niepodłączone',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Wszystkie argumenty (zaawansowane)',
+ 'flows.nodeConfig.upstream.insert': 'Wstaw…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Wstaw wartość z poprzedniego kroku',
+ 'flows.nodeConfig.native.toolLabel': 'Narzędzie',
+ 'flows.nodeConfig.native.toolHint':
+ 'Jedno z wbudowanych narzędzi asystenta (wyszukiwanie, media, pliki, …).',
+ 'flows.nodeConfig.native.select': 'Wybierz narzędzie…',
+ 'flows.nodeConfig.native.loading': 'Ładowanie narzędzi…',
'flows.nodeConfig.close': 'Zamknij ustawienia',
'flows.nodeConfig.nameLabel': 'Nazwa',
'flows.nodeConfig.namePlaceholder': 'Nazwa węzła',
@@ -6908,6 +6959,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Kopiuj',
'codeBlock.copied': 'Skopiowano!',
+ 'flows.editor.undo': 'Cofnij',
+ 'flows.editor.redo': 'Ponów',
+ 'flows.editor.onboardingTitle': 'Zbuduj swój przepływ pracy',
+ 'flows.editor.onboardingBody':
+ 'Dodaj węzeł z palety po lewej stronie, a następnie przeciągnij między kropkami na każdej karcie, aby je połączyć.',
+ 'flows.palette.search': 'Szukaj węzłów…',
+ 'flows.palette.noResults': 'Brak pasujących węzłów',
+ 'flows.palette.group.triggers': 'Wyzwalacze',
+ 'flows.palette.group.actions': 'Akcje',
+ 'flows.palette.group.logic': 'Logika',
+ 'flows.list.duplicate': 'Duplikuj',
+ 'flows.list.duplicated': 'Zduplikowano przepływ pracy',
+ 'flows.list.delete': 'Usuń',
+ 'flows.list.deleted': 'Usunięto przepływ pracy',
+ 'flows.list.moreActions': 'Więcej akcji',
+ 'flows.delete.title': 'Usunąć przepływ pracy?',
+ 'flows.delete.body':
+ '„{name}” oraz jego historia uruchomień zostaną trwale usunięte. Tej operacji nie można cofnąć.',
+ 'flows.delete.cancel': 'Anuluj',
+ 'flows.delete.confirm': 'Usuń',
+ 'flows.delete.deleting': 'Usuwanie…',
+ 'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy',
};
export default messages;
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index c28548ab9..0c26ea5a9 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -3756,6 +3756,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Concluído',
'flowRuns.status.pending_approval': 'Aguardando aprovação',
'flowRuns.status.failed': 'Falhou',
+ 'flowRuns.status.cancelled': 'Cancelado',
'flows.page.title': 'Fluxos de trabalho',
'flows.page.description': 'Automações salvas que você pode habilitar, executar e monitorar.',
@@ -3877,6 +3878,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Ficar',
'flows.editor.leaveDiscard': 'Sair',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Avisos de expressão',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'resolvido como null',
+ 'flows.runs.sidebarTitle': 'Execuções',
+ 'flows.runs.refresh': 'Atualizar execuções',
+ 'flows.palette.appAction': 'Ação de app',
+ 'flows.palette.ohTool': 'Ferramenta',
+ 'flows.editor.deleteNode': 'Excluir',
+ 'flows.nodeConfig.connections.title': 'Conexões',
+ 'flows.nodeConfig.connections.inputs': 'Entradas',
+ 'flows.nodeConfig.connections.outputs': 'Saídas',
+ 'flows.nodeConfig.connections.none': 'Ainda não conectado a nenhum outro nó.',
+ 'flows.nodeConfig.connections.remove': 'Remover conexão',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Expressão cron',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequência',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'A cada N minutos',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'A cada N horas',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'Em um horário todos os dias',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'a cada',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Intervalo',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
+ 'flows.nodeConfig.trigger.scheduleAt': 'às',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Hora do dia',
+ 'flows.nodeConfig.trigger.scheduleDays': 'Nos dias (opcional — deixe vazio para todos os dias)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Avançado (editar cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Voltar ao agendamento simples',
+ 'flows.nodeConfig.trigger.pickApp': 'Escolha primeiro um app conectado.',
+ 'flows.nodeConfig.tool.pickConnection': 'Escolha primeiro uma conexão.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Ainda não há apps conectados. Conecte um em Configurações → Apps e escolha aqui.',
+ 'flows.nodeConfig.composio.selectApp': 'Selecionar um app…',
+ 'flows.nodeConfig.composio.select': 'Selecionar…',
+ 'flows.nodeConfig.composio.loading': 'Carregando…',
+ 'flows.nodeConfig.composio.custom': 'Inserir manualmente…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Escolha um nível de capacidade — o workspace resolve o modelo.',
+ 'flows.nodeConfig.agent.modelInherit': 'Padrão (herdar)',
+ 'flows.nodeConfig.agent.modelHints': 'Dicas de modelo',
+ 'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'ex.: gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'obrigatório',
+ 'flows.nodeConfig.tool.requiredMissing': 'Obrigatório — não conectado',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Todos os argumentos (avançado)',
+ 'flows.nodeConfig.upstream.insert': 'Inserir…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Inserir um valor de uma etapa anterior',
+ 'flows.nodeConfig.native.toolLabel': 'Ferramenta',
+ 'flows.nodeConfig.native.toolHint':
+ 'Uma das ferramentas integradas do assistente (busca, mídia, arquivos, …).',
+ 'flows.nodeConfig.native.select': 'Selecionar uma ferramenta…',
+ 'flows.nodeConfig.native.loading': 'Carregando ferramentas…',
'flows.nodeConfig.close': 'Fechar configurações',
'flows.nodeConfig.nameLabel': 'Nome',
'flows.nodeConfig.namePlaceholder': 'Nome do nó',
@@ -6921,6 +6973,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copiar',
'codeBlock.copied': 'Copiado!',
+ 'flows.editor.undo': 'Desfazer',
+ 'flows.editor.redo': 'Refazer',
+ 'flows.editor.onboardingTitle': 'Crie seu fluxo de trabalho',
+ 'flows.editor.onboardingBody':
+ 'Adicione um nó a partir da paleta à esquerda e, em seguida, arraste entre os pontos de cada cartão para conectá-los.',
+ 'flows.palette.search': 'Pesquisar nós…',
+ 'flows.palette.noResults': 'Nenhum nó correspondente',
+ 'flows.palette.group.triggers': 'Gatilhos',
+ 'flows.palette.group.actions': 'Ações',
+ 'flows.palette.group.logic': 'Lógica',
+ 'flows.list.duplicate': 'Duplicar',
+ 'flows.list.duplicated': 'Fluxo de trabalho duplicado',
+ 'flows.list.delete': 'Excluir',
+ 'flows.list.deleted': 'Fluxo de trabalho excluído',
+ 'flows.list.moreActions': 'Mais ações',
+ 'flows.delete.title': 'Excluir fluxo de trabalho?',
+ 'flows.delete.body':
+ '"{name}" e seu histórico de execuções serão removidos permanentemente. Isso não pode ser desfeito.',
+ 'flows.delete.cancel': 'Cancelar',
+ 'flows.delete.confirm': 'Excluir',
+ 'flows.delete.deleting': 'Excluindo…',
+ 'flows.canvas.renameLabel': 'Renomear fluxo de trabalho',
};
export default messages;
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index d6cecc1db..6fa6df353 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -3730,6 +3730,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Завершено',
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
'flowRuns.status.failed': 'Не удалось',
+ 'flowRuns.status.cancelled': 'Отменено',
'flows.page.title': 'Рабочие процессы',
'flows.page.description':
@@ -3854,6 +3855,58 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Остаться',
'flows.editor.leaveDiscard': 'Выйти',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': 'Предупреждения выражений',
+ 'flowRuns.inspector.diagnosticResolvedNull': 'разрешилось в null',
+ 'flows.runs.sidebarTitle': 'Запуски',
+ 'flows.runs.refresh': 'Обновить запуски',
+ 'flows.palette.appAction': 'Действие приложения',
+ 'flows.palette.ohTool': 'Инструмент',
+ 'flows.editor.deleteNode': 'Удалить',
+ 'flows.nodeConfig.connections.title': 'Соединения',
+ 'flows.nodeConfig.connections.inputs': 'Входы',
+ 'flows.nodeConfig.connections.outputs': 'Выходы',
+ 'flows.nodeConfig.connections.none': 'Пока не подключено ни к одному другому узлу.',
+ 'flows.nodeConfig.connections.remove': 'Удалить соединение',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron-выражение',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': 'Частота',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Каждые N минут',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': 'Каждые N часов',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': 'В заданное время каждый день',
+ 'flows.nodeConfig.trigger.scheduleEvery': 'каждые',
+ 'flows.nodeConfig.trigger.scheduleInterval': 'Интервал',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': 'мин',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': 'ч',
+ 'flows.nodeConfig.trigger.scheduleAt': 'в',
+ 'flows.nodeConfig.trigger.scheduleTime': 'Время дня',
+ 'flows.nodeConfig.trigger.scheduleDays':
+ 'По дням (необязательно — оставьте пустым для каждого дня)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': 'Расширенно (редактировать cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': 'Вернуться к простому расписанию',
+ 'flows.nodeConfig.trigger.pickApp': 'Сначала выберите подключенное приложение.',
+ 'flows.nodeConfig.tool.pickConnection': 'Сначала выберите соединение.',
+ 'flows.nodeConfig.composio.noConnections':
+ 'Подключенных приложений пока нет. Подключите одно в Настройки → Apps, затем выберите его здесь.',
+ 'flows.nodeConfig.composio.selectApp': 'Выберите приложение…',
+ 'flows.nodeConfig.composio.select': 'Выбрать…',
+ 'flows.nodeConfig.composio.loading': 'Загрузка…',
+ 'flows.nodeConfig.composio.custom': 'Ввести вручную…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint':
+ 'Выберите уровень возможностей — рабочая область определит модель.',
+ 'flows.nodeConfig.agent.modelInherit': 'По умолчанию (наследовать)',
+ 'flows.nodeConfig.agent.modelHints': 'Подсказки модели',
+ 'flows.nodeConfig.agent.modelCustom': 'Пользовательская модель…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': 'например, gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': 'обязательно',
+ 'flows.nodeConfig.tool.requiredMissing': 'Обязательно — не подключено',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': 'Все аргументы (расширенно)',
+ 'flows.nodeConfig.upstream.insert': 'Вставить…',
+ 'flows.nodeConfig.upstream.insertLabel': 'Вставить значение из предыдущего шага',
+ 'flows.nodeConfig.native.toolLabel': 'Инструмент',
+ 'flows.nodeConfig.native.toolHint':
+ 'Один из встроенных инструментов ассистента (поиск, медиа, файлы, …).',
+ 'flows.nodeConfig.native.select': 'Выберите инструмент…',
+ 'flows.nodeConfig.native.loading': 'Загрузка инструментов…',
'flows.nodeConfig.close': 'Закрыть настройки',
'flows.nodeConfig.nameLabel': 'Имя',
'flows.nodeConfig.namePlaceholder': 'Имя узла',
@@ -6880,6 +6933,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Копировать',
'codeBlock.copied': 'Скопировано!',
+ 'flows.editor.undo': 'Отменить',
+ 'flows.editor.redo': 'Повторить',
+ 'flows.editor.onboardingTitle': 'Создайте свой рабочий процесс',
+ 'flows.editor.onboardingBody':
+ 'Добавьте узел из палитры слева, затем перетащите линию между точками на карточках, чтобы соединить их.',
+ 'flows.palette.search': 'Поиск узлов…',
+ 'flows.palette.noResults': 'Нет подходящих узлов',
+ 'flows.palette.group.triggers': 'Триггеры',
+ 'flows.palette.group.actions': 'Действия',
+ 'flows.palette.group.logic': 'Логика',
+ 'flows.list.duplicate': 'Дублировать',
+ 'flows.list.duplicated': 'Рабочий процесс продублирован',
+ 'flows.list.delete': 'Удалить',
+ 'flows.list.deleted': 'Рабочий процесс удалён',
+ 'flows.list.moreActions': 'Дополнительные действия',
+ 'flows.delete.title': 'Удалить рабочий процесс?',
+ 'flows.delete.body':
+ '«{name}» и история его запусков будут удалены навсегда. Это действие нельзя отменить.',
+ 'flows.delete.cancel': 'Отмена',
+ 'flows.delete.confirm': 'Удалить',
+ 'flows.delete.deleting': 'Удаление…',
+ 'flows.canvas.renameLabel': 'Переименовать рабочий процесс',
};
export default messages;
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index caa851f71..05cb87cca 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -3504,6 +3504,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '已完成',
'flowRuns.status.pending_approval': '等待批准',
'flowRuns.status.failed': '失败',
+ 'flowRuns.status.cancelled': '已取消',
'flows.page.title': '工作流',
'flows.page.description': '已保存的自动化流程,可启用、运行并监控。',
@@ -3618,6 +3619,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': '留下',
'flows.editor.leaveDiscard': '离开',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
+ 'flowRuns.inspector.diagnosticsTitle': '表达式警告',
+ 'flowRuns.inspector.diagnosticResolvedNull': '解析为 null',
+ 'flows.runs.sidebarTitle': '运行记录',
+ 'flows.runs.refresh': '刷新运行记录',
+ 'flows.palette.appAction': '应用操作',
+ 'flows.palette.ohTool': '工具',
+ 'flows.editor.deleteNode': '删除',
+ 'flows.nodeConfig.connections.title': '连接',
+ 'flows.nodeConfig.connections.inputs': '输入',
+ 'flows.nodeConfig.connections.outputs': '输出',
+ 'flows.nodeConfig.connections.none': '尚未连接到任何其他节点。',
+ 'flows.nodeConfig.connections.remove': '移除连接',
+ 'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron 表达式',
+ 'flows.nodeConfig.trigger.scheduleFreqLabel': '频率',
+ 'flows.nodeConfig.trigger.scheduleFreq_minutes': '每 N 分钟',
+ 'flows.nodeConfig.trigger.scheduleFreq_hours': '每 N 小时',
+ 'flows.nodeConfig.trigger.scheduleFreq_daily': '每天指定时间',
+ 'flows.nodeConfig.trigger.scheduleEvery': '每',
+ 'flows.nodeConfig.trigger.scheduleInterval': '间隔',
+ 'flows.nodeConfig.trigger.scheduleUnit_minutes': '分钟',
+ 'flows.nodeConfig.trigger.scheduleUnit_hours': '小时',
+ 'flows.nodeConfig.trigger.scheduleAt': '在',
+ 'flows.nodeConfig.trigger.scheduleTime': '一天中的时间',
+ 'flows.nodeConfig.trigger.scheduleDays': '在这些天(可选,留空表示每天)',
+ 'flows.nodeConfig.trigger.scheduleAdvanced': '高级(编辑 cron)',
+ 'flows.nodeConfig.trigger.scheduleSimple': '返回简单计划',
+ 'flows.nodeConfig.trigger.pickApp': '请先选择一个已连接的应用。',
+ 'flows.nodeConfig.tool.pickConnection': '请先选择一个连接。',
+ 'flows.nodeConfig.composio.noConnections':
+ '还没有已连接的应用。请在“设置 → 应用”中连接一个,然后在这里选择。',
+ 'flows.nodeConfig.composio.selectApp': '选择应用…',
+ 'flows.nodeConfig.composio.select': '选择…',
+ 'flows.nodeConfig.composio.loading': '正在加载…',
+ 'flows.nodeConfig.composio.custom': '手动输入…',
+ 'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
+ 'flows.nodeConfig.agent.modelHint': '选择能力层级,工作区会解析具体模型。',
+ 'flows.nodeConfig.agent.modelInherit': '默认(继承)',
+ 'flows.nodeConfig.agent.modelHints': '模型提示',
+ 'flows.nodeConfig.agent.modelCustom': '自定义模型…',
+ 'flows.nodeConfig.agent.modelCustomPlaceholder': '例如 gpt-4o-mini',
+ 'flows.nodeConfig.tool.requiredMark': '必填',
+ 'flows.nodeConfig.tool.requiredMissing': '必填 — 尚未接线',
+ 'flows.nodeConfig.tool.argsAdvancedLabel': '所有参数(高级)',
+ 'flows.nodeConfig.upstream.insert': '插入…',
+ 'flows.nodeConfig.upstream.insertLabel': '插入上一步的值',
+ 'flows.nodeConfig.native.toolLabel': '工具',
+ 'flows.nodeConfig.native.toolHint': '助手内置工具之一(搜索、媒体、文件等)。',
+ 'flows.nodeConfig.native.select': '选择工具…',
+ 'flows.nodeConfig.native.loading': '正在加载工具…',
'flows.nodeConfig.close': '关闭设置',
'flows.nodeConfig.nameLabel': '名称',
'flows.nodeConfig.namePlaceholder': '节点名称',
@@ -6438,6 +6488,26 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': '复制',
'codeBlock.copied': '已复制!',
+ 'flows.editor.undo': '撤销',
+ 'flows.editor.redo': '重做',
+ 'flows.editor.onboardingTitle': '构建你的工作流',
+ 'flows.editor.onboardingBody': '从左侧面板添加节点,然后在各卡片的圆点之间拖动以连接它们。',
+ 'flows.palette.search': '搜索节点…',
+ 'flows.palette.noResults': '没有匹配的节点',
+ 'flows.palette.group.triggers': '触发器',
+ 'flows.palette.group.actions': '操作',
+ 'flows.palette.group.logic': '逻辑',
+ 'flows.list.duplicate': '复制',
+ 'flows.list.duplicated': '工作流已复制',
+ 'flows.list.delete': '删除',
+ 'flows.list.deleted': '工作流已删除',
+ 'flows.list.moreActions': '更多操作',
+ 'flows.delete.title': '删除工作流?',
+ 'flows.delete.body': '“{name}”及其运行历史将被永久删除。此操作无法撤销。',
+ 'flows.delete.cancel': '取消',
+ 'flows.delete.confirm': '删除',
+ 'flows.delete.deleting': '正在删除…',
+ 'flows.canvas.renameLabel': '重命名工作流',
};
export default messages;
diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx
index 2371cd3f5..199da434e 100644
--- a/app/src/pages/FlowCanvasPage.tsx
+++ b/app/src/pages/FlowCanvasPage.tsx
@@ -22,9 +22,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import FlowCanvas from '../components/flows/canvas/FlowCanvas';
+import FlowRunsSidebar from '../components/flows/FlowRunsSidebar';
import WorkflowCopilotPanel from '../components/flows/WorkflowCopilotPanel';
+import {
+ getCopilotThreadId,
+ setCopilotThreadId as setCopilotThreadIdCache,
+} from '../components/flows/workflowCopilotThreads';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
+import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import Button from '../components/ui/Button';
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
import { asFlowCanvasDraftState } from '../lib/flows/canvasDraft';
@@ -123,11 +129,45 @@ function FlowEditor({
const [running, setRunning] = useState(false);
const [runError, setRunError] = useState(null);
- const { flowId, name, graph, requireApproval } = editorFlow;
+ const { flowId, graph, requireApproval } = editorFlow;
// Draft (unsaved) canvases have no persisted id yet; Save creates the flow
// rather than updating one, and there is nothing runnable to run.
const isDraft = flowId === null;
+ // Editable flow name. `name` is the committed value (used by Save + the run
+ // header); `titleDraft` is the in-progress input buffer. Renaming a persisted
+ // flow is metadata-only (`flows_update({ name })`) — it never touches the
+ // graph, so it can't fire a schedule, and is safe to persist on blur/Enter
+ // without the graph's explicit-Save gate. A draft just updates locally; the
+ // name rides into `flows_create` when the draft is first Saved.
+ const [name, setName] = useState(editorFlow.name);
+ const [titleDraft, setTitleDraft] = useState(editorFlow.name);
+ const [renaming, setRenaming] = useState(false);
+
+ const commitRename = useCallback(async () => {
+ const trimmed = titleDraft.trim();
+ if (!trimmed || trimmed === name) {
+ setTitleDraft(name);
+ return;
+ }
+ if (isDraft) {
+ log('rename (draft): %s', trimmed);
+ setName(trimmed);
+ return;
+ }
+ setRenaming(true);
+ try {
+ log('rename: flow id=%s name=%s', flowId, trimmed);
+ await updateFlow(flowId, { name: trimmed });
+ setName(trimmed);
+ } catch (err) {
+ log('rename failed: id=%s err=%o', flowId, err);
+ setTitleDraft(name);
+ } finally {
+ setRenaming(false);
+ }
+ }, [titleDraft, name, isDraft, flowId]);
+
// ── Canvas copilot + draft overlay (Phase 5c) ─────────────────────────────
// `draftGraph` is the current ACCEPTED draft (starts as the loaded graph),
// kept in sync with manual canvas edits via `onGraphChange`. A copilot
@@ -136,6 +176,19 @@ function FlowEditor({
// commits the proposed graph into `draftGraph`; Reject reverts to the frozen
// base. NOTHING here persists — the canvas's own Save is the only gate.
const [copilotOpen, setCopilotOpen] = useState(initialCopilotSeed !== null);
+ // Per-workflow copilot thread: seeded from the session cache so opening/closing
+ // the panel (or switching flows and back) resumes the same conversation
+ // instead of starting a fresh `workflow_builder` thread each time.
+ const [copilotThreadId, setCopilotThreadId] = useState(() =>
+ getCopilotThreadId(flowId)
+ );
+ const handleCopilotThreadId = useCallback(
+ (id: string | null) => {
+ setCopilotThreadId(id);
+ setCopilotThreadIdCache(flowId, id);
+ },
+ [flowId]
+ );
const [draftGraph, setDraftGraph] = useState(graph);
const [preview, setPreview] = useState<{
proposal: WorkflowProposal;
@@ -346,13 +399,44 @@ function FlowEditor({
);
+ // Editable title: an unstyled input that reads as the page heading until
+ // focused, so renaming is discoverable without a separate edit affordance.
+ const titleNode = (
+ setTitleDraft(e.target.value)}
+ onBlur={() => void commitRename()}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.currentTarget.blur();
+ } else if (e.key === 'Escape') {
+ setTitleDraft(name);
+ e.currentTarget.blur();
+ }
+ }}
+ className="w-full max-w-md truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 text-base font-semibold text-content hover:border-line focus:border-primary-400 focus:outline-none disabled:opacity-60"
+ />
+ );
+
return (
+ {/* Project this flow's run history into the left shell sidebar while it's
+ open (persisted flows only — a draft has no runs yet). */}
+ {!isDraft && flowId && (
+
+
+
+ )}
setCopilotOpen(false)}
repairSeed={copilotRepairSeed}
+ seedThreadId={copilotThreadId}
+ onThreadIdChange={handleCopilotThreadId}
/>
)}
diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx
index b2f0baa40..7bad805ce 100644
--- a/app/src/pages/FlowsPage.test.tsx
+++ b/app/src/pages/FlowsPage.test.tsx
@@ -23,6 +23,8 @@ const runFlow = vi.hoisted(() => vi.fn());
const listFlowRuns = vi.hoisted(() => vi.fn());
const createFlow = vi.hoisted(() => vi.fn());
const importFlow = vi.hoisted(() => vi.fn());
+const deleteFlow = vi.hoisted(() => vi.fn());
+const duplicateFlow = vi.hoisted(() => vi.fn());
// Flow Scout discovery clients — rendered via the SuggestedWorkflows section.
const discoverWorkflows = vi.hoisted(() => vi.fn());
const listSuggestions = vi.hoisted(() => vi.fn());
@@ -35,6 +37,8 @@ vi.mock('../services/api/flowsApi', () => ({
listFlowRuns,
createFlow,
importFlow,
+ deleteFlow,
+ duplicateFlow,
discoverWorkflows,
listSuggestions,
dismissSuggestion,
@@ -198,18 +202,17 @@ describe('FlowsPage', () => {
expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
});
- it('"describe it" in the chooser focuses the in-place prompt bar (no Chat hand-off)', async () => {
+ it('always shows the in-place prompt bar and the chooser no longer duplicates it', async () => {
listFlows.mockResolvedValue([makeFlow()]);
renderWithProviders(
);
- fireEvent.click(await screen.findByTestId('flows-new-workflow'));
- fireEvent.click(screen.getByTestId('new-workflow-describe'));
+ // The prompt bar is the single "describe a workflow" entry point.
+ expect(await screen.findByTestId('workflow-prompt-bar')).toBeInTheDocument();
- // 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();
+ // The chooser modal offers scratch + template only — no redundant describe.
+ fireEvent.click(await screen.findByTestId('flows-new-workflow'));
+ expect(screen.getByTestId('new-workflow-scratch')).toBeInTheDocument();
+ expect(screen.queryByTestId('new-workflow-describe')).not.toBeInTheDocument();
});
it('empty-state template gallery creates a flow and opens its canvas', async () => {
@@ -238,11 +241,39 @@ describe('FlowsPage', () => {
listFlows.mockResolvedValue([makeFlow({ graph: { nodes: [], edges: [] } })]);
renderWithProviders(
);
+ // Export now lives behind the row's "⋯" overflow menu.
+ fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
fireEvent.click(await screen.findByTestId('flow-export-flow-1'));
expect(downloadFlowGraph).toHaveBeenCalledWith('Daily digest', { nodes: [], edges: [] });
});
+ it('deletes a flow via the overflow menu + confirm dialog', async () => {
+ listFlows.mockResolvedValueOnce([makeFlow()]).mockResolvedValueOnce([]);
+ deleteFlow.mockResolvedValue('flow-1');
+ renderWithProviders(
);
+
+ fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
+ fireEvent.click(await screen.findByTestId('flow-delete-flow-1'));
+
+ // Confirm dialog gates the destructive call.
+ expect(deleteFlow).not.toHaveBeenCalled();
+ fireEvent.click(await screen.findByTestId('flow-delete-confirm-button'));
+
+ await waitFor(() => expect(deleteFlow).toHaveBeenCalledWith('flow-1'));
+ });
+
+ it('duplicates a flow via the overflow menu', async () => {
+ listFlows.mockResolvedValue([makeFlow()]);
+ duplicateFlow.mockResolvedValue(makeFlow({ id: 'flow-2', name: 'Daily digest copy' }));
+ renderWithProviders(
);
+
+ fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
+ fireEvent.click(await screen.findByTestId('flow-duplicate-flow-1'));
+
+ await waitFor(() => expect(duplicateFlow).toHaveBeenCalledWith('flow-1'));
+ });
+
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: [] };
diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx
index 9e33d41cd..c4c688a07 100644
--- a/app/src/pages/FlowsPage.tsx
+++ b/app/src/pages/FlowsPage.tsx
@@ -26,12 +26,15 @@ 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 { ModalShell } from '../components/ui/ModalShell';
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 {
+ deleteFlow,
+ duplicateFlow,
type Flow,
importFlow,
listFlows,
@@ -63,12 +66,13 @@ export default function FlowsPage() {
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();
+ // Flow queued for deletion behind the confirm dialog (`null` = closed), plus
+ // an in-flight flag so the confirm button can show progress + block re-entry.
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
const addToast = useCallback((toast: Omit) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
@@ -201,6 +205,43 @@ export default function FlowsPage() {
[addToast, t]
);
+ /** Duplicate a flow (server creates a disabled copy), then refresh the list. */
+ const handleDuplicate = useCallback(
+ async (flow: Flow) => {
+ log('duplicate: id=%s', flow.id);
+ setError(null);
+ try {
+ const copy = await duplicateFlow(flow.id);
+ addToast({ type: 'success', title: t('flows.list.duplicated') });
+ log('duplicate: created id=%s', copy.id);
+ await loadFlows();
+ } catch (err) {
+ log('duplicate failed: id=%s err=%o', flow.id, err);
+ setError(errorMessage(err));
+ }
+ },
+ [addToast, loadFlows, t]
+ );
+
+ /** Confirm-gated delete: the row's Delete opens the dialog; this commits it. */
+ const handleConfirmDelete = useCallback(async () => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ setError(null);
+ log('delete: confirming id=%s', deleteTarget.id);
+ try {
+ await deleteFlow(deleteTarget.id);
+ addToast({ type: 'success', title: t('flows.list.deleted') });
+ setDeleteTarget(null);
+ await loadFlows();
+ } catch (err) {
+ log('delete failed: id=%s err=%o', deleteTarget.id, err);
+ setError(errorMessage(err));
+ } finally {
+ setDeleting(false);
+ }
+ }, [deleteTarget, addToast, loadFlows, 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);
@@ -258,18 +299,6 @@ export default function FlowsPage() {
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) => {
@@ -315,13 +344,9 @@ export default function FlowsPage() {
{/* 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}
- />
+ compact otherwise. Always visible, so it's the single "describe a
+ workflow" entry point (the chooser modal no longer duplicates it). */}
+
{/* Flow Scout discovery: proactive, buildable workflow suggestions
grounded in how the user works. Read-only until they click "Build
@@ -388,6 +413,8 @@ export default function FlowsPage() {
onViewRuns={handleViewRuns}
onView={handleView}
onExport={handleExport}
+ onDuplicate={f => void handleDuplicate(f)}
+ onDelete={setDeleteTarget}
/>
))}
@@ -401,8 +428,37 @@ export default function FlowsPage() {
onFixWithAgent={handleFixWithAgent}
/>
- {chooserOpen && (
- setChooserOpen(false)} onDescribe={handleDescribe} />
+ {chooserOpen && setChooserOpen(false)} />}
+
+ {deleteTarget && (
+ (deleting ? undefined : setDeleteTarget(null))}
+ title={t('flows.delete.title')}
+ subtitle={t('flows.delete.body').replace('{name}', deleteTarget.name)}
+ titleId="flow-delete-modal-title"
+ maxWidthClassName="max-w-sm">
+
+ setDeleteTarget(null)}>
+ {t('flows.delete.cancel')}
+
+ void handleConfirmDelete()}>
+ {deleting ? t('flows.delete.deleting') : t('flows.delete.confirm')}
+
+
+
)}
diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx
index ec48fd70a..da24e189b 100644
--- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx
+++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx
@@ -91,7 +91,21 @@ describe('FlowCanvasPage', () => {
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
expect(getFlow).toHaveBeenCalledWith('test-id');
- expect(screen.getByText('Daily digest')).toBeInTheDocument();
+ expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Daily digest');
+ });
+
+ it('renames a persisted flow via the editable title (metadata-only update)', async () => {
+ getFlow.mockResolvedValue(makeFlow());
+ updateFlow.mockResolvedValue(makeFlow({ name: 'Renamed' }));
+ renderAtFlowId('test-id');
+
+ const title = await screen.findByTestId('flow-canvas-title');
+ fireEvent.change(title, { target: { value: 'Renamed' } });
+ fireEvent.blur(title);
+
+ await waitFor(() => expect(updateFlow).toHaveBeenCalledWith('test-id', { name: 'Renamed' }));
+ // Name-only update — no graph in the payload, so it can't fire a schedule.
+ expect(updateFlow.mock.calls[0][1]).not.toHaveProperty('graph');
});
it('shows a not-found state when the flow does not exist', async () => {
@@ -128,7 +142,7 @@ describe('FlowCanvasPage', () => {
// Navigate away before the old id's fetch resolves.
router.navigate('/flows/new-id');
- await waitFor(() => expect(screen.getByText('New flow')).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New flow'));
// Now let the stale old-id fetch resolve — it must not clobber the
// already-rendered new-id state.
@@ -136,8 +150,8 @@ describe('FlowCanvasPage', () => {
await Promise.resolve();
await Promise.resolve();
- expect(screen.getByText('New flow')).toBeInTheDocument();
- expect(screen.queryByText('Old flow (stale)')).not.toBeInTheDocument();
+ expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New flow');
+ expect(screen.queryByDisplayValue('Old flow (stale)')).not.toBeInTheDocument();
});
function renderEditor(id = 'test-id') {
@@ -231,7 +245,7 @@ describe('FlowCanvasPage', () => {
renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
- expect(screen.getByText('Proposed flow')).toBeInTheDocument();
+ expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Proposed flow');
// A draft is not fetched, is not runnable, and has persisted nothing.
expect(getFlow).not.toHaveBeenCalled();
expect(createFlow).not.toHaveBeenCalled();
diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts
index f677fd0e2..8a38a5bcd 100644
--- a/app/src/services/api/flowsApi.ts
+++ b/app/src/services/api/flowsApi.ts
@@ -55,6 +55,11 @@ export interface FlowRunStep {
output: unknown;
/** Output port the node routed on, if any (branching/switch nodes). Omitted when absent. */
port?: string;
+ /**
+ * Config `=`-expressions that resolved to `null` while running this step
+ * (`location` is the config path, e.g. `args.to`). Empty/absent when clean.
+ */
+ diagnostics?: Array<{ location: string; expression: string }>;
}
/** A persisted flow run record (`src/openhuman/flows/types.rs::FlowRun`). */
@@ -380,6 +385,38 @@ export async function runFlow(id: string, input?: unknown): Promise {
+ log('deleteFlow: request id=%s', id);
+ const response = await callCoreRpc({ method: 'openhuman.flows_delete', params: { id } });
+ const payload = unwrapCliEnvelope<{ id: string; removed: boolean }>(response);
+ log('deleteFlow: response id=%s removed=%s', payload.id, payload.removed);
+ return payload.id;
+}
+
+/**
+ * Duplicate a saved flow via `openhuman.flows_duplicate`. The copy is created
+ * DISABLED and unbound (no live trigger), with a derived name, so duplicating an
+ * enabled flow never silently starts a second live schedule. Returns the new
+ * `Flow` row.
+ */
+export async function duplicateFlow(id: string): Promise {
+ log('duplicateFlow: request id=%s', id);
+ const response = await callCoreRpc({
+ method: 'openhuman.flows_duplicate',
+ params: { id },
+ });
+ const flow = unwrapCliEnvelope(response);
+ log('duplicateFlow: response newId=%s name=%s', flow.id, flow.name);
+ return flow;
+}
+
/**
* Update a saved flow's name and/or graph via `openhuman.flows_update` (the
* Workflow Canvas Save path, B5b.2 / Phase 3d). The server re-validates the
@@ -557,6 +594,8 @@ export const flowsApi = {
setFlowEnabled,
runFlow,
updateFlow,
+ deleteFlow,
+ duplicateFlow,
validateFlow,
listFlowConnections,
};
diff --git a/app/src/services/api/runtimeToolsApi.ts b/app/src/services/api/runtimeToolsApi.ts
new file mode 100644
index 000000000..8f3c0a082
--- /dev/null
+++ b/app/src/services/api/runtimeToolsApi.ts
@@ -0,0 +1,58 @@
+/**
+ * Frontend client for the agent's native tool registry, exposed over
+ * `openhuman.javascript_list_tools` (the same registry the assistant uses). The
+ * flows "Tool" node (native OpenHuman tools, as opposed to the Composio "App
+ * action" node) uses this to offer a dropdown of real tool names + descriptions.
+ */
+import debug from 'debug';
+
+import { callCoreRpc } from '../coreRpcClient';
+
+const log = debug('runtimeToolsApi');
+
+/** One agent-callable tool (`RuntimeToolSummary` on the Rust side). */
+export interface RuntimeTool {
+ name: string;
+ description: string;
+ category: string;
+ permission_level: string;
+ scope: string;
+ supports_markdown: boolean;
+ /** JSON-schema-ish parameters object for the tool's args. */
+ parameters: unknown;
+}
+
+function asRecord(value: unknown): Record | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ return value as Record;
+}
+
+/** Peel the `{ result, logs }` CLI envelope, like `flowsApi` does. */
+function unwrapCliEnvelope(payload: unknown): T {
+ const record = asRecord(payload);
+ if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {
+ return record.result as T;
+ }
+ return payload as T;
+}
+
+/**
+ * List the native agent tools available to a flow's "Tool" node. The payload is
+ * `{ tools: RuntimeTool[] }` (or a bare array on some cores); both are handled.
+ */
+export async function listRuntimeTools(): Promise {
+ log('listRuntimeTools: request');
+ const response = await callCoreRpc({
+ method: 'openhuman.javascript_list_tools',
+ params: {},
+ });
+ const payload = unwrapCliEnvelope(response);
+ const record = asRecord(payload);
+ const tools = Array.isArray(payload)
+ ? (payload as RuntimeTool[])
+ : record && Array.isArray(record.tools)
+ ? (record.tools as RuntimeTool[])
+ : [];
+ log('listRuntimeTools: response count=%d', tools.length);
+ return tools;
+}
diff --git a/docs/plans/tinyflows-node-io-and-agent-kinds.md b/docs/plans/tinyflows-node-io-and-agent-kinds.md
new file mode 100644
index 000000000..513bbe1aa
--- /dev/null
+++ b/docs/plans/tinyflows-node-io-and-agent-kinds.md
@@ -0,0 +1,171 @@
+# tinyflows — Node I/O Alignment & Selectable Agent Kinds (POA)
+
+> **Status**: proposed · **Date**: 2026-07-05
+> **Scope**: (A) fix the node input/output contract so producer/consumer shapes line up (agent ↔ tool*call ↔ merge), and (B) let a workflow pick \_which kind of agent* an `agent` node runs (coding agent, researcher, crypto agent, …) so each brings its own curated tool access.
+> **Companion**: extends `docs/plans/tinyflows-integration/README.md`; audit source `vendor/tinyflows/docs/AUDIT-n8n-gap.md` (§3 bugs, §4b I/O alignment).
+> **Reference product**: [n8n](https://github.com/n8n-io/n8n) — declared per-node output schemas, per-item execution, AI Agent node with pluggable tool/model sub-nodes.
+
+---
+
+## 0. Why this plan
+
+Two problems surfaced in the audit that share a root cause — **nodes have no declared I/O contract**, so what one node emits and what the next reads are only accidentally aligned:
+
+1. **Shape drift.** Every capability node wraps its host capability's raw return verbatim into `Item.json` (`agent.rs:115`, `tool_call.rs:31`, `http_request.rs:24`). The `agent` node emits three different shapes depending on sub-ports, and its plain shape flips between "parsed model JSON" and `{text}` at runtime (`caps.rs:359,369`). Downstream `=item.` expressions therefore guess.
+2. **No agent identity.** The `agent` node is a single bare `provider.chat` call with a free-form inline `tools` list (`caps.rs` `OpenHumanLlm::complete`, "no agent loop is driven here"). There is no way to say "run this step as the **coding** agent" or "as the **researcher**", even though OpenHuman already ships a 35-agent registry where each agent (`researcher`, `code_executor`, `crypto_agent`, …) declares its own toolset, model hint, sandbox, and iteration policy in an `agent.toml`.
+
+Part A fixes the contract; Part B builds selectable agent kinds on top of the existing registry. They are sequenced so A1 (the agent envelope) lands before B (agent kinds emit into that same envelope).
+
+---
+
+## Part A — Node I/O alignment
+
+### A0. Design principle: a normalized item envelope
+
+Adopt a small, stable **output envelope** for capability nodes so every downstream expression has a guaranteed accessor, regardless of provider or config:
+
+```jsonc
+// agent / tool_call / http_request / code emit items shaped:
+{
+ "json": , // parsed/structured result when there is one
+ "text": , // human-readable text when there is one
+ "raw": , // escape hatch: the untouched capability return
+ "error": // present only on continue/route error items
+}
+```
+
+Rules: `=item.text` always resolves (or is explicitly `null`); `=item.json.` is the structured path; `=item.raw` preserves today's behavior for anyone who needs the provider blob. This is additive — `raw` is exactly what nodes emit today — so migration is mechanical.
+
+> Keep the crate **host-agnostic**: the envelope is defined in `vendor/tinyflows` (`src/data.rs` / node executors). The host adapters (`caps.rs`) already produce `{text}` / parsed JSON, so they map onto `json`/`text` directly.
+
+### A1. Normalize the `agent` node output — _highest leverage, do first_
+
+- **Crate** (`vendor/tinyflows/src/nodes/integration/agent.rs:115`): wrap the completion in the envelope instead of `Item::new(value)`. If `output_parser` ran, the coerced value goes in `json`; the completion text (when present) in `text`; the untouched response in `raw`; a model-elected tool result stays under `json.tool_result` **and** mirrors to a stable `tool_result` accessor (see A2).
+- **Host** (`src/openhuman/tinyflows/caps.rs` `OpenHumanLlm::complete`): return `{ json: , text: , raw: }` rather than either the bare parsed object _or_ the `{text}` fallback. Removes the runtime shape-flip (audit M1).
+- **Tests**: update `agent.rs` unit tests + `caps.rs` seam tests; add an e2e asserting `=item.text` resolves on both a JSON-emitting and a prose-emitting model (mock both).
+
+### A2. Unify inline-tool vs `tool_call`-node result shape (audit M2)
+
+Make a tool result reachable at the **same** path whether the tool ran inline in an `agent` node or as a standalone `tool_call` node. Standardize on the envelope: `tool_call` node emits `{ json: , raw: }`; the agent's inline tool result lands at `item.json.tool_result` and the node envelope's `raw` keeps the full completion. Document the one canonical accessor.
+
+### A3. Per-item execution for integration nodes (audit M3 — the silent-drop trap)
+
+Today `agent`/`tool_call`/`http_request` **always emit one item** and bind config against `input.first()` only, so `split_out (N) → tool_call` fires **once** and drops N−1 items.
+
+- Add a node config flag `execution: "once" | "per_item"` (default `per_item` for `tool_call`/`http_request`; default `once` for `agent`, since an agent turn is usually batch-level — but allow `per_item`).
+- In `per_item` mode: map the executor over `ctx.input`, re-resolving config per item (so `=item.x` means _this_ item), emit one output item per input, carry `paired_item` (`vendor/tinyflows/src/data.rs`).
+- Touches `agent.rs`, `tool_call.rs`, `http_request.rs`, and the per-item resolution path in `nodes/mod.rs`.
+- **Tests**: `split_out → tool_call` runs N times; `paired_item` lineage preserved; `once` mode unchanged.
+
+### A4. Port-aware `collect_input` (audit M4 — untaken-branch leak + BUG-3/BUG-4)
+
+`collect_input` (`vendor/tinyflows/src/engine.rs:171`) concatenates items from **every** predecessor slot regardless of `Edge.to_port` (stored, never read) or which port the predecessor emitted on. A node after a `condition` reads the not-taken branch's items.
+
+- Read `Edge.to_port` / the predecessor's recorded `port`; collect only items the predecessor actually emitted on the connecting port.
+- Enables **named merge inputs** (input A vs B) and removes the leak.
+- Fold in the merge-barrier gap (BUG-4) and mixed-port fan-out drop (BUG-3) from the audit — same routing/lowering surface (`engine.rs:422`, `789-826`).
+- **Tests**: condition-false slot not visible to a node wired on the true port; merge fed by a branching predecessor barriers correctly; the `main→a, main→b, error→h` shape runs both `a` and `b`.
+
+### A5. `merge` modes (audit M5)
+
+Add `merge.mode`: `append` (today's concat), `combine_by_key` (join items by a key field), `combine_by_position` (zip). Config-only change in `vendor/tinyflows/src/nodes/control_flow/merge.rs`; barrier semantics unchanged.
+
+### A6. Author-time alignment lint (feeds Part C tooling)
+
+A validation pass that, given a producer node's known/declared output envelope, flags downstream `=item.` references the producer cannot emit — surfaced as structured, node-addressed diagnostics. Depends on tightening `validate.rs` (audit BUG-10). Wire into the agent-facing `validate` / `revise_workflow` tools so the builder agent gets the feedback.
+
+### A-bugs (bundle with the above — from audit §3)
+
+- **BUG-1 (security, hotfix now):** jq `env` builtin leaks host env — disable `jaq-std` default features / filter `env`/`input*` in `vendor/tinyflows/src/expr.rs:302`.
+- **BUG-2:** `switch`/`transform` don't get the `nodes` scope — use `expr_scope` (`switch.rs:26`, `transform.rs:27`).
+- **BUG-5/6:** sub-workflow HITL dropped; `on_run_finish` never fires on failure — verify against `FlowRunObserver`.
+- **BUG-9:** `code`/`output_parser`/`sub_workflow` skip `=`-resolution — make expression binding uniform.
+
+---
+
+## Part B — Selectable agent kinds
+
+### B0. The idea
+
+Let an `agent` node declare **which agent** runs it, by referencing an OpenHuman registry agent:
+
+```jsonc
+{
+ "kind": "agent",
+ "config": {
+ "agent_ref": "code_executor", // or "researcher", "crypto_agent", …
+ "prompt": "=item.text",
+ "connection_ref": "…", // still host-resolved, never model-supplied
+ // optional per-node overrides:
+ "model": "…",
+ "max_iterations": 6,
+ "tools_allow": ["grep", "edit"],
+ },
+}
+```
+
+When `agent_ref` is set, the host runs that **registered agent** — with _its_ curated toolset, model hint, sandbox mode, and iteration policy — as a full multi-turn agent loop, instead of the current single `provider.chat` call. A coding step gets coding tools; a research step gets `web_search`/`web_fetch`; a crypto step gets market tools. This is exactly the registry's existing contract (`src/openhuman/agent_registry/agents/*/agent.toml`).
+
+### B1. Crate seam — new `AgentRunner` capability (host-agnostic)
+
+The crate must not know about OpenHuman's registry, and "run a named agent to completion (multi-turn, tool-using)" is a **different capability** than `LlmProvider.complete` (single shot). Add a trait to `vendor/tinyflows/src/caps/mod.rs`:
+
+```rust
+#[async_trait]
+pub trait AgentRunner: Send + Sync {
+ /// Run a host-registered agent identified by `agent_ref` to completion.
+ /// `request` carries prompt/input/overrides; `conn` is the opaque credential.
+ async fn run_agent(&self, agent_ref: &str, request: Value, conn: Option<&str>) -> Result;
+}
+```
+
+- Add `agent: Option>` to `Capabilities` (optional so hosts without a registry keep working).
+- **`agent.rs` dispatch**: if `config.agent_ref` is present _and_ `caps.agent` is wired → `run_agent(...)`; else fall back to today's `LlmProvider.complete` path. Both emit the A1 envelope, so downstream expressions are identical regardless.
+- Mock impl in `caps/mock.rs` (echo `agent_ref` + request) so crate tests exercise both paths.
+- `agent_ref` is **trusted config only**, never taken from model output (same rule as `tool_call.connection_ref`, `agent.rs:71-79`).
+
+### B2. Host adapter — implement `AgentRunner` over the registry + delegate runtime
+
+- `src/openhuman/tinyflows/caps.rs`: new `OpenHumanAgentRunner` implementing `AgentRunner`.
+- `run_agent(agent_ref, request, conn)`:
+ 1. `agent_registry::ops::get_agent(agent_ref)` → resolve the entry (tools, model hint, sandbox, `max_iterations`, `iteration_policy`).
+ 2. Apply optional per-node overrides (`model`, `max_iterations`, and a **narrowing-only** `tools_allow` — a node may _subset_ the agent's tools, never add).
+ 3. Drive the existing delegate runtime (`src/openhuman/agent/tools/delegate.rs`) with a `TurnOrigin` of the flow run (`src/openhuman/agent/turn_origin.rs` already has a flow origin variant).
+ 4. Return `{ json, text, raw }` (A1 envelope): final structured output in `json`, final message text in `text`.
+- **Autonomy/security**: the agent kind's tools are still gated by `SecurityPolicy` / autonomy tier; a `sandboxed` agent (e.g. `code_executor`) runs sandboxed; the flow's own approval gate still parks outbound actions. No new privilege path.
+- **Depth/cost guard**: an agent node running a full agent loop inside a flow can fan out cost — bound it (reuse `MAX_SUB_WORKFLOW_DEPTH`-style counter or a per-run agent-invocation cap) and thread cancellation into the delegate run (today's sub-workflow path drops the token — BUG-5 — fix here too).
+
+### B3. Authoring surface — ground the choice for the builder agent
+
+- **Builder tool** `list_agent_profiles` in `src/openhuman/flows/builder_tools.rs`, backed by `agent_registry::ops::list_agents(false)`, returning `{ id, display_name, when_to_use, tools, sandbox_mode }`. Mirrors the existing `search_tool_catalog` pattern so the `workflow_builder` sub-agent picks a real `agent_ref` instead of hallucinating one.
+- **Validation** (`vendor/tinyflows/src/validate.rs` + host): an `agent` node with an `agent_ref` that doesn't resolve is a structured validation error (needs the host to pass the known-agent set into validation, or validate host-side in `flows::ops::validate`).
+- **workflow_builder prompt** (`src/openhuman/agent_registry/agents/workflow_builder/prompt.md`): document `agent_ref` and when to prefer a specialized agent over a bare completion.
+
+### B4. UI — agent-kind picker
+
+- The (upcoming) node config panel (`U2` in the integration plan) gets an **Agent kind** dropdown for `agent` nodes, populated from `agent_registry` `list_agents` RPC (already exists), showing `display_name` + `when_to_use`, with an optional tool-subset multiselect.
+- Read-only canvas: show the chosen agent kind as a node badge.
+
+---
+
+## Part C — Sequencing & ownership
+
+| Phase | Work | Depends on | Surface |
+| ------ | -------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------- |
+| **C0** | BUG-1 env-leak hotfix; BUG-2 switch/transform scope | — | `vendor/tinyflows/src/expr.rs`, `control_flow/*` |
+| **C1** | A0 envelope + **A1 agent output normalization** | C0 | `agent.rs`, `caps.rs` |
+| **C2** | A2 tool-shape unification; A3 per-item execution | C1 | `tool_call.rs`, `http_request.rs`, `nodes/mod.rs` |
+| **C3** | A4 port-aware `collect_input` (+ BUG-3/4); A5 merge modes | — (parallel to C1/C2) | `engine.rs`, `merge.rs` |
+| **C4** | **B1 `AgentRunner` capability** + mock | C1 (shared envelope) | `caps/mod.rs`, `caps/mock.rs`, `agent.rs` |
+| **C5** | **B2 host `OpenHumanAgentRunner`** over registry+delegate (+ BUG-5 cancellation) | C4 | `tinyflows/caps.rs`, `agent/tools/delegate.rs` |
+| **C6** | B3 `list_agent_profiles` + `agent_ref` validation; A6 alignment lint | C5, BUG-10 | `flows/builder_tools.rs`, `validate.rs` |
+| **C7** | B4 UI agent-kind picker | C5, U2 node panel | `app/src/components/flows/*` |
+
+Each phase ships with tests (crate unit + host seam + JSON-RPC E2E) per the repo's "tests before the next layer" rule, and records design decisions in `vendor/tinyflows/local/docs/11-decisions.md`.
+
+## Open questions
+
+1. **`agent_ref` in the crate**: prefer a dedicated `AgentRunner` capability (B1, recommended — keeps `LlmProvider.complete` a clean single-shot contract) vs. overloading `complete` to loop when it sees `agent_ref`. Recommendation: the capability.
+2. **Per-node model/tool overrides**: allow narrowing only (`tools_allow` subsets the agent's toolset) — never widen, to preserve the registry's security envelope. Confirm this constraint.
+3. **Cost/loop bounds** for agent-kind nodes inside a flow (and inside a sub-workflow): reuse the depth counter or introduce a per-run agent-invocation budget?
+4. **Custom agents**: `upsert_custom_agent` already exists — user-defined agent kinds are addressable by `agent_ref` for free. Confirm they should be selectable in flows.
diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs
index e4dc3c4f3..a52264f8b 100644
--- a/src/openhuman/agent_registry/agents/loader.rs
+++ b/src/openhuman/agent_registry/agents/loader.rs
@@ -989,10 +989,15 @@ mod tests {
#[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.
+ // tool scope is EXACTLY the propose-or-read + Composio discovery/connect
+ // + confirmed test-run belt — no persistence (flows_create/update/
+ // set_enabled), no shell, no file writes, no channel sends, and no
+ // composio_execute. It can list toolkits/connections, raise the inline
+ // connect card, and `run_workflow` a flow the user already SAVED to test
+ // it (a real run the prompt gates behind user confirmation), but it can
+ // never persist/enable a flow or perform a raw integration action. This
+ // pins the 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"));
@@ -1013,6 +1018,10 @@ mod tests {
"list_flow_connections",
"search_tool_catalog",
"dry_run_workflow",
+ "run_workflow",
+ "composio_list_toolkits",
+ "composio_list_connections",
+ "composio_connect",
];
for required in expected {
assert!(
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/agent.toml b/src/openhuman/agent_registry/agents/workflow_builder/agent.toml
index 420416093..8b69bf1b4 100644
--- a/src/openhuman/agent_registry/agents/workflow_builder/agent.toml
+++ b/src/openhuman/agent_registry/agents/workflow_builder/agent.toml
@@ -23,9 +23,15 @@ 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.
+# connections, tool catalog) + sandbox dry-run + Composio discovery/connect +
+# a confirmed real test-run of a SAVED flow. NO shell, NO file writes, NO
+# channel sends, NO composio_execute, and NO flows_create/update/set_enabled —
+# the agent can never persist or enable a flow, or perform a real integration
+# action directly. Composio access is limited to LISTING toolkits/connections
+# and raising the inline CONNECT card (an approval-gated OAuth hand-off).
+# `run_workflow` executes a flow the user has ALREADY saved to test it — a real
+# run, but the prompt requires asking the user to confirm first, and the flow's
+# own approval gate still pauses outbound-action nodes.
named = [
"propose_workflow",
"revise_workflow",
@@ -35,4 +41,8 @@ named = [
"list_flow_connections",
"search_tool_catalog",
"dry_run_workflow",
+ "run_workflow",
+ "composio_list_toolkits",
+ "composio_list_connections",
+ "composio_connect",
]
diff --git a/src/openhuman/agent_registry/agents/workflow_builder/prompt.md b/src/openhuman/agent_registry/agents/workflow_builder/prompt.md
index 351455ec6..1381825e7 100644
--- a/src/openhuman/agent_registry/agents/workflow_builder/prompt.md
+++ b/src/openhuman/agent_registry/agents/workflow_builder/prompt.md
@@ -8,8 +8,8 @@ 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:
+You **cannot and must not** create, update, enable, or disable a saved flow. You
+have no tool that does — by design. Your authoring outputs are:
- **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate
graph and hand back a proposal summary. They **never** save anything.
@@ -21,6 +21,24 @@ 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.
+## Testing a saved flow: `run_workflow` (ask first!)
+
+Once the user has **saved** a flow, you can `run_workflow { flow_id }` to test it
+end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects can
+fire (the flow's own approval gate still pauses outbound-action nodes, but treat
+it as real). Rules:
+
+1. **Only a saved flow.** `run_workflow` needs a `flow_id`; if the workflow isn't
+ saved yet, tell the user to Save it first (you can't run a draft — use
+ `dry_run_workflow` for a draft wiring check).
+2. **ALWAYS ask for confirmation and wait for an explicit "yes"** before calling
+ `run_workflow`. Say what it will do ("This will run the flow for real and may
+ send/act on live data — run it now?") and only proceed once they agree. Never
+ run a workflow unprompted or as a surprise side effect of another request.
+3. After a run, read the result (status + any nodes paused for approval) and
+ report what happened; if it failed, `get_flow_run` for the steps and propose a
+ fix.
+
## Your authoring loop
1. **Understand the trigger and the steps.** What starts the flow? What should
@@ -34,6 +52,37 @@ it on for me", explain that you can only propose it — they confirm the save.
`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.
+ - **Missing the integration the workflow needs?** See "Connecting
+ integrations" below — you can help the user link it before you build,
+ rather than dead-ending.
+
+## Connecting integrations
+
+A workflow often needs an app the user hasn't linked yet (a `tool_call` on
+Gmail, Slack, Notion…). You can close that gap yourself instead of telling the
+user to go do it elsewhere:
+
+- **`composio_list_toolkits`** — the catalog of connectable apps (slugs like
+ `gmail`, `slack`, `googlesheets`). Use it to find the right toolkit for what
+ the user described.
+- **`composio_list_connections`** — which toolkits the user has ALREADY
+ connected (mirrors `list_flow_connections`' Composio side). Check here first —
+ never ask someone to connect an app they've already linked.
+- **`composio_connect`** — raises an inline **Connect** card for a toolkit and
+ waits for the user to approve the OAuth hand-off. Call it when the workflow
+ needs an app that isn't in `composio_list_connections` yet. After it returns
+ connected, re-run `list_flow_connections` to pick up the fresh
+ `connection_ref` and put it on the node.
+
+Still bounded: you can **discover and connect** apps, but you have **no** tool to
+*execute* a Composio action (`composio_execute` is deliberately out of scope) and
+**no** tool to persist a flow. Connecting is a setup step in service of a
+proposal — the user still saves the workflow themselves.
+
+Typical setup arc: user asks for a Slack step → `composio_list_connections`
+shows Slack isn't linked → `composio_connect { toolkit: "slack" }` → once
+connected, `list_flow_connections` → build the `tool_call` node with the real
+`connection_ref` + a `search_tool_catalog` slug → dry-run → propose.
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.
@@ -57,9 +106,22 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`.
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.
+ **If the agent's output feeds a `tool_call`, it MUST declare an output
+ schema** — set `config.output_parser.schema` (a JSON Schema object) — so
+ its emitted item is a structured object whose fields downstream nodes can
+ address (`=nodes..item.`). Without a schema the agent
+ emits `{text: "..."}` and `=item.to`-style bindings resolve to null.
+3. **`tool_call`** — an action. Two flavours by `config.slug`:
+ - **Composio app action** — `config.slug` = a real action slug (from
+ `search_tool_catalog`, e.g. `GMAIL_SEND_EMAIL`) + `config.connection_ref`
+ for the account. **Wire every REQUIRED arg in `config.args` from a named
+ upstream node** — e.g. an email send needs `to`/`recipient_email`, usually
+ `"to": "=nodes..item.email"`. A required arg left unwired (or
+ whose expression misses) now fails BEFORE the provider call — both in
+ `dry_run_workflow` and in real runs — with an error naming the field.
+ - **Native OpenHuman tool** — `config.slug` = `oh:` (e.g.
+ `oh:web_search`) to call one of the assistant's own built-in tools (search,
+ media generation, files, …). No `connection_ref`. Args go in `config.args`.
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`.
@@ -85,8 +147,39 @@ the run scope (`.`):
- A string **without** a leading `=` is a literal. To emit a literal `=`, don't
start the string with it.
+The scope exposes:
+
+- `item` / `items` — the **direct predecessor(s)'** output (first item / all
+ items, in edge order).
+- `run` — run metadata and the trigger payload.
+- `nodes` — **every completed node's output, keyed by node id**:
+ `nodes..item` (first item) and `nodes..items` (all items). Use this
+ to reference ANY upstream node — not just the immediate predecessor — and to
+ disambiguate a fan-in node's inputs. Dotted form: `"=nodes.fetch.item.email"`;
+ jq form: `"=.nodes[\"fetch\"].items[0].email"`. Ids (not names) are the key.
+
Use expressions to thread data between steps (a `transform`'s `set`, an
-`agent`'s `prompt`, a `tool_call`'s `args`).
+`agent`'s `prompt`, a `tool_call`'s `args`). Prefer `=nodes..…` for
+`tool_call` args so the binding survives graph re-wiring.
+
+**Worked example — agent → Gmail send.** The agent must declare a schema, and
+the tool_call wires each required arg from the agent BY ID:
+
+```json
+{ "id": "extract", "kind": "agent", "config": {
+ "prompt": "=\"Extract the recipient and a reply from: \" + .item.text",
+ "output_parser": { "schema": { "type": "object",
+ "required": ["email", "subject", "body"],
+ "properties": { "email": {"type": "string"}, "subject": {"type": "string"}, "body": {"type": "string"} } } } } }
+{ "id": "send", "kind": "tool_call", "config": {
+ "slug": "GMAIL_SEND_EMAIL", "connection_ref": "composio:gmail:",
+ "args": { "to": "=nodes.extract.item.email",
+ "subject": "=nodes.extract.item.subject",
+ "body": "=nodes.extract.item.body" } } }
+```
+
+Without the schema, `=nodes.extract.item.email` would be null (the agent's
+item would be `{text: ...}`) and the send would fail preflight naming `to`.
### Trigger kinds — which ones actually fire
diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs
index e10e57ea3..2f5bb5756 100644
--- a/src/openhuman/flows/builder_tools.rs
+++ b/src/openhuman/flows/builder_tools.rs
@@ -21,6 +21,14 @@
//! 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.
+//!
+//! The agent's full tool scope (see `agent_registry/agents/workflow_builder/
+//! agent.toml`) also grants the Composio **discovery/connect** tools —
+//! `composio_list_toolkits`, `composio_list_connections`, `composio_connect`
+//! (defined in `composio/tools.rs`) — so the builder can link an app the
+//! workflow needs before proposing. Those stay within the invariant: connect
+//! is an approval-gated OAuth hand-off, and `composio_execute` (running a real
+//! action) remains deliberately OUT of scope.
use std::sync::Arc;
@@ -156,7 +164,10 @@ impl Tool for ReviseWorkflowTool {
};
let summary = super::tools::build_summary(&graph);
- let warnings = ops::graph_trigger_warnings(&graph);
+ let mut warnings = ops::graph_trigger_warnings(&graph);
+ // Author-time wiring check: unwired REQUIRED Composio args come back
+ // as warnings naming the field, before the user ever saves.
+ warnings.extend(ops::graph_wiring_warnings(&self.config, &graph).await);
let graph_value = serde_json::to_value(&graph)?;
tracing::info!(
@@ -613,13 +624,20 @@ impl Tool for SearchToolCatalogTool {
/// 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.
+///
+/// **Wiring preflight:** the mock tool invoker is wrapped in the host's
+/// [`PreflightToolInvoker`](crate::openhuman::tinyflows::caps::PreflightToolInvoker),
+/// so a Composio `tool_call` whose required arg is missing or `=`-resolved to
+/// null fails the dry run with the same actionable, field-naming error a real
+/// run would produce — the echo mocks alone would happily accept a null `to`.
pub struct DryRunWorkflowTool {
security: Arc,
+ config: Arc,
}
impl DryRunWorkflowTool {
- pub fn new(security: Arc) -> Self {
- Self { security }
+ pub fn new(security: Arc, config: Arc) -> Self {
+ Self { security, config }
}
}
@@ -719,7 +737,13 @@ impl Tool for DryRunWorkflowTool {
}
};
- let caps = tinyflows::caps::mock::mock_capabilities();
+ let mut caps = tinyflows::caps::mock::mock_capabilities();
+ // Wiring preflight over the echo mocks (see the struct doc): required
+ // Composio args must be present and non-null even in the sandbox.
+ caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker {
+ config: self.config.clone(),
+ inner: caps.tools.clone(),
+ });
let run = tinyflows::engine::run(&compiled, input, &caps);
let outcome = match tokio::time::timeout(
std::time::Duration::from_secs(DRY_RUN_TIMEOUT_SECS),
diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs
index 26e8329c5..2f8113835 100644
--- a/src/openhuman/flows/builder_tools_tests.rs
+++ b/src/openhuman/flows/builder_tools_tests.rs
@@ -201,7 +201,10 @@ async fn search_tool_catalog_missing_query_is_error() {
#[test]
fn dry_run_is_execute_permission() {
- let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
+ let tool = DryRunWorkflowTool::new(
+ policy(AutonomyLevel::Supervised),
+ test_config(&TempDir::new().unwrap()),
+ );
assert_eq!(tool.name(), "dry_run_workflow");
assert_eq!(tool.permission_level(), PermissionLevel::Execute);
// Mock-backed: no real outbound effect.
@@ -210,7 +213,10 @@ fn dry_run_is_execute_permission() {
#[tokio::test]
async fn dry_run_refused_under_readonly_tier() {
- let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::ReadOnly));
+ let tool = DryRunWorkflowTool::new(
+ policy(AutonomyLevel::ReadOnly),
+ test_config(&TempDir::new().unwrap()),
+ );
let result = tool
.execute(json!({ "graph": valid_graph() }))
.await
@@ -221,7 +227,10 @@ async fn dry_run_refused_under_readonly_tier() {
#[tokio::test]
async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
- let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
+ let tool = DryRunWorkflowTool::new(
+ policy(AutonomyLevel::Supervised),
+ test_config(&TempDir::new().unwrap()),
+ );
let result = tool
.execute(json!({ "graph": valid_graph(), "input": { "x": 1 } }))
.await
@@ -239,10 +248,119 @@ async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
#[tokio::test]
async fn dry_run_invalid_graph_is_error() {
- let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Full));
+ let tool = DryRunWorkflowTool::new(
+ policy(AutonomyLevel::Full),
+ test_config(&TempDir::new().unwrap()),
+ );
let result = tool
.execute(json!({ "graph": { "nodes": [], "edges": [] } }))
.await
.unwrap();
assert!(result.is_error);
}
+
+#[tokio::test]
+async fn dry_run_catches_unwired_required_composio_arg() {
+ // Seed the preflight schema cache so no live Composio backend is needed.
+ // NOTE: the cache is process-global and other tests seed the `gmail`
+ // toolkit too — keep every seeding of GMAIL_SEND_EMAIL identical
+ // (`to` + `body`) so test order can't change the outcome.
+ let mut entries = std::collections::HashMap::new();
+ entries.insert(
+ "GMAIL_SEND_EMAIL".to_string(),
+ vec!["to".to_string(), "body".to_string()],
+ );
+ crate::openhuman::tinyflows::caps::seed_required_args_cache("gmail", entries);
+
+ let tmp = TempDir::new().unwrap();
+ let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised), test_config(&tmp));
+
+ let graph_with = |args: Value| {
+ json!({
+ "nodes": [
+ { "id": "t", "kind": "trigger", "name": "Manual" },
+ { "id": "send", "kind": "tool_call", "name": "Send email",
+ "config": { "slug": "GMAIL_SEND_EMAIL", "args": args } }
+ ],
+ "edges": [ { "from_node": "t", "to_node": "send" } ]
+ })
+ };
+
+ // `to` is a `=`-expression that misses (trigger input has no `email`):
+ // the dry run must fail BEFORE the (mock) tool call, naming the field.
+ let result = tool
+ .execute(json!({
+ "graph": graph_with(json!({ "to": "=item.email", "body": "hello" })),
+ "input": {}
+ }))
+ .await
+ .unwrap();
+ let out = result.output();
+ assert!(
+ out.contains("`to`") && out.contains("required"),
+ "dry run must name the unwired required arg: {out}"
+ );
+
+ // The same flow with `to` wired from the trigger passes the preflight.
+ let result = tool
+ .execute(json!({
+ "graph": graph_with(json!({ "to": "=item.email", "body": "hello" })),
+ "input": { "email": "a@b.com" }
+ }))
+ .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,
+ "wired flow must dry-run green: {parsed}"
+ );
+}
+
+#[tokio::test]
+async fn revise_workflow_warns_on_unwired_required_composio_arg() {
+ let mut entries = std::collections::HashMap::new();
+ entries.insert(
+ "GMAIL_SEND_EMAIL".to_string(),
+ vec!["to".to_string(), "body".to_string()],
+ );
+ crate::openhuman::tinyflows::caps::seed_required_args_cache("gmail", entries);
+
+ let tmp = TempDir::new().unwrap();
+ let tool = ReviseWorkflowTool::new(test_config(&tmp));
+ let result = tool
+ .execute(json!({
+ "name": "Send mail",
+ "graph": {
+ "nodes": [
+ { "id": "t", "kind": "trigger", "name": "Manual" },
+ { "id": "send", "kind": "tool_call", "name": "Send",
+ // `body` wired via expression (counts as wired); `to` absent.
+ "config": { "slug": "GMAIL_SEND_EMAIL",
+ "args": { "body": "=item.text" } } }
+ ],
+ "edges": [ { "from_node": "t", "to_node": "send" } ]
+ }
+ }))
+ .await
+ .unwrap();
+
+ assert!(!result.is_error, "{}", result.output());
+ let parsed: Value = serde_json::from_str(&result.output()).unwrap();
+ let warnings = parsed["warnings"].as_array().unwrap();
+ assert!(
+ warnings.iter().any(|w| {
+ let w = w.as_str().unwrap_or_default();
+ w.contains("`to`") && w.contains("send")
+ }),
+ "expected a warning naming node `send` and arg `to`: {warnings:?}"
+ );
+ // `body` is wired (expression) — no warning for it.
+ assert!(
+ !warnings
+ .iter()
+ .any(|w| w.as_str().unwrap_or_default().contains("`body`")),
+ "wired arg must not warn: {warnings:?}"
+ );
+}
diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs
index fd4034ca2..a4e8505ee 100644
--- a/src/openhuman/flows/ops.rs
+++ b/src/openhuman/flows/ops.rs
@@ -162,6 +162,56 @@ pub(crate) fn graph_trigger_warnings(graph: &WorkflowGraph) -> Vec {
)]
}
+/// Author-time wiring warnings for Composio `tool_call` nodes: flags every
+/// **required** arg (per the action's schema, best-effort cached lookup) that
+/// is absent or a literal `null` in `config.args` — the exact mis-wiring that
+/// would later fail the run's required-arg preflight.
+///
+/// Static by design: an arg carrying an `=`-expression counts as wired (only
+/// the runtime preflight can tell whether it resolves), a `=`-derived slug is
+/// skipped (can't know the action), and native `oh:` tools are skipped (no
+/// Composio schema). Best-effort like the runtime preflight — no schema, no
+/// warning, never a block.
+pub(crate) async fn graph_wiring_warnings(config: &Config, graph: &WorkflowGraph) -> Vec {
+ use crate::openhuman::tinyflows::caps::{composio_required_args, missing_required_args};
+
+ let mut warnings = Vec::new();
+ for node in &graph.nodes {
+ if node.kind != tinyflows::model::NodeKind::ToolCall {
+ continue;
+ }
+ let Some(slug) = node.config.get("slug").and_then(Value::as_str) else {
+ continue;
+ };
+ // `=`-derived slugs are resolved at runtime; native tools have no
+ // Composio schema to check against.
+ if slug.starts_with('=') || slug.starts_with("oh:") {
+ continue;
+ }
+ let Some(required) = composio_required_args(config, slug).await else {
+ tracing::debug!(target: "flows", node = %node.id, %slug, "[flows] wiring check: no schema — skipping node");
+ continue;
+ };
+ let args = node.config.get("args").cloned().unwrap_or(Value::Null);
+ for missing in missing_required_args(&required, &args) {
+ tracing::warn!(
+ target: "flows",
+ node = %node.id,
+ %slug,
+ arg = %missing,
+ "[flows] wiring check: required arg not wired"
+ );
+ warnings.push(format!(
+ "Node '{}': required arg `{missing}` of `{slug}` is not wired — set \
+ args.{missing}, e.g. \"=nodes..item.\" (an agent feeding \
+ this value needs an output schema so its fields are addressable).",
+ node.id
+ ));
+ }
+ }
+ warnings
+}
+
/// Validates a candidate graph without persisting it — the same
/// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and
/// reports structural errors alongside non-fatal trigger warnings
@@ -1506,6 +1556,7 @@ fn reconstruct_steps(output: &Value) -> Vec {
// Reconstructed post-hoc: no live status/timing (see FlowRunStep).
status: None,
duration_ms: None,
+ diagnostics: Vec::new(),
})
.collect()
}
diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs
index 6d6fa1e27..3ad85df4b 100644
--- a/src/openhuman/flows/ops_tests.rs
+++ b/src/openhuman/flows/ops_tests.rs
@@ -1044,12 +1044,14 @@ async fn observer_persists_each_step_incrementally() {
status: StepStatus::Success,
output: json!([{ "json": { "ok": true } }]),
duration_ms: 7,
+ diagnostics: Vec::new(),
});
observer.on_step_finish(&ExecutionStep {
node_id: "b".to_string(),
status: StepStatus::Error,
output: Value::Null,
duration_ms: 3,
+ diagnostics: Vec::new(),
});
// The store now holds both live steps with real status + timing — proof of
@@ -1069,6 +1071,7 @@ async fn observer_persists_each_step_incrementally() {
status: StepStatus::Success,
output: json!([{ "json": { "ok": true } }]),
duration_ms: 42,
+ diagnostics: Vec::new(),
});
let row = store::get_flow_run(&config, &run_id).unwrap().unwrap();
assert_eq!(row.steps.len(), 2, "re-firing a node must not duplicate it");
diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs
index bf2c92df4..ee7fcd5d6 100644
--- a/src/openhuman/flows/tools.rs
+++ b/src/openhuman/flows/tools.rs
@@ -177,6 +177,13 @@ impl Tool for ProposeWorkflowTool {
};
let summary = build_summary(&graph);
+ // Author-time warnings: unfired trigger kinds + unwired REQUIRED
+ // Composio args (see `ops::graph_wiring_warnings`) — surfaced on the
+ // proposal so the builder fixes wiring before the user saves.
+ let mut warnings = crate::openhuman::flows::ops::graph_trigger_warnings(&graph);
+ warnings.extend(
+ crate::openhuman::flows::ops::graph_wiring_warnings(&self.config, &graph).await,
+ );
let graph_value = serde_json::to_value(&graph)?;
tracing::info!(
@@ -184,6 +191,7 @@ impl Tool for ProposeWorkflowTool {
%name,
node_count = graph.nodes.len(),
require_approval,
+ warning_count = warnings.len(),
"[flows] propose_workflow: proposal ready for user review"
);
@@ -193,10 +201,110 @@ impl Tool for ProposeWorkflowTool {
"graph": graph_value,
"require_approval": require_approval,
"summary": summary,
+ "warnings": warnings,
}))?))
}
}
+/// Runs a **saved** workflow by id so the `workflow-builder` agent can *test*
+/// it end-to-end. Unlike [`crate::openhuman::flows::builder_tools::DryRunWorkflowTool`]
+/// (a MOCK sandbox), this is a **real** run — so it is `PermissionLevel::Write`
+/// with `external_effect() == true`. Two safety layers remain: the flow's own
+/// `require_approval` gate still pauses outbound-action nodes mid-run, and the
+/// agent prompt requires it to ASK THE USER for confirmation before ever
+/// calling this. It only runs an already-persisted flow (no `flow_id`, no run).
+pub struct RunFlowTool {
+ config: Arc,
+}
+
+impl RunFlowTool {
+ pub fn new(config: Arc) -> Self {
+ Self { config }
+ }
+}
+
+#[async_trait]
+impl Tool for RunFlowTool {
+ fn name(&self) -> &str {
+ "run_workflow"
+ }
+
+ fn description(&self) -> &str {
+ "Run a SAVED workflow by id to TEST it end-to-end. This is a REAL run, not a \
+ simulation — real effects can fire (use dry_run_workflow for a safe MOCK run \
+ instead). It only works on a flow the user has already saved; pass its `flow_id`. \
+ You MUST ask the user to confirm and wait for an explicit 'yes' before calling this \
+ — never run a workflow unprompted. The flow's own approval gate still pauses \
+ outbound-action nodes. Params: { flow_id (required), input? }. Returns the run's \
+ status + any nodes paused for approval."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "flow_id": {
+ "type": "string",
+ "description": "Id of the SAVED flow to run (the user must have saved it first)."
+ },
+ "input": {
+ "description": "Optional trigger input passed to the run (defaults to {})."
+ }
+ },
+ "required": ["flow_id"]
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ // A real run with real effects — gated like a Write-class action.
+ PermissionLevel::Write
+ }
+
+ fn external_effect(&self) -> bool {
+ true
+ }
+
+ async fn execute(&self, args: Value) -> anyhow::Result {
+ let flow_id =
+ match args.get("flow_id").and_then(Value::as_str).map(str::trim) {
+ Some(id) if !id.is_empty() => id.to_string(),
+ _ => return Ok(ToolResult::error(
+ "Missing 'flow_id' — run_workflow only works on a SAVED flow. Ask the user \
+ to Save the workflow first, then run it by id."
+ .to_string(),
+ )),
+ };
+ let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
+
+ tracing::info!(
+ target: "flows",
+ %flow_id,
+ "[flows] run_workflow: agent-initiated test run starting"
+ );
+
+ match crate::openhuman::flows::ops::flows_run(
+ &self.config,
+ &flow_id,
+ input,
+ crate::openhuman::flows::types::FlowRunTrigger::Rpc,
+ )
+ .await
+ {
+ Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
+ "type": "workflow_run_result",
+ "flow_id": flow_id,
+ "result": outcome.value,
+ }))?)),
+ Err(e) => {
+ tracing::debug!(target: "flows", %flow_id, error = %e, "[flows] run_workflow: failed");
+ Ok(ToolResult::error(format!(
+ "Could not run flow '{flow_id}': {e}"
+ )))
+ }
+ }
+ }
+}
+
/// Builds the `{ trigger, steps }` summary surfaced to both the LLM (in the
/// tool result) and the chat UI's `WorkflowProposalCard`.
///
diff --git a/src/openhuman/flows/types.rs b/src/openhuman/flows/types.rs
index 4a39d40c8..8e2a7c694 100644
--- a/src/openhuman/flows/types.rs
+++ b/src/openhuman/flows/types.rs
@@ -141,6 +141,13 @@ pub struct FlowRunStep {
/// observed incrementally. `None` for a step recovered post-hoc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option,
+ /// Data-binding diagnostics from the engine: each config `=`-expression
+ /// that resolved to `null` during this step, as
+ /// `{ "location": "args.to", "expression": "=item.to" }`. Lets the run
+ /// view point at the exact unresolved wiring. Empty for clean steps and
+ /// for steps recovered post-hoc.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub diagnostics: Vec,
}
/// A resolvable connection the flows UI / agent picker can attach to a node's
diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs
index 3a7734220..0bc43c19c 100644
--- a/src/openhuman/runtime_node/ops.rs
+++ b/src/openhuman/runtime_node/ops.rs
@@ -6,8 +6,8 @@ use crate::openhuman::agent::host_runtime::{NativeRuntime, RuntimeAdapter};
use crate::openhuman::config::Config;
use crate::openhuman::memory::Memory;
use crate::openhuman::runtime_node::types::{ExecuteToolOutcome, RuntimeToolSummary};
-use crate::openhuman::security::SecurityPolicy;
-use crate::openhuman::tools::{self, Tool, ToolCallOptions, ToolScope};
+use crate::openhuman::security::{CommandClass, SecurityPolicy};
+use crate::openhuman::tools::{self, PermissionLevel, Tool, ToolCallOptions, ToolScope};
use tracing::{debug, trace};
fn tool_scope_label(scope: ToolScope) -> &'static str {
@@ -30,6 +30,51 @@ fn summarize_tool(tool: &dyn Tool) -> RuntimeToolSummary {
}
}
+fn classify_shell_tool_call(security: &SecurityPolicy, args: &serde_json::Value) -> CommandClass {
+ let command = args
+ .get("command")
+ .and_then(|value| value.as_str())
+ .unwrap_or("");
+ let mut class = security.classify_command(command);
+ if let Some(declared) = args
+ .get("category")
+ .and_then(|value| value.as_str())
+ .and_then(SecurityPolicy::parse_declared_class)
+ {
+ class = class.max(declared);
+ }
+ class
+}
+
+fn command_class_for_tool(
+ security: &SecurityPolicy,
+ tool: &dyn Tool,
+ args: &serde_json::Value,
+) -> CommandClass {
+ if tool.name() == "shell" {
+ return classify_shell_tool_call(security, args);
+ }
+
+ let permission = tool.permission_level_with_args(args);
+ match permission {
+ PermissionLevel::None | PermissionLevel::ReadOnly => {
+ if tool.external_effect_with_args(args) {
+ CommandClass::Network
+ } else {
+ CommandClass::Read
+ }
+ }
+ PermissionLevel::Write | PermissionLevel::Execute => {
+ if tool.external_effect_with_args(args) {
+ CommandClass::Network
+ } else {
+ CommandClass::Write
+ }
+ }
+ PermissionLevel::Dangerous => CommandClass::Destructive,
+ }
+}
+
fn build_runtime_tools(config: &Config) -> Result>, String> {
debug!(
workspace = %config.workspace_dir.display(),
@@ -104,6 +149,36 @@ pub fn list_tools(config: &Config) -> Result, String> {
Ok(summaries)
}
+pub fn classify_tool_call(
+ config: &Config,
+ tool_name: &str,
+ args: &serde_json::Value,
+) -> Result {
+ debug!(tool_name, "[runtime_node::ops] classify_tool_call: start");
+ let security =
+ SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir, &config.action_dir);
+ let tools = build_runtime_tools(config)?;
+ let tool = tools
+ .into_iter()
+ .find(|tool| tool.name() == tool_name)
+ .ok_or_else(|| {
+ debug!(
+ tool_name,
+ "[runtime_node::ops] classify_tool_call: tool not found"
+ );
+ format!("unknown tool `{tool_name}`")
+ })?;
+ let class = command_class_for_tool(&security, tool.as_ref(), args);
+ debug!(
+ tool_name,
+ ?class,
+ permission = %tool.permission_level_with_args(args),
+ external_effect = tool.external_effect_with_args(args),
+ "[runtime_node::ops] classify_tool_call: done"
+ );
+ Ok(class)
+}
+
pub async fn execute_tool(
config: &Config,
tool_name: &str,
@@ -217,6 +292,42 @@ mod tests {
}
}
+ struct PolicyTool {
+ name: &'static str,
+ permission: PermissionLevel,
+ external_effect: bool,
+ }
+
+ #[async_trait]
+ impl Tool for PolicyTool {
+ fn name(&self) -> &str {
+ self.name
+ }
+
+ fn description(&self) -> &str {
+ "Policy test tool"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ json!({"type": "object"})
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ self.permission
+ }
+
+ fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
+ self.external_effect
+ }
+
+ async fn execute(
+ &self,
+ _args: serde_json::Value,
+ ) -> anyhow::Result {
+ Ok(crate::openhuman::skills::types::ToolResult::success("ok"))
+ }
+ }
+
#[test]
fn summarize_tool_exposes_metadata() {
let summary = summarize_tool(&DummyTool);
@@ -232,4 +343,84 @@ mod tests {
assert_eq!(tool_scope_label(ToolScope::AgentOnly), "agent_only");
assert_eq!(tool_scope_label(ToolScope::CliRpcOnly), "cli_rpc_only");
}
+
+ #[test]
+ fn command_class_for_tool_maps_metadata_to_policy_buckets() {
+ let security = SecurityPolicy::default();
+
+ let read = PolicyTool {
+ name: "read_tool",
+ permission: PermissionLevel::ReadOnly,
+ external_effect: false,
+ };
+ assert_eq!(
+ command_class_for_tool(&security, &read, &json!({})),
+ CommandClass::Read
+ );
+
+ let write = PolicyTool {
+ name: "write_tool",
+ permission: PermissionLevel::Write,
+ external_effect: false,
+ };
+ assert_eq!(
+ command_class_for_tool(&security, &write, &json!({})),
+ CommandClass::Write
+ );
+
+ let outbound = PolicyTool {
+ name: "outbound_tool",
+ permission: PermissionLevel::Write,
+ external_effect: true,
+ };
+ assert_eq!(
+ command_class_for_tool(&security, &outbound, &json!({})),
+ CommandClass::Network
+ );
+
+ let dangerous = PolicyTool {
+ name: "dangerous_tool",
+ permission: PermissionLevel::Dangerous,
+ external_effect: true,
+ };
+ assert_eq!(
+ command_class_for_tool(&security, &dangerous, &json!({})),
+ CommandClass::Destructive
+ );
+ }
+
+ #[test]
+ fn command_class_for_shell_uses_command_args() {
+ let security = SecurityPolicy::default();
+ let shell = PolicyTool {
+ name: "shell",
+ permission: PermissionLevel::Execute,
+ external_effect: true,
+ };
+
+ assert_eq!(
+ command_class_for_tool(&security, &shell, &json!({"command": "ls src"})),
+ CommandClass::Read
+ );
+ assert_eq!(
+ command_class_for_tool(&security, &shell, &json!({"command": "touch out.txt"})),
+ CommandClass::Write
+ );
+ assert_eq!(
+ command_class_for_tool(
+ &security,
+ &shell,
+ &json!({"command": "curl https://example.com"})
+ ),
+ CommandClass::Network
+ );
+ assert_eq!(
+ command_class_for_tool(
+ &security,
+ &shell,
+ &json!({"command": "cat Cargo.toml", "category": "destructive"})
+ ),
+ CommandClass::Destructive
+ );
+ }
}
diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs
index dbe51f63f..c2ae1bb48 100644
--- a/src/openhuman/tinyflows/caps.rs
+++ b/src/openhuman/tinyflows/caps.rs
@@ -198,12 +198,60 @@ fn escalated_origin_for_prompt(
}
}
+/// Returns true when an agent-node completion `request` asked for structured
+/// output: an `output_parser.schema` is configured on the node, or the config
+/// sets `response_format: "json"`.
+///
+/// This is the host-side contract for **agent → tool wiring**: downstream
+/// `=item.` bindings only work when the agent's emitted item is a
+/// structured object, so an agent feeding a `tool_call` should declare an
+/// output schema (or `response_format: "json"`).
+fn structured_output_requested(request: &Value) -> bool {
+ let has_schema = request
+ .get("output_parser")
+ .and_then(|p| p.get("schema"))
+ .is_some_and(|s| !s.is_null());
+ let json_format = request.get("response_format").and_then(Value::as_str) == Some("json");
+ has_schema || json_format
+}
+
+/// Best-effort parse of an LLM completion as structured JSON.
+///
+/// Accepts a bare JSON object/array or one wrapped in a markdown code fence
+/// (```json … ``` or ``` … ```). Returns `None` for anything that doesn't
+/// parse to an object or array — scalars pass through the legacy `{text}`
+/// shape instead, since `item.` addressing is meaningless on them.
+pub(crate) fn parse_llm_json(text: &str) -> Option {
+ let trimmed = text.trim();
+ let candidate = match trimmed.strip_prefix("```") {
+ Some(rest) => {
+ let rest = rest.strip_prefix("json").unwrap_or(rest);
+ match rest.rsplit_once("```") {
+ Some((inner, _)) => inner.trim(),
+ None => trimmed,
+ }
+ }
+ None => trimmed,
+ };
+ let parsed = serde_json::from_str::(candidate).ok()?;
+ matches!(parsed, Value::Object(_) | Value::Array(_)).then_some(parsed)
+}
+
/// [`LlmProvider`] adapter over OpenHuman's inference stack
/// (`src/openhuman/inference/provider/`).
///
/// The `agent` node is single-completion in tinyflows 0.2 (no tool-calling
/// loop, no sub-ports), so `complete` performs exactly one `provider.chat`
/// call and returns its result — no agent loop is driven here.
+///
+/// **Structured output**: when the node requested it (an
+/// `output_parser.schema` or `response_format: "json"` in the config), the
+/// completion text is parsed as JSON and the **parsed object** is returned as
+/// the response value — so a downstream node can bind `=item.` (or
+/// `=nodes..item.`) instead of receiving an opaque
+/// `{text: "..."}` blob. A completion that doesn't parse falls back to the
+/// legacy shape, where the agent node's `output_parser` sub-port can still
+/// coerce it via the schema auto-fix path.
pub struct OpenHumanLlm {
pub config: Arc,
}
@@ -230,7 +278,10 @@ impl LlmProvider for OpenHumanLlm {
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok());
- let messages: Vec = match request.get("messages").and_then(Value::as_array) {
+ let structured = structured_output_requested(&request);
+
+ let mut messages: Vec = match request.get("messages").and_then(Value::as_array)
+ {
Some(entries) if !entries.is_empty() => entries
.iter()
.filter_map(|entry| {
@@ -253,10 +304,29 @@ impl LlmProvider for OpenHumanLlm {
}
};
+ // Structured mode: steer the model toward parseable JSON. The schema
+ // (when configured) rides along so the model knows the exact shape.
+ if structured {
+ let mut instruction = "Respond with a single JSON object only — no prose, no \
+ markdown code fences."
+ .to_string();
+ if let Some(schema) = request
+ .get("output_parser")
+ .and_then(|p| p.get("schema"))
+ .filter(|s| !s.is_null())
+ {
+ instruction.push_str(&format!(
+ " The object must match this JSON Schema:\n{schema}"
+ ));
+ }
+ messages.insert(0, ChatMessage::system(instruction));
+ }
+
tracing::debug!(
target: "flows",
role,
message_count = messages.len(),
+ structured,
"[flows] llm.complete: dispatching agent-node completion"
);
@@ -277,6 +347,26 @@ impl LlmProvider for OpenHumanLlm {
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
+ // Structured mode: surface the parsed object itself so downstream
+ // `=item.` / `=nodes..item.` bindings work. The
+ // agent node's output_parser sub-port then validates it against the
+ // configured schema (and auto-fixes when it doesn't parse here).
+ if structured {
+ if let Some(parsed) = response.text.as_deref().and_then(parse_llm_json) {
+ tracing::debug!(
+ target: "flows",
+ "[flows] llm.complete: structured output parsed from completion text"
+ );
+ return Ok(parsed);
+ }
+ tracing::warn!(
+ target: "flows",
+ "[flows] llm.complete: structured output requested but the completion did not \
+ parse as JSON — falling back to the {{text}} shape (the output_parser sub-port \
+ may still coerce it)"
+ );
+ }
+
Ok(json!({
"text": response.text,
"tool_calls": response.tool_calls,
@@ -542,9 +632,220 @@ pub struct OpenHumanTools {
pub config: Arc,
}
+/// Prefix marking a `tool_call` node's slug as a NATIVE OpenHuman tool (the
+/// "Tool" node) rather than a Composio action (the "App action" node). e.g.
+/// `oh:web_search`. Native tools run through the same agent tool registry the
+/// assistant uses (`runtime_node::ops::execute_tool`), so a flow can call
+/// search / media generation / file / shell / etc. — the full toolset.
+pub(crate) const NATIVE_TOOL_PREFIX: &str = "oh:";
+
+/// Process-level cache for [`composio_required_args`]: toolkit → (uppercase
+/// action slug → required top-level arg names). One `list_tools` fetch per
+/// toolkit per process; schemas are effectively static within a session.
+static REQUIRED_ARGS_CACHE: std::sync::OnceLock<
+ std::sync::Mutex<
+ std::collections::HashMap>>,
+ >,
+> = std::sync::OnceLock::new();
+
+/// Seeds the required-args cache for a toolkit — test hook so preflight
+/// behavior can be exercised without a live Composio backend.
+#[cfg(test)]
+pub(crate) fn seed_required_args_cache(
+ toolkit: &str,
+ entries: std::collections::HashMap>,
+) {
+ REQUIRED_ARGS_CACHE
+ .get_or_init(Default::default)
+ .lock()
+ .expect("required-args cache poisoned")
+ .insert(toolkit.to_string(), entries);
+}
+
+/// Best-effort lookup of a Composio action's **required** top-level parameter
+/// names, from the toolkit's tool schemas (`parameters.required`).
+///
+/// Returns `None` when the schema is unavailable — unknown toolkit, client
+/// construction failure, or a failed/empty listing — so callers can skip the
+/// preflight rather than block execution on a catalog hiccup. Results are
+/// cached per toolkit for the life of the process.
+pub(crate) async fn composio_required_args(config: &Config, slug: &str) -> Option> {
+ let toolkit = crate::openhuman::memory_sync::composio::providers::toolkit_from_slug(slug)?;
+ let slug_key = slug.to_ascii_uppercase();
+
+ if let Some(by_slug) = REQUIRED_ARGS_CACHE
+ .get_or_init(Default::default)
+ .lock()
+ .ok()?
+ .get(&toolkit)
+ {
+ return by_slug.get(&slug_key).cloned();
+ }
+
+ tracing::debug!(target: "flows", %toolkit, %slug, "[flows] preflight: fetching tool schemas for toolkit");
+ let resp = crate::openhuman::composio::ops::composio_list_tools(
+ config,
+ Some(vec![toolkit.clone()]),
+ None,
+ )
+ .await
+ .map_err(|e| {
+ tracing::debug!(target: "flows", %toolkit, error = %e, "[flows] preflight: schema fetch failed — skipping check");
+ e
+ })
+ .ok()?;
+
+ let mut by_slug = std::collections::HashMap::new();
+ for tool in &resp.value.tools {
+ let required: Vec = tool
+ .function
+ .parameters
+ .as_ref()
+ .and_then(|p| p.get("required"))
+ .and_then(Value::as_array)
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+ by_slug.insert(tool.function.name.to_ascii_uppercase(), required);
+ }
+ let found = by_slug.get(&slug_key).cloned();
+ if let Ok(mut cache) = REQUIRED_ARGS_CACHE.get_or_init(Default::default).lock() {
+ cache.insert(toolkit, by_slug);
+ }
+ found
+}
+
+/// Returns the names in `required` that are absent or `null` in `args`.
+pub(crate) fn missing_required_args(required: &[String], args: &Value) -> Vec {
+ required
+ .iter()
+ .filter(|name| match args.get(name.as_str()) {
+ None => true,
+ Some(v) => v.is_null(),
+ })
+ .cloned()
+ .collect()
+}
+
+/// Required-arg preflight for a Composio `tool_call`: fails **before** the
+/// Composio dispatch when a required arg is missing or resolved to `null`,
+/// with a message that names the field and the likely fix — instead of letting
+/// the raw provider error surface from deep inside the call.
+///
+/// Best-effort by design: when the action's schema cannot be looked up the
+/// check is skipped (never blocks on catalog availability).
+pub(crate) async fn preflight_composio_args(
+ config: &Config,
+ slug: &str,
+ args: &Value,
+) -> Result<()> {
+ let Some(required) = composio_required_args(config, slug).await else {
+ tracing::debug!(target: "flows", %slug, "[flows] preflight: no schema for action — skipping required-arg check");
+ return Ok(());
+ };
+ let missing = missing_required_args(&required, args);
+ if missing.is_empty() {
+ tracing::debug!(target: "flows", %slug, "[flows] preflight: all required args present");
+ return Ok(());
+ }
+ tracing::warn!(target: "flows", %slug, ?missing, "[flows] preflight: required arg(s) missing or null — failing before dispatch");
+ let list = missing
+ .iter()
+ .map(|m| format!("`{m}`"))
+ .collect::>()
+ .join(", ");
+ let first = &missing[0];
+ Err(EngineError::Capability(format!(
+ "tool_call `{slug}`: required arg(s) {list} missing or resolved to null — wire each from \
+ an upstream node's output, e.g. \"{first}\": \"=nodes..item.\". If the \
+ value comes from an agent node, give that agent an output schema \
+ (config.output_parser.schema) so its fields are addressable."
+ )))
+}
+
+/// A [`ToolInvoker`] decorator that runs the host's Composio required-arg
+/// preflight before delegating to `inner`.
+///
+/// Used by `dry_run_workflow`: the dry-run path executes against tinyflows'
+/// echo mocks, which would happily accept a `null` required arg — wrapping
+/// the mock invoker with this makes the wiring check actually check wiring,
+/// so an unwired required arg fails the dry run with the same actionable
+/// message a real run would produce.
+pub struct PreflightToolInvoker {
+ /// Host config, for the Composio schema lookup.
+ pub config: Arc,
+ /// The delegate that performs the actual invocation (e.g. the mock).
+ pub inner: Arc,
+}
+
+#[async_trait]
+impl ToolInvoker for PreflightToolInvoker {
+ async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result {
+ if !slug.starts_with(NATIVE_TOOL_PREFIX) {
+ preflight_composio_args(&self.config, slug, &args).await?;
+ }
+ self.inner.invoke(slug, args, conn).await
+ }
+}
+
#[async_trait]
impl ToolInvoker for OpenHumanTools {
async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result {
+ // Native OpenHuman tool path (the "Tool" node): `oh:`. Bypasses
+ // the Composio curation gate (it isn't a Composio slug) but still runs
+ // through the autonomy-tier + approval gates, then dispatches to the
+ // agent tool registry.
+ if let Some(tool_name) = slug.strip_prefix(NATIVE_TOOL_PREFIX) {
+ let tool_name = tool_name.trim();
+ if tool_name.is_empty() {
+ return Err(EngineError::Capability(
+ "tool_call node: native tool slug is empty (expected `oh:`)"
+ .to_string(),
+ ));
+ }
+
+ let security = SecurityPolicy::from_config(
+ &self.config.autonomy,
+ &self.config.workspace_dir,
+ &self.config.action_dir,
+ );
+ let class = crate::openhuman::runtime_node::ops::classify_tool_call(
+ &self.config,
+ tool_name,
+ &args,
+ )
+ .map_err(EngineError::Capability)?;
+ let tier_decision = enforce_node_tier_gate(&security, class, "tool_call")?;
+ let summary = crate::openhuman::approval::summarize_action(tool_name, &args);
+ let redacted = crate::openhuman::approval::redact_args(&args);
+ let (outcome, _request_id) =
+ gate_call_for_tier(tier_decision, tool_name, &summary, redacted).await;
+ if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome {
+ return Err(EngineError::Capability(reason));
+ }
+ tracing::debug!(
+ target: "flows",
+ %tool_name,
+ ?class,
+ ?tier_decision,
+ "[flows] tool_call: dispatching NATIVE OpenHuman tool"
+ );
+ let outcome = crate::openhuman::runtime_node::ops::execute_tool(
+ &self.config,
+ tool_name,
+ args,
+ false,
+ )
+ .await
+ .map_err(EngineError::Capability)?;
+ return serde_json::to_value(&outcome.result).map_err(|e| {
+ EngineError::Capability(format!("could not serialize tool result: {e}"))
+ });
+ }
+
// Curation + scope gate — hard allowlist (see [`is_curated_flow_tool`]'s
// doc for why this differs from the general agent tool-call path).
// Runs before anything else — a rejected slug never reaches the
@@ -561,6 +862,12 @@ impl ToolInvoker for OpenHumanTools {
)));
}
+ // Required-arg preflight — fail with an actionable, field-naming error
+ // BEFORE the approval gate and the Composio dispatch, so a mis-wired
+ // arg (`=`-expression that resolved to null) never reaches the
+ // provider or asks the user to approve a call that cannot succeed.
+ preflight_composio_args(&self.config, slug, &args).await?;
+
// Approval gate (see the struct doc). Mirrors
// `tinyagents/middleware.rs::ApprovalSecurityMiddleware::wrap_tool`'s
// shape exactly: compute summary/redacted args only when a gate is
diff --git a/src/openhuman/tinyflows/observability.rs b/src/openhuman/tinyflows/observability.rs
index 784e8d5a6..c35970a3f 100644
--- a/src/openhuman/tinyflows/observability.rs
+++ b/src/openhuman/tinyflows/observability.rs
@@ -125,12 +125,27 @@ impl RunObserver for FlowRunObserver {
// Persist the live step. `duration_ms` is `u128` on the engine side;
// clamp into `u64` (a node executor taking > 584 million years is not a
// real concern, but never panic on cast).
+ if !step.diagnostics.is_empty() {
+ tracing::warn!(
+ target: "flows",
+ flow_id = %self.flow_id,
+ run_id = %self.run_id,
+ node = %step.node_id,
+ diagnostics = ?step.diagnostics,
+ "[flows] observer: step reported null-resolved expression(s) — persisting for the run view"
+ );
+ }
let flow_step = FlowRunStep {
node_id: step.node_id.clone(),
output: step.output.clone(),
port: None,
status: Some(status.to_string()),
duration_ms: Some(u64::try_from(step.duration_ms).unwrap_or(u64::MAX)),
+ diagnostics: step
+ .diagnostics
+ .iter()
+ .map(|d| serde_json::to_value(d).unwrap_or(serde_json::Value::Null))
+ .collect(),
};
if let Err(e) = upsert_flow_run_step(&self.config, &self.run_id, &flow_step) {
tracing::warn!(
@@ -191,6 +206,7 @@ mod tests {
status: StepStatus::Success,
output: Value::Null,
duration_ms: 5,
+ diagnostics: Vec::new(),
});
observer.on_run_finish(&Run {
id: "run-1".to_string(),
diff --git a/src/openhuman/tinyflows/tests.rs b/src/openhuman/tinyflows/tests.rs
index 8c1c2e8aa..eb89d5742 100644
--- a/src/openhuman/tinyflows/tests.rs
+++ b/src/openhuman/tinyflows/tests.rs
@@ -354,3 +354,137 @@ fn http_cred_name_returns_none_for_non_http_cred_ref_or_empty_name() {
assert_eq!(super::caps::http_cred_name("composio:slack:acct_1"), None);
assert_eq!(super::caps::http_cred_name("http_cred:"), None);
}
+
+// ── structured agent output (parse_llm_json) ────────────────────────────
+
+#[test]
+fn parse_llm_json_accepts_bare_and_fenced_objects() {
+ let obj = super::caps::parse_llm_json(r#"{ "to": "a@b.com", "subject": "hi" }"#)
+ .expect("bare object parses");
+ assert_eq!(obj["to"], "a@b.com");
+
+ let fenced = "```json\n{ \"to\": \"a@b.com\" }\n```";
+ let obj = super::caps::parse_llm_json(fenced).expect("fenced object parses");
+ assert_eq!(obj["to"], "a@b.com");
+
+ let fenced_plain = "```\n[1, 2]\n```";
+ assert_eq!(
+ super::caps::parse_llm_json(fenced_plain),
+ Some(serde_json::json!([1, 2]))
+ );
+}
+
+#[test]
+fn parse_llm_json_rejects_prose_and_scalars() {
+ // Prose is not JSON.
+ assert_eq!(super::caps::parse_llm_json("Sure! Here's the email."), None);
+ // Scalars parse as JSON but are not addressable — legacy shape instead.
+ assert_eq!(super::caps::parse_llm_json("42"), None);
+ assert_eq!(super::caps::parse_llm_json("\"just a string\""), None);
+}
+
+// ── tool_call required-arg preflight ─────────────────────────────────────
+
+#[test]
+fn missing_required_args_flags_absent_and_null() {
+ let required = vec!["to".to_string(), "subject".to_string(), "body".to_string()];
+ let args = json!({ "to": null, "subject": "hi" });
+ assert_eq!(
+ super::caps::missing_required_args(&required, &args),
+ vec!["to".to_string(), "body".to_string()]
+ );
+ let full = json!({ "to": "a@b.com", "subject": "hi", "body": "text" });
+ assert!(super::caps::missing_required_args(&required, &full).is_empty());
+}
+
+#[tokio::test]
+async fn preflight_fails_before_dispatch_naming_the_missing_field() {
+ let tmp = TempDir::new().unwrap();
+ let config = test_config(&tmp);
+ // Seed the schema cache so no live Composio backend is needed.
+ let mut entries = std::collections::HashMap::new();
+ entries.insert(
+ "GMAIL_SEND_EMAIL".to_string(),
+ vec!["to".to_string(), "subject".to_string(), "body".to_string()],
+ );
+ super::caps::seed_required_args_cache("gmail", entries);
+
+ // `to` resolved to null (the classic mis-wired agent → tool_call case).
+ let err = super::caps::preflight_composio_args(
+ &config,
+ "GMAIL_SEND_EMAIL",
+ &json!({ "to": null, "subject": "hi", "body": "text" }),
+ )
+ .await
+ .expect_err("null required arg must fail preflight");
+ let msg = err.to_string();
+ assert!(msg.contains("`to`"), "error must name the field: {msg}");
+ assert!(
+ msg.contains("=nodes..item."),
+ "error must suggest the wiring fix: {msg}"
+ );
+ assert!(
+ msg.contains("output schema"),
+ "error must mention the agent output schema rule: {msg}"
+ );
+
+ // Fully-wired args pass.
+ super::caps::preflight_composio_args(
+ &config,
+ "GMAIL_SEND_EMAIL",
+ &json!({ "to": "a@b.com", "subject": "hi", "body": "text" }),
+ )
+ .await
+ .expect("wired args must pass preflight");
+}
+
+#[tokio::test]
+async fn preflight_skips_when_no_schema_is_available() {
+ let tmp = TempDir::new().unwrap();
+ let config = test_config(&tmp);
+ // A slug whose toolkit is unknown to the curation catalog has no schema
+ // source at all — the preflight must skip, never block.
+ super::caps::preflight_composio_args(&config, "NOT_A_REAL_TOOLKIT_ACTION", &json!({}))
+ .await
+ .expect("preflight must be best-effort when no schema is available");
+}
+
+#[tokio::test]
+async fn preflight_invoker_gates_the_mock_tool_path() {
+ use tinyflows::caps::ToolInvoker as _;
+
+ let tmp = TempDir::new().unwrap();
+ let config = test_config(&tmp);
+ let mut entries = std::collections::HashMap::new();
+ entries.insert("GMAIL_SEND_EMAIL".to_string(), vec!["to".to_string()]);
+ super::caps::seed_required_args_cache("gmail", entries);
+
+ let mock = tinyflows::caps::mock::mock_capabilities();
+ let invoker = super::caps::PreflightToolInvoker {
+ config,
+ inner: mock.tools.clone(),
+ };
+
+ // Unwired required arg: fails with the named field even though the inner
+ // mock would echo anything.
+ let err = invoker
+ .invoke("GMAIL_SEND_EMAIL", json!({ "to": null }), None)
+ .await
+ .expect_err("dry-run preflight must catch the unwired arg");
+ assert!(err.to_string().contains("`to`"));
+
+ // Wired arg: delegates to the mock echo.
+ let ok = invoker
+ .invoke("GMAIL_SEND_EMAIL", json!({ "to": "a@b.com" }), None)
+ .await
+ .expect("wired arg passes through to the mock");
+ assert_eq!(ok["tool"], "GMAIL_SEND_EMAIL");
+
+ // Native `oh:` slugs bypass the Composio preflight (no Composio schema).
+ // The mock echoes them unchecked.
+ let ok = invoker
+ .invoke("oh:web_search", json!({}), None)
+ .await
+ .expect("native slug bypasses composio preflight");
+ assert_eq!(ok["tool"], "oh:web_search");
+}
diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs
index 0435ccb8c..3b6f1aa03 100644
--- a/src/openhuman/tools/ops.rs
+++ b/src/openhuman/tools/ops.rs
@@ -286,7 +286,11 @@ pub fn all_tools_with_runtime(
Box::new(GetFlowRunTool::new(config.clone())),
Box::new(ListFlowConnectionsTool::new(config.clone())),
Box::new(SearchToolCatalogTool::new()),
- Box::new(DryRunWorkflowTool::new(security.clone())),
+ Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())),
+ // Real end-to-end test run of a SAVED flow (Write / external-effect). The
+ // workflow-builder prompt requires it to ask the user for confirmation
+ // first, and the flow's own approval gate still pauses outbound nodes.
+ Box::new(RunFlowTool::new(config.clone())),
// Flow Scout discovery: the `flow_discovery` agent's terminal emit
// sink. Read-only reasoning over the user's data ends by calling
// `suggest_workflows`, which persists workflow ideas for the Flows page
diff --git a/vendor/tinyflows b/vendor/tinyflows
index 01fe8b079..52209b50f 160000
--- a/vendor/tinyflows
+++ b/vendor/tinyflows
@@ -1 +1 @@
-Subproject commit 01fe8b079e8b7df94594b9d9862d35ce9b404ac2
+Subproject commit 52209b50f108dc668a5ca1cc74f791ee3a718337
diff --git a/vendor/tinyjuice b/vendor/tinyjuice
index ee6cd5ed8..41b5ca87c 160000
--- a/vendor/tinyjuice
+++ b/vendor/tinyjuice
@@ -1 +1 @@
-Subproject commit ee6cd5ed8af044287afbcca4efa0533258a54efa
+Subproject commit 41b5ca87c4af786f908beb66eec1aae3df25fbdd