diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 383877afe..9c6ba1015 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -11,8 +11,9 @@ Reference frontend for the Workflow Builder AI Studio product. Consumes `@workfl A complete, runnable AI workflow product built on top of the Workflow Builder SDK. It demonstrates: - Connecting to the reference Hono backend over HTTP + Server-Sent Events -- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`) -- Live execution UI: Play/Stop controls, log panel, per-node status markers, edge highlighting, node-detail overlay +- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`, `ai-studio/human-decision`, `ai-studio/visualize`) +- A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port. +- Live execution UI: Play/Stop controls, log panel, per-node status markers (including a waiting marker), edge highlighting, node-detail overlay This is a sibling to `apps/demo`, not a layer over it. They share the SDK; nothing else. diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index df5548fbf..e82564ad6 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -12,11 +12,16 @@ import { ExecutionLogPanel } from '../components/execution/log-panel'; import { aiStudioTemplates } from '../data/ai-studio-templates'; import { aiStudioNodeTypes } from '../data/node-types'; import { supportTriageFlow } from '../data/support-triage-flow'; +import { humanDecisionNodeType } from '../nodes/human-decision'; +import { HumanDecisionNodeTemplate } from '../nodes/human-decision/human-decision-template'; import { plugin as aiStudioFeaturesPlugin } from '../plugin'; import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports'; const flagship = supportTriageFlow.value; +// Module-level: `nodeTemplates` must keep the same reference across renders. +const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate }; + // A start node is where the run begins, so it can never be a connection target. const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode; @@ -30,6 +35,7 @@ export function App() { initialNodes={flagship.diagram.nodes} initialEdges={flagship.diagram.edges} nodeTypes={aiStudioNodeTypes} + nodeTemplates={nodeTemplates} diagramTemplates={aiStudioTemplates} isValidConnection={isValidConnection} plugins={[aiStudioFeaturesPlugin, undoRedoPlugin]} diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx new file mode 100644 index 000000000..12459cb9f --- /dev/null +++ b/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx @@ -0,0 +1,60 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events'; + +import { applySnapshot, resetExecution } from '../../stores/use-execution-store'; +import { AiStudioControls } from './ai-studio-controls'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +vi.mock('../../hooks/use-has-start-node', () => ({ useHasStartNode: () => true })); + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function setRunStatus(status: ExecutionStatus) { + act(() => applySnapshot({ executionId: 'exec-1', status, lastSequence: 0, events: [] })); +} + +describe('AiStudioControls', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + resetExecution(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + act(() => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const icons = () => [...container.querySelectorAll('[data-icon]')].map((icon) => icon.dataset['icon']); + + it('offers Stop while the run waits for a decision, the same as while it runs', () => { + setRunStatus('running'); + expect(icons()).toEqual(['Stop']); + + setRunStatus('waiting'); + expect(icons()).toEqual(['Stop']); + }); + + it('offers Play and Reset once the run has ended', () => { + setRunStatus('waiting'); + setRunStatus('completed'); + + expect(icons()).toEqual(['Play', 'ArrowCounterClockwise']); + }); +}); diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx index 45133ba32..0371c6232 100644 --- a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx +++ b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx @@ -27,7 +27,7 @@ export function AiStudioControls() { } }, [executeFromCanvas]); - const isRunning = status === 'pending' || status === 'running'; + const isRunning = status === 'pending' || status === 'running' || status === 'waiting'; const isDone = status === 'completed' || status === 'incomplete' || status === 'failed' || status === 'cancelled'; return ( diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css index f485e2779..f714abda1 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -11,8 +11,10 @@ html[data-theme='dark'] { :root { --ai-studio-status-color--completed: var(--ax-txt-success-default); + --ai-studio-status-color--waiting: var(--ax-txt-info-default); --ai-studio-status-color--failed: var(--ax-txt-error-default); --ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%); + --ai-studio-status-bg--waiting: color-mix(in srgb, var(--ai-studio-status-color--waiting), transparent 85%); --ai-studio-status-bg--incomplete: color-mix(in srgb, var(--ai-studio-status-color--incomplete), transparent 85%); --ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%); diff --git a/apps/ai-studio/src/components/execution/highlighting.test.tsx b/apps/ai-studio/src/components/execution/highlighting.test.tsx new file mode 100644 index 000000000..cffd3ffc8 --- /dev/null +++ b/apps/ai-studio/src/components/execution/highlighting.test.tsx @@ -0,0 +1,56 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; + +import { applyEvent, resetExecution, setExecutionStarted } from '../../stores/use-execution-store'; +import { ExecutionHighlighting } from './highlighting'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getStoreEdges: () => [] }; +}); + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const waiting = (nodeId: string): ExecutionEvent => ({ + executionId: 'exec-1', + sequence: 1, + timestamp: '2026-09-15T12:00:00.000Z', + type: 'node_waiting', + nodeId, +}); + +describe('ExecutionHighlighting', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + resetExecution(); + setExecutionStarted('exec-1', '/stream'); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.querySelector('#us-css-ai-studio-highlighting')?.remove(); + }); + + it('keeps the active shadow on a node that waits for a decision', () => { + act(() => root.render()); + act(() => applyEvent(waiting('human-1'))); + + const css = document.querySelector('#us-css-ai-studio-highlighting')?.innerHTML ?? ''; + const activeRule = css.split('}').find((rule) => rule.includes('[data-id="human-1"]')); + + expect(activeRule).toContain('--ai-studio-node-shadow--active'); + }); +}); diff --git a/apps/ai-studio/src/components/execution/highlighting.tsx b/apps/ai-studio/src/components/execution/highlighting.tsx index b28db5ec8..03790dbb3 100644 --- a/apps/ai-studio/src/components/execution/highlighting.tsx +++ b/apps/ai-studio/src/components/execution/highlighting.tsx @@ -38,7 +38,8 @@ export function ExecutionHighlighting() { } switch (state.status) { - case 'running': { + case 'running': + case 'waiting': { byStatus.running.push(nodeId); break; } diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 22b063107..e36988444 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -94,6 +94,10 @@ color: var(--ax-txt-secondary-default, #888); } +.badge--node_waiting { + color: var(--ai-studio-status-color--waiting); +} + .badge--execution_incomplete { color: var(--ai-studio-status-color--incomplete); } @@ -163,6 +167,11 @@ background: var(--ai-studio-status-bg--completed); } +.status--waiting { + color: var(--ai-studio-status-color--waiting); + background: var(--ai-studio-status-bg--waiting); +} + .status--incomplete { color: var(--ai-studio-status-color--incomplete); background: var(--ai-studio-status-bg--incomplete); diff --git a/apps/ai-studio/src/components/execution/node-markers.module.css b/apps/ai-studio/src/components/execution/node-markers.module.css index 2d2711449..e238d745a 100644 --- a/apps/ai-studio/src/components/execution/node-markers.module.css +++ b/apps/ai-studio/src/components/execution/node-markers.module.css @@ -51,6 +51,10 @@ animation: spin 1.6s linear infinite; } +.icon--waiting { + color: var(--ai-studio-status-color--waiting); +} + @keyframes spin { from { transform: rotate(0deg); diff --git a/apps/ai-studio/src/components/execution/node-markers.test.tsx b/apps/ai-studio/src/components/execution/node-markers.test.tsx new file mode 100644 index 000000000..379ffe449 --- /dev/null +++ b/apps/ai-studio/src/components/execution/node-markers.test.tsx @@ -0,0 +1,86 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; + +import styles from './node-markers.module.css'; + +import { + applyEvent, + resetExecution, + setExecutionStarted, + setLogCollapsed, + useExecutionStore, +} from '../../stores/use-execution-store'; +import { ExecutionNodeMarkers } from './node-markers'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function nodeEvent(type: 'node_started' | 'node_waiting' | 'node_completed', nodeId: string): ExecutionEvent { + const base = { executionId: 'exec-1', sequence: 1, timestamp: '2026-09-15T12:00:00.000Z', nodeId }; + return (type === 'node_completed' ? { ...base, type, payload: { output: {} } } : { ...base, type }) as ExecutionEvent; +} + +describe('ExecutionNodeMarkers', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + resetExecution(); + setExecutionStarted('exec-1', '/stream'); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + function render(nodeId: string) { + act(() => root.render()); + } + + function click() { + act(() => { + container.firstElementChild?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + } + + it('shows an hourglass for a waiting node and does not open the log on click', () => { + act(() => applyEvent(nodeEvent('node_waiting', 'human-1'))); + setLogCollapsed(true); + + render('human-1'); + + expect(container.querySelector('[data-icon="HourglassMedium"]')).not.toBeNull(); + expect(container.firstElementChild?.classList.contains(styles['container--clickable']!)).toBe(false); + + click(); + expect(useExecutionStore.getState().isLogCollapsed).toBe(true); + }); + + it('swaps the hourglass for the completed flag once the decision lands, and that one opens the log', () => { + act(() => applyEvent(nodeEvent('node_waiting', 'human-1'))); + render('human-1'); + act(() => applyEvent(nodeEvent('node_completed', 'human-1'))); + setLogCollapsed(true); + + expect(container.querySelector('[data-icon="HourglassMedium"]')).toBeNull(); + expect(container.querySelector('[data-icon="FlagBannerFold"]')).not.toBeNull(); + + click(); + expect(useExecutionStore.getState().isLogCollapsed).toBe(false); + }); +}); diff --git a/apps/ai-studio/src/components/execution/node-markers.tsx b/apps/ai-studio/src/components/execution/node-markers.tsx index d760aa9b6..647f0a576 100644 --- a/apps/ai-studio/src/components/execution/node-markers.tsx +++ b/apps/ai-studio/src/components/execution/node-markers.tsx @@ -31,6 +31,11 @@ export function ExecutionNodeMarkers({ props }: Props) { )} + {nodeState.status === 'waiting' && ( + + + + )} {nodeState.status === 'completed' && ( diff --git a/apps/ai-studio/src/data/ai-studio-templates.ts b/apps/ai-studio/src/data/ai-studio-templates.ts index a603b4fe7..e71c0effc 100644 --- a/apps/ai-studio/src/data/ai-studio-templates.ts +++ b/apps/ai-studio/src/data/ai-studio-templates.ts @@ -3,6 +3,7 @@ import type { TemplateModel } from '@workflowbuilder/sdk'; import { aiDebateFlow } from './ai-debate-flow'; import { contentRepurposerFlow } from './content-repurposer-flow'; import { meetingNotesFlow } from './meeting-notes-flow'; +import { refundReviewFlow } from './refund-review-flow'; import { researchFlow } from './research-flow'; import { supportTriageFlow } from './support-triage-flow'; @@ -12,4 +13,5 @@ export const aiStudioTemplates: TemplateModel[] = [ contentRepurposerFlow, meetingNotesFlow, researchFlow, + refundReviewFlow, ]; diff --git a/apps/ai-studio/src/data/node-types.ts b/apps/ai-studio/src/data/node-types.ts index 48ad1faef..5143bb571 100644 --- a/apps/ai-studio/src/data/node-types.ts +++ b/apps/ai-studio/src/data/node-types.ts @@ -2,6 +2,7 @@ import type { PaletteItemOrGroup } from '@workflowbuilder/sdk'; import { aiAgentPaletteItem } from '../nodes/ai-agent'; import { decisionPaletteItem } from '../nodes/decision'; +import { humanDecisionPaletteItem } from '../nodes/human-decision'; import { triggerPaletteItem } from '../nodes/trigger'; import { visualizePaletteItem } from '../nodes/visualize'; @@ -9,6 +10,12 @@ export const aiStudioNodeTypes: PaletteItemOrGroup[] = [ { label: 'AI Studio', isOpen: true, - groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem, visualizePaletteItem], + groupItems: [ + triggerPaletteItem, + aiAgentPaletteItem, + decisionPaletteItem, + humanDecisionPaletteItem, + visualizePaletteItem, + ], }, ]; diff --git a/apps/ai-studio/src/data/refund-review-flow.test.ts b/apps/ai-studio/src/data/refund-review-flow.test.ts new file mode 100644 index 000000000..bd0fa52a9 --- /dev/null +++ b/apps/ai-studio/src/data/refund-review-flow.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; + +import { humanDecisionNodeType } from '../nodes/human-decision'; +import { defaultDecisionRequest } from '../nodes/human-decision/default-properties-data'; +import { aiStudioTemplates } from './ai-studio-templates'; +import { refundReviewFlow, refundReviewRequest } from './refund-review-flow'; + +const { nodes, edges } = refundReviewFlow.value.diagram; +const human = nodes.find((node) => node.id === 'human-1')!; +const edgesInto = (nodeId: string) => edges.filter((edge) => edge.target === nodeId); +const edgesOutOf = (nodeId: string) => edges.filter((edge) => edge.source === nodeId); +const ports = (request: DecisionRequest) => + request.actions.flatMap((action) => ('port' in action ? [action.port] : [])); + +describe('refundReviewFlow', () => { + it('is registered once among the AI Studio templates, under an id no other template uses', () => { + const ids = aiStudioTemplates.map((template) => template.id); + + expect(aiStudioTemplates.filter((template) => template === refundReviewFlow)).toHaveLength(1); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('renders the human-decision node with its own template: the React Flow type is the palette type', () => { + expect(human.type).toBe(humanDecisionNodeType); + expect(human.data.type).toBe(humanDecisionNodeType); + expect(human.data.properties['decisionRequest']).toBe(refundReviewRequest); + }); + + it('keeps the preset actions and ports, adding only the refund form', () => { + expect(refundReviewRequest.actions).toEqual(defaultDecisionRequest.actions); + expect(refundReviewRequest.version).toBe(1); + expect(refundReviewRequest.schema.properties.orderDate.readOnly).toBe(true); + expect(refundReviewRequest.schema.required).toEqual(['refundAmount']); + expect(refundReviewRequest).not.toHaveProperty('proposalSourceNodeId'); + }); + + it('gives the deciding node exactly one predecessor, as publishing requires', () => { + expect(edgesInto('human-1').map((edge) => edge.source)).toEqual(['draft-1']); + }); + + it('draws one edge per action port and none from a port the request does not offer', () => { + const handles = edgesOutOf('human-1').map((edge) => edge.sourceHandle); + + expect([...handles].sort()).toEqual([...ports(refundReviewRequest)].sort()); + }); + + it('connects every edge to nodes that exist', () => { + const ids = new Set(nodes.map((node) => node.id)); + + for (const edge of edges) { + expect(ids.has(edge.source), edge.id).toBe(true); + expect(ids.has(edge.target), edge.id).toBe(true); + } + }); +}); diff --git a/apps/ai-studio/src/data/refund-review-flow.ts b/apps/ai-studio/src/data/refund-review-flow.ts new file mode 100644 index 000000000..64c40cda4 --- /dev/null +++ b/apps/ai-studio/src/data/refund-review-flow.ts @@ -0,0 +1,206 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; + +import { humanDecisionNodeType } from '../nodes/human-decision'; +import { defaultDecisionRequest } from '../nodes/human-decision/default-properties-data'; + +const REFUND_CONTEXT = `You work in customer support for Lumen, a SaaS analytics product. + +Refund policy: a duplicate charge is refunded in full; an unused month on the Pro plan ($49 / month) +is refunded pro rata; refunds go back to the original card within 5 to 10 business days. +Style: empathetic, concise, no promises the team cannot keep.`; + +// The palette preset with the refund form on top: the amount may be corrected, the order date may not. +export const refundReviewRequest = { + ...defaultDecisionRequest, + schema: { + type: 'object', + properties: { + refundAmount: { type: 'number' }, + orderDate: { type: 'string', readOnly: true }, + note: { type: 'string' }, + }, + required: ['refundAmount'], + }, +} satisfies DecisionRequest; + +const diagram: DiagramModel = { + name: 'Refund Review', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + isStartNode: true, + properties: { + label: 'Refund Request', + description: 'A customer asks for a refund.', + inputPrompt: `Subject: Refund for a duplicate charge + +Hi, on 2026-09-02 I was charged $49 twice for my Pro plan (order #48213). Could you refund the extra charge? I would also like to know whether this will happen again next month. + +Thanks, +Marcus +Head of Ops, Brightwave`, + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + }, + { + id: 'draft-1', + type: 'node', + position: { x: 350, y: 300 }, + data: { + segments: [], + properties: { + label: 'Draft the Refund Reply', + description: 'Proposes a refund and drafts the reply.', + systemPrompt: `${REFUND_CONTEXT} + +Read the customer's message. Decide the refund amount under the policy and draft the reply. + +Return exactly this format: + +**Refund amount:** [number, in USD] +**Order date:** [YYYY-MM-DD, taken from the message] +**Reply draft:** +[the reply, under 120 words, signed "Lumen Support"]`, + webSearch: false, + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + }, + { + id: 'human-1', + type: humanDecisionNodeType, + position: { x: 700, y: 300 }, + data: { + segments: [], + properties: { + label: 'Review Refund', + description: 'A person approves or rejects it.', + decisionRequest: refundReviewRequest, + }, + type: humanDecisionNodeType, + icon: 'UserCheck', + }, + }, + { + id: 'send-1', + type: 'node', + position: { x: 1100, y: 200 }, + data: { + segments: [], + properties: { + label: 'Send the Confirmation', + description: 'Writes the confirmation to the customer.', + systemPrompt: `${REFUND_CONTEXT} + +A person approved the refund. The context holds the drafted reply and the decision record. +If the decision carries edits (for example a corrected refundAmount), the edited values win over the draft. + +Write the final confirmation to the customer: the amount refunded, where and when it arrives, +and one sentence on preventing a repeat. Under 100 words, signed "Lumen Support".`, + webSearch: false, + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + }, + { + id: 'done-1', + type: 'node', + position: { x: 1450, y: 200 }, + data: { + segments: [], + properties: { + label: 'Confirmation', + description: 'What the customer receives.', + mode: 'markdown', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + }, + { + id: 'rejected-1', + type: 'node', + position: { x: 1100, y: 600 }, + data: { + segments: [], + properties: { + label: 'Rejected', + description: 'The decision record.', + mode: 'json', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'draft-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-draft', + data: {}, + }, + { + source: 'draft-1', + sourceHandle: 'source', + target: 'human-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-draft-human', + data: {}, + }, + { + source: 'human-1', + sourceHandle: 'source:inner:approved', + zIndex: 1001, + target: 'send-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-human-send', + data: {}, + }, + { + source: 'human-1', + sourceHandle: 'source:inner:rejected', + zIndex: 1001, + target: 'rejected-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-human-rejected', + data: {}, + }, + { + source: 'send-1', + sourceHandle: 'source', + target: 'done-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-send-done', + data: {}, + }, + ], + viewport: { x: 100, y: 100, zoom: 0.6 }, + }, + layoutDirection: 'RIGHT', +}; + +export const refundReviewFlow: TemplateModel = { + id: 306, + name: 'Refund Review', + value: diagram, + icon: 'Receipt', +}; diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts new file mode 100644 index 000000000..9b94b39f8 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts @@ -0,0 +1,32 @@ +import { getHandleId } from '@workflowbuilder/sdk'; +import { describe, expect, it } from 'vitest'; + +import { defaultDecisionRequest, defaultPropertiesData } from './default-properties-data'; + +describe('defaultDecisionRequest', () => { + const ports = defaultDecisionRequest.actions.map((action) => action.port); + + it('routes on the SDK handle ids the template renders', () => { + expect(ports).toEqual([ + getHandleId({ handleType: 'source', innerId: 'approved' }), + getHandleId({ handleType: 'source', innerId: 'rejected' }), + ]); + }); + + it('gives reject a port of its own and never the reserved error route', () => { + expect(new Set(ports).size).toBe(ports.length); + expect(ports).not.toContain('errorRoute'); + }); + + it('is a version 1 request: one resume, one reject, no rerun, an empty form, no proposal source', () => { + expect(defaultDecisionRequest.version).toBe(1); + expect(defaultDecisionRequest.actions.map((action) => action.effect)).toEqual(['resume', 'reject']); + expect(defaultDecisionRequest.schema).toEqual({ type: 'object', properties: {} }); + expect(defaultDecisionRequest).not.toHaveProperty('proposalSourceNodeId'); + expect(defaultDecisionRequest).not.toHaveProperty('deadline'); + }); + + it('is what a node dropped from the palette carries', () => { + expect(defaultPropertiesData.decisionRequest).toBe(defaultDecisionRequest); + }); +}); diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts new file mode 100644 index 000000000..d4de4c097 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts @@ -0,0 +1,32 @@ +import { getHandleId } from '@workflowbuilder/sdk'; +import type { NodeDataProperties } from '@workflowbuilder/sdk'; + +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; + +import type { HumanDecisionSchema } from './schema'; + +export const defaultDecisionRequest = { + version: 1, + actions: [ + { + name: 'approve', + label: 'Approve', + effect: 'resume', + port: getHandleId({ handleType: 'source', innerId: 'approved' }), + }, + { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: getHandleId({ handleType: 'source', innerId: 'rejected' }), + reasonRequired: false, + }, + ], + schema: { type: 'object', properties: {} }, +} satisfies DecisionRequest; + +export const defaultPropertiesData: NodeDataProperties = { + label: 'Human decision', + description: '', + decisionRequest: defaultDecisionRequest, +}; diff --git a/apps/ai-studio/src/nodes/human-decision/human-decision-template.module.css b/apps/ai-studio/src/nodes/human-decision/human-decision-template.module.css new file mode 100644 index 000000000..21766dd94 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/human-decision-template.module.css @@ -0,0 +1,28 @@ +.actions { + display: flex; + gap: 0.5rem; +} + +.actions--vertical { + flex-direction: column; +} + +.action { + composes: ax-public-p11 from global; + + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background-color: var(--ax-ui-bg-tertiary-default); + border: 0.0625rem solid var(--ax-input-stroke-primary-default); + border-radius: 0.375rem; +} + +.action-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/ai-studio/src/nodes/human-decision/human-decision-template.test.tsx b/apps/ai-studio/src/nodes/human-decision/human-decision-template.test.tsx new file mode 100644 index 000000000..80acb2fb0 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/human-decision-template.test.tsx @@ -0,0 +1,189 @@ +import { ReactFlowProvider } from '@xyflow/react'; +import { type ReactNode, act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { defaultDecisionRequest } from './default-properties-data'; +import { HumanDecisionNodeTemplate } from './human-decision-template'; + +// The slot is observed through a marker element; the real one renders its children unchanged. +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Icon: () => null, + OptionalNodeContent: ({ nodeId, children }: { nodeId: string; children?: ReactNode }) => ( +
{children}
+ ), + }; +}); + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const data = { + type: 'ai-studio/human-decision', + icon: 'UserCheck' as const, + properties: { label: 'Human decision', description: '', decisionRequest: defaultDecisionRequest }, +}; + +function handles(container: HTMLElement, type: 'source' | 'target') { + return [...container.querySelectorAll(`.react-flow__handle.${type}`)]; +} + +describe('HumanDecisionNodeTemplate', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + function render(element: ReactNode) { + act(() => root.render({element})); + } + + it('mounts one source handle per action, with the action port as the handle id', () => { + render( + , + ); + + expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual([ + 'source:inner:approved', + 'source:inner:rejected', + ]); + expect(container.textContent).toContain('Approve'); + expect(container.textContent).toContain('Reject'); + }); + + it('derives the handles from the request: labels, ports and count come from its actions', () => { + const request = { + version: 1, + actions: [ + { name: 'ship', label: 'Ship it', effect: 'resume', port: 'source:inner:shipped' }, + { name: 'escalate', label: 'Escalate', effect: 'resume', port: 'source:inner:escalated' }, + { name: 'send-back', label: 'Send back', effect: 'reject', port: 'source:inner:sent-back' }, + { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }, + ], + schema: { type: 'object', properties: {} }, + }; + + render( + , + ); + + expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual([ + 'source:inner:shipped', + 'source:inner:escalated', + 'source:inner:sent-back', + ]); + for (const label of ['Ship it', 'Escalate', 'Send back']) { + expect(container.textContent).toContain(label); + } + expect(container.textContent).not.toContain('Ask again'); + expect(container.textContent).not.toContain('Approve'); + }); + + it.each([ + ['no request', undefined], + ['actions that are not a list', { version: 1, actions: 'nope' }], + ['a request that is not an object', 'nope'], + ])('renders the header and only the target handle with %s', (_case, decisionRequest) => { + render( + , + ); + + expect(container.textContent).toContain('Human decision'); + expect(container.textContent).not.toContain('Decision'); + expect(handles(container, 'source')).toHaveLength(0); + expect(handles(container, 'target').map((handle) => handle.dataset['handleid'])).toEqual(['target']); + }); + + it('skips actions without a port and shows the port when an action has no label', () => { + const request = { + version: 1, + actions: [{ name: 'go', port: 'source:inner:go' }, { name: 'stay', label: 'No port here' }, null, 'garbage'], + }; + + render( + , + ); + + expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual(['source:inner:go']); + expect(container.textContent).toContain('source:inner:go'); + expect(container.textContent).not.toContain('No port here'); + }); + + it('mounts one target handle', () => { + render( + , + ); + + expect(handles(container, 'target').map((handle) => handle.dataset['handleid'])).toEqual(['target']); + }); + + it('renders the actions inside the OptionalNodeContent slot, where the execution markers mount', () => { + render( + , + ); + + const slot = container.querySelector('[data-optional-node-content="human-1"]'); + expect(slot).not.toBeNull(); + expect(slot?.querySelectorAll('.react-flow__handle.source')).toHaveLength(2); + }); + + it('follows the layout direction: handles hang below and above in a top-down diagram', () => { + render( + , + ); + + expect(handles(container, 'source').map((handle) => handle.dataset['handlepos'])).toEqual(['bottom', 'bottom']); + expect(handles(container, 'target').map((handle) => handle.dataset['handlepos'])).toEqual(['top']); + }); + + it('shows only the header in the palette preview: no handles, no slot', () => { + render( + , + ); + + expect(container.querySelectorAll('.react-flow__handle')).toHaveLength(0); + expect(container.querySelector('[data-optional-node-content]')).toBeNull(); + expect(container.textContent).toContain('Human decision'); + }); +}); diff --git a/apps/ai-studio/src/nodes/human-decision/human-decision-template.tsx b/apps/ai-studio/src/nodes/human-decision/human-decision-template.tsx new file mode 100644 index 000000000..ab5819e41 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/human-decision-template.tsx @@ -0,0 +1,85 @@ +import { Icon, NodeSection, OptionalNodeContent, defineNodeTemplate, getHandleId } from '@workflowbuilder/sdk'; +import type { NodeDataProperties, WorkflowNodeTemplateProps } from '@workflowbuilder/sdk'; +import { NodeDescription, NodeIcon, NodePanel, Status } from '@workflowbuilder/ui'; +import { Handle, Position } from '@xyflow/react'; +import clsx from 'clsx'; +import { memo, useMemo } from 'react'; + +import styles from './human-decision-template.module.css'; + +import type { HumanDecisionSchema } from './schema'; + +type HumanDecisionProperties = NodeDataProperties; + +type RoutedAction = { label: string; port: string }; + +// One handle per action with a port, so the port a decision routes on is written once, in the request. +function routedActions(decisionRequest: unknown): RoutedAction[] { + const actions = (decisionRequest as { actions?: unknown } | undefined)?.actions; + if (!Array.isArray(actions)) { + return []; + } + return actions.flatMap((action: unknown) => { + const { label, port } = (action ?? {}) as { label?: unknown; port?: unknown }; + if (typeof port !== 'string' || port.length === 0) { + return []; + } + return [{ label: typeof label === 'string' ? label : port, port }]; + }); +} + +export const HumanDecisionNodeTemplate = defineNodeTemplate( + memo( + ({ + id, + icon, + label, + description, + data, + selected = false, + layoutDirection = 'RIGHT', + showHandles = true, + isValid, + }: WorkflowNodeTemplateProps) => { + const iconElement = useMemo(() => , [icon]); + const decisionRequest = data?.properties.decisionRequest; + const actions = useMemo(() => routedActions(decisionRequest), [decisionRequest]); + + const isHorizontal = layoutDirection === 'RIGHT'; + const isCanvasNode = showHandles; + + return ( + + + + + + + + + {actions.length > 0 && ( + +
+ {actions.map(({ label: actionLabel, port }) => ( +
+ {actionLabel} + +
+ ))} +
+
+ )} +
+
+ + + +
+ ); + }, + ), +); diff --git a/apps/ai-studio/src/nodes/human-decision/index.test.ts b/apps/ai-studio/src/nodes/human-decision/index.test.ts new file mode 100644 index 000000000..a611dde16 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/index.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { humanDecisionNodeType, humanDecisionPaletteItem } from '.'; +import { aiStudioNodeTypes } from '../../data/node-types'; + +describe('humanDecisionPaletteItem', () => { + it('is registered in the AI Studio palette exactly once, under the type the template is keyed by', () => { + const items = aiStudioNodeTypes.flatMap((entry) => ('groupItems' in entry ? entry.groupItems : [entry])); + const registered = items.filter((item) => item.type === humanDecisionNodeType); + + expect(registered).toEqual([humanDecisionPaletteItem]); + }); +}); diff --git a/apps/ai-studio/src/nodes/human-decision/index.ts b/apps/ai-studio/src/nodes/human-decision/index.ts new file mode 100644 index 000000000..049512a03 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/index.ts @@ -0,0 +1,27 @@ +import type { PaletteItem } from '@workflowbuilder/sdk'; + +import { defaultPropertiesData } from './default-properties-data'; +import { type HumanDecisionSchema, schema } from './schema'; +import { uischema } from './uischema'; + +// Also the key of the node's template in `nodeTemplates`; a custom template keyed by the palette type wins. +export const humanDecisionNodeType = 'ai-studio/human-decision'; + +export const humanDecisionPaletteItem: PaletteItem = { + label: 'Human decision', + description: 'A person decides before the run continues', + type: humanDecisionNodeType, + icon: 'UserCheck', + defaultPropertiesData, + schema, + uischema, + // The completion's output is the decision, so `{{ nodes..action }}` resolves downstream. + outputSchema: { + type: 'default', + properties: { + action: { type: 'string', label: 'Action', description: 'The name of the action the person chose' }, + effect: { type: 'string', label: 'Effect', description: 'resume, resume-with-edits or reject' }, + edits: { type: 'object', label: 'Edits', description: 'Field values the person corrected before approving' }, + }, + }, +}; diff --git a/apps/ai-studio/src/nodes/human-decision/schema.ts b/apps/ai-studio/src/nodes/human-decision/schema.ts new file mode 100644 index 000000000..a51d67837 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/schema.ts @@ -0,0 +1,14 @@ +import { sharedProperties } from '@workflowbuilder/sdk'; +import type { NodeSchema } from '@workflowbuilder/sdk'; + +// Opaque here: the backend parses the request, and the properties panel never renders it. +export const schema = { + type: 'object', + properties: { + ...sharedProperties, + decisionRequest: { type: 'object', properties: {} }, + }, + required: ['decisionRequest'], +} satisfies NodeSchema; + +export type HumanDecisionSchema = typeof schema; diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.ts b/apps/ai-studio/src/nodes/human-decision/uischema.ts new file mode 100644 index 000000000..974076535 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/uischema.ts @@ -0,0 +1,19 @@ +import { getScope } from '@workflowbuilder/sdk'; +import type { UISchema } from '@workflowbuilder/sdk'; + +import type { HumanDecisionSchema } from './schema'; + +const scope = getScope; + +// Authoring the request itself lands later (follow-up: decision-request-properties-ui). +export const uischema: UISchema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Text', + scope: scope('properties.label'), + label: 'Title', + placeholder: 'Node Title...', + }, + ], +}; diff --git a/apps/ai-studio/src/stores/use-execution-store.test.ts b/apps/ai-studio/src/stores/use-execution-store.test.ts new file mode 100644 index 000000000..16814f697 --- /dev/null +++ b/apps/ai-studio/src/stores/use-execution-store.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; + +import { + applyEvent, + applySnapshot, + resetExecution, + setExecutionStarted, + useExecutionStore, +} from './use-execution-store'; + +let sequence = 0; + +function event(partial: Omit): ExecutionEvent { + sequence += 1; + return { executionId: 'exec-1', sequence, timestamp: '2026-09-15T12:00:00.000Z', ...partial } as ExecutionEvent; +} + +const nodeState = (nodeId: string) => useExecutionStore.getState().nodeStates[nodeId]; + +describe('use-execution-store: a node waiting for a person', () => { + beforeEach(() => { + sequence = 0; + resetExecution(); + setExecutionStarted('exec-1', '/api/executions/exec-1/stream'); + }); + + it('node_waiting marks the node waiting, and the completion that follows marks it completed', () => { + applyEvent(event({ type: 'node_started', nodeId: 'human-1' })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + + expect(nodeState('human-1')).toEqual({ status: 'waiting' }); + + applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: { action: 'approve' } } })); + + expect(nodeState('human-1')).toEqual({ status: 'completed', output: { action: 'approve' } }); + }); + + it('a failure after the wait marks the node failed, not waiting', () => { + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + applyEvent(event({ type: 'node_failed', nodeId: 'human-1', payload: { error: { message: 'boom' } } })); + + expect(nodeState('human-1')).toEqual({ status: 'failed', error: { message: 'boom' } }); + }); + + it('the run is waiting while a node waits, and running again once the node resolves', () => { + applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + expect(useExecutionStore.getState().status).toBe('running'); + + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + expect(useExecutionStore.getState().status).toBe('waiting'); + + applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } })); + expect(useExecutionStore.getState().status).toBe('running'); + }); + + it('with two nodes waiting, the first verdict keeps the run waiting', () => { + applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-2' })); + + applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } })); + expect(useExecutionStore.getState().status).toBe('waiting'); + + applyEvent(event({ type: 'node_failed', nodeId: 'human-2', payload: { error: { message: 'boom' } } })); + expect(useExecutionStore.getState().status).toBe('running'); + }); + + it('a terminal event closes a waiting run, whatever the nodes say', () => { + applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + + applyEvent(event({ type: 'execution_cancelled', payload: {} })); + + expect(useExecutionStore.getState().status).toBe('cancelled'); + expect(nodeState('human-1')).toEqual({ status: 'waiting' }); + }); + + it('a snapshot whose row still says pending shows the run waiting, because the events say so', () => { + const events = [ + event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), + event({ type: 'node_started', nodeId: 'human-1' }), + event({ type: 'node_waiting', nodeId: 'human-1' }), + ]; + + applySnapshot({ executionId: 'exec-1', status: 'pending', lastSequence: sequence, events }); + + expect(useExecutionStore.getState().status).toBe('waiting'); + }); + + it('a snapshot of a run that already resolved its wait shows running, whatever the row says', () => { + const events = [ + event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), + event({ type: 'node_waiting', nodeId: 'human-1' }), + event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }), + event({ type: 'node_started', nodeId: 'send-1' }), + ]; + + applySnapshot({ executionId: 'exec-1', status: 'pending', lastSequence: sequence, events }); + + expect(useExecutionStore.getState().status).toBe('running'); + }); + + it('node_waiting delivered twice for one node does not drift the run status', () => { + applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' })); + expect(useExecutionStore.getState().status).toBe('waiting'); + + applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } })); + expect(useExecutionStore.getState().status).toBe('running'); + }); + + it('a snapshot replayed after a reload rebuilds the waiting node and the run status', () => { + const events = [ + event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), + event({ type: 'node_started', nodeId: 'trigger-1' }), + event({ type: 'node_completed', nodeId: 'trigger-1', payload: { output: {} } }), + event({ type: 'node_started', nodeId: 'human-1' }), + event({ type: 'node_waiting', nodeId: 'human-1' }), + ]; + + applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: sequence, events }); + + const state = useExecutionStore.getState(); + expect(state.status).toBe('waiting'); + expect(state.nodeStates['trigger-1']?.status).toBe('completed'); + expect(state.nodeStates['human-1']?.status).toBe('waiting'); + expect(state.events).toHaveLength(events.length); + }); +}); diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index aaa571136..6d437aa3a 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -7,7 +7,7 @@ import type { ExecutionStatus, } from '@workflow-builder/types/workflow-execution/execution-events'; -type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'skipped'; +type NodeExecutionStatus = 'idle' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped'; export type NodeExecutionState = { status: NodeExecutionStatus; @@ -63,16 +63,20 @@ export function applyConnectionLost() { useExecutionStore.setState({ status: 'disconnected' }); } +// Replayed through the same rule as live events, so a reload shows what live showed. The row's +// status is only the seed: the engine never writes `running` at start and its `waiting` write is advisory. export function applySnapshot(snapshot: ExecutionSnapshot) { const nodeStates: Record = {}; + let status: ExecutionStore['status'] = snapshot.status; for (const event of snapshot.events) { applyEventToNodeStates(event, nodeStates); + status = nextRunStatus(status, event, nodeStates); } useExecutionStore.setState({ executionId: snapshot.executionId, - status: snapshot.status, + status, nodeStates, events: snapshot.events, }); @@ -83,22 +87,41 @@ export function applyEvent(event: ExecutionEvent) { const nodeStates = { ...state.nodeStates }; applyEventToNodeStates(event, nodeStates); - const status = eventToExecutionStatus(event) ?? state.status; - return { nodeStates, events: [...state.events, event], - status, + status: nextRunStatus(state.status, event, nodeStates), }; }); } +function nextRunStatus( + current: ExecutionStore['status'], + event: ExecutionEvent, + nodeStates: Record, +): ExecutionStore['status'] { + return eventToExecutionStatus(event) ?? deriveRunStatus(current, nodeStates); +} + +// No event carries the run's waiting status, so it is derived the way the engine derives it: +// waiting while any node is parked, running again once the last one resolves. +function deriveRunStatus(current: ExecutionStore['status'], nodeStates: Record) { + if (current !== 'running' && current !== 'waiting') { + return current; + } + return Object.values(nodeStates).some((node) => node.status === 'waiting') ? 'waiting' : 'running'; +} + function applyEventToNodeStates(event: ExecutionEvent, states: Record) { switch (event.type) { case 'node_started': { states[event.nodeId] = { status: 'running' }; break; } + case 'node_waiting': { + states[event.nodeId] = { status: 'waiting' }; + break; + } case 'node_completed': { states[event.nodeId] = { status: 'completed', output: event.payload.output }; break; diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a9aa5d107..4862b37fa 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -56,7 +56,7 @@ own: one executor per node type and the database as the store port. - **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package. - **Workflow ID:** `execution-` — deterministic, lets the backend cancel by execution ID. Also owned by the package. - **Activity timeouts:** DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as `DEFAULT_DATABASE_ACTIVITY_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`. -- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — the reference executors have not been classified yet. +- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — most reference executors have not been classified yet; `human-decision` is the first that has. - **Sandbox constraint:** `workflows.ts` is bundled into V8 with no Web APIs. It may only re-export from `@workflowbuilder/temporal/workflow`, never from the package root. - **Editing the package:** the worker imports its built `dist`, so run `pnpm build:temporal` after changing `packages/temporal/src`. - **Deploys that change the emitted event set:** drain in-flight runs first. Replaying an old run's history against a new emit sequence diverges — see [`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9. diff --git a/apps/execution-worker/src/domain/ai-studio-nodes.ts b/apps/execution-worker/src/domain/ai-studio-nodes.ts index 2f665f1e6..03eb09a1a 100644 --- a/apps/execution-worker/src/domain/ai-studio-nodes.ts +++ b/apps/execution-worker/src/domain/ai-studio-nodes.ts @@ -36,6 +36,9 @@ type DecisionNodeConfig = { // Display-only node; the UI reads the upstream output directly, so no runtime config. type VisualizeNodeConfig = Record; +// What to ask a person travels on BaseNode.decisionRequest, lifted off config by the backend. +type HumanDecisionNodeConfig = Record; + export type TriggerNode = ProductNode<'ai-studio/trigger', TriggerNodeConfig>; export type AiAgentNode = ProductNode<'ai-studio/ai-agent', AiAgentNodeConfig>; @@ -44,4 +47,6 @@ export type DecisionNode = ProductNode<'ai-studio/decision', DecisionNodeConfig> type VisualizeNode = ProductNode<'ai-studio/visualize', VisualizeNodeConfig>; -export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode | VisualizeNode; +export type HumanDecisionNode = ProductNode<'ai-studio/human-decision', HumanDecisionNodeConfig>; + +export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode | VisualizeNode | HumanDecisionNode; diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 60732cc47..1056318a9 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -8,6 +8,7 @@ import { database } from '../../database'; import type { AiStudioNode } from '../../domain/ai-studio-nodes'; import { env } from '../../env'; import { executeDecision } from '../../executors/decision'; +import { executeHumanDecision } from '../../executors/human-decision'; import { executeTrigger } from '../../executors/trigger'; import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; @@ -29,6 +30,7 @@ const plugin = new WorkflowBuilderPlugin({ 'ai-studio/ai-agent': (node, context) => executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }), 'ai-studio/visualize': executeVisualize, + 'ai-studio/human-decision': executeHumanDecision, }, store: withPayloadSizeWarning(database, logger), }); diff --git a/apps/execution-worker/src/executors/human-decision.test.ts b/apps/execution-worker/src/executors/human-decision.test.ts new file mode 100644 index 000000000..0c36ddc67 --- /dev/null +++ b/apps/execution-worker/src/executors/human-decision.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { PermanentNodeExecutionError } from '@workflow-builder/execution-core'; +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; + +import type { HumanDecisionNode } from '../domain/ai-studio-nodes'; +import { executeHumanDecision } from './human-decision'; + +const request: DecisionRequest = { + version: 1, + actions: [ + { name: 'approve', label: 'Approve', effect: 'resume', port: 'source:inner:approved' }, + { name: 'reject', label: 'Reject', effect: 'reject', port: 'source:inner:rejected', reasonRequired: false }, + ], + schema: { type: 'object', properties: {} }, +}; + +function humanDecisionNode(decisionRequest?: DecisionRequest): HumanDecisionNode { + return { + id: 'human-1', + type: 'ai-studio/human-decision', + config: {}, + ...(decisionRequest === undefined ? {} : { decisionRequest }), + }; +} + +describe('executeHumanDecision', () => { + it('returns exactly { waiting: true } for a node that carries a request', () => { + expect(executeHumanDecision(humanDecisionNode(request))).toStrictEqual({ waiting: true }); + }); + + it('is not a second validator: a request with no actions still parks', () => { + expect(executeHumanDecision(humanDecisionNode({ ...request, actions: [] }))).toStrictEqual({ waiting: true }); + }); + + it('fails permanently with code "decision_request_missing" for a node without a request', () => { + let thrown: unknown; + try { + executeHumanDecision(humanDecisionNode()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(PermanentNodeExecutionError); + const error = thrown as PermanentNodeExecutionError; + expect(error.code).toBe('decision_request_missing'); + expect(error.classification).toBe('permanent'); + expect(error.message).toContain("'human-1'"); + }); +}); diff --git a/apps/execution-worker/src/executors/human-decision.ts b/apps/execution-worker/src/executors/human-decision.ts new file mode 100644 index 000000000..345dfe738 --- /dev/null +++ b/apps/execution-worker/src/executors/human-decision.ts @@ -0,0 +1,14 @@ +// The completion arrives through POST /api/executions/:id/decision, never through this executor. +import { type NodeExecutionResult, PermanentNodeExecutionError } from '@workflow-builder/execution-core'; + +import type { HumanDecisionNode } from '../domain/ai-studio-nodes'; + +export function executeHumanDecision(node: HumanDecisionNode): NodeExecutionResult { + if (node.decisionRequest === undefined) { + throw new PermanentNodeExecutionError( + 'decision_request_missing', + `Node '${node.id}' carries no decision request, so nobody could ever decide it`, + ); + } + return { waiting: true }; +}