diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 7430391d7..703f74ee1 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`, `ai-studio/human-decision`, `ai-studio/visualize`) +- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`, `ai-studio/human-decision`, `ai-studio/review`, `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. +- `ai-studio/review` is Human decision with another preset: Approve and Escalate resume the run on their own ports, Reject ends it. Same template, panel and executor, keyed by its own palette type; only the default `decisionRequest` differs. The author cannot edit the actions yet, and until the decider's form offers every resume action, Escalate is chosen through the API. - The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record). - The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read. - A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass. diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index 50ffc1a00..187263531 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -12,20 +12,17 @@ import { ExecutionHighlighting } from '../components/execution/highlighting'; import { ExecutionLogPanel } from '../components/execution/log-panel'; import { decisionFieldsRenderer } from '../components/human-decision/decision-fields/decision-fields-control'; import { decisionFormRenderer } from '../components/human-decision/decision-form/decision-form-control'; -import { HumanDecisionNodeTemplate } from '../components/human-decision/node-template/human-decision-template'; import { DecisionWaitingSnackbar } from '../components/human-decision/waiting-snackbar/decision-waiting-snackbar'; import { OpenNotices } from '../components/open-from-url/open-notices'; import { aiStudioTemplates } from '../data/ai-studio-templates'; +import { nodeTemplates } from '../data/node-templates'; import { aiStudioNodeTypes } from '../data/node-types'; import { supportTriageFlow } from '../data/support-triage-flow'; -import { humanDecisionNodeType } from '../nodes/human-decision'; import type { OpenedSource } from './open-from-url'; import { rootPropsFor } from './root-props'; const flagship = supportTriageFlow.value; -// Module-level: `nodeTemplates` must keep the same reference across renders. -const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate }; const jsonForm = { renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer] }; // A start node is where the run begins, so it can never be a connection target. diff --git a/apps/ai-studio/src/data/node-templates.ts b/apps/ai-studio/src/data/node-templates.ts new file mode 100644 index 000000000..c17a33a23 --- /dev/null +++ b/apps/ai-studio/src/data/node-templates.ts @@ -0,0 +1,9 @@ +import { HumanDecisionNodeTemplate } from '../components/human-decision/node-template/human-decision-template'; +import { humanDecisionNodeType } from '../nodes/human-decision'; +import { reviewNodeType } from '../nodes/review'; + +// Keyed by palette type, which is also the node's React Flow type. Module-level: the same reference across renders. +export const nodeTemplates = { + [humanDecisionNodeType]: HumanDecisionNodeTemplate, + [reviewNodeType]: HumanDecisionNodeTemplate, +}; diff --git a/apps/ai-studio/src/data/node-types.ts b/apps/ai-studio/src/data/node-types.ts index 5143bb571..8bd003ee0 100644 --- a/apps/ai-studio/src/data/node-types.ts +++ b/apps/ai-studio/src/data/node-types.ts @@ -3,6 +3,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 { reviewPaletteItem } from '../nodes/review'; import { triggerPaletteItem } from '../nodes/trigger'; import { visualizePaletteItem } from '../nodes/visualize'; @@ -15,6 +16,7 @@ export const aiStudioNodeTypes: PaletteItemOrGroup[] = [ aiAgentPaletteItem, decisionPaletteItem, humanDecisionPaletteItem, + reviewPaletteItem, visualizePaletteItem, ], }, diff --git a/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts b/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts index ed78d50c6..6cb84cf3f 100644 --- a/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts +++ b/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts @@ -5,12 +5,14 @@ import { describe, expect, it } from 'vitest'; // live is still open (follow-up: decision-request-contract-test-home). import { decisionRequestSchema } from '../../../../backend/src/domain/decision/decision-request-schema'; import { refundReviewRequest } from '../../data/refund-review-flow'; +import { reviewDecisionRequest } from '../review/default-properties-data'; import { defaultDecisionRequest } from './default-properties-data'; describe('the decision requests AI Studio ships, against the backend contract', () => { it.each([ - ['the palette preset', defaultDecisionRequest], + ['the Human decision preset', defaultDecisionRequest], ['the "Refund Review" template', refundReviewRequest], + ['the Review preset', reviewDecisionRequest], ])('%s parses with decisionRequestSchema', (_name, request) => { const parsed = decisionRequestSchema.safeParse(request); diff --git a/apps/ai-studio/src/nodes/review/default-properties-data.test.ts b/apps/ai-studio/src/nodes/review/default-properties-data.test.ts new file mode 100644 index 000000000..0668b6148 --- /dev/null +++ b/apps/ai-studio/src/nodes/review/default-properties-data.test.ts @@ -0,0 +1,36 @@ +import { getHandleId } from '@workflowbuilder/sdk'; +import { describe, expect, it } from 'vitest'; + +import { defaultPropertiesData, reviewDecisionRequest } from './default-properties-data'; + +describe('reviewDecisionRequest', () => { + const ports = reviewDecisionRequest.actions.map((action) => action.port); + + it('routes on the SDK handle ids the template renders, one per action', () => { + expect(ports).toEqual([ + getHandleId({ handleType: 'source', innerId: 'approved' }), + getHandleId({ handleType: 'source', innerId: 'escalated' }), + getHandleId({ handleType: 'source', innerId: 'rejected' }), + ]); + }); + + it('gives every action 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: two resume actions, one reject that needs a reason, no rerun, an empty form, no proposal source', () => { + expect(reviewDecisionRequest.version).toBe(1); + expect(reviewDecisionRequest.actions.map((action) => action.name)).toEqual(['approve', 'escalate', 'reject']); + expect(reviewDecisionRequest.actions.map((action) => action.effect)).toEqual(['resume', 'resume', 'reject']); + expect(reviewDecisionRequest.actions[2]).toMatchObject({ effect: 'reject', reasonRequired: true }); + expect(reviewDecisionRequest.schema).toEqual({ type: 'object', properties: {} }); + expect(reviewDecisionRequest).not.toHaveProperty('proposalSourceNodeId'); + expect(reviewDecisionRequest).not.toHaveProperty('deadline'); + }); + + it('is what a node dropped from the palette carries, titled Review', () => { + expect(defaultPropertiesData.decisionRequest).toBe(reviewDecisionRequest); + expect(defaultPropertiesData.label).toBe('Review'); + }); +}); diff --git a/apps/ai-studio/src/nodes/review/default-properties-data.ts b/apps/ai-studio/src/nodes/review/default-properties-data.ts new file mode 100644 index 000000000..b3f5bd681 --- /dev/null +++ b/apps/ai-studio/src/nodes/review/default-properties-data.ts @@ -0,0 +1,38 @@ +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 '../human-decision/schema'; + +export const reviewDecisionRequest = { + version: 1, + actions: [ + { + name: 'approve', + label: 'Approve', + effect: 'resume', + port: getHandleId({ handleType: 'source', innerId: 'approved' }), + }, + { + name: 'escalate', + label: 'Escalate', + effect: 'resume', + port: getHandleId({ handleType: 'source', innerId: 'escalated' }), + }, + { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: getHandleId({ handleType: 'source', innerId: 'rejected' }), + reasonRequired: true, + }, + ], + schema: { type: 'object', properties: {} }, +} satisfies DecisionRequest; + +export const defaultPropertiesData: NodeDataProperties = { + label: 'Review', + description: '', + decisionRequest: reviewDecisionRequest, +}; diff --git a/apps/ai-studio/src/nodes/review/index.test.ts b/apps/ai-studio/src/nodes/review/index.test.ts new file mode 100644 index 000000000..70a39512c --- /dev/null +++ b/apps/ai-studio/src/nodes/review/index.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { reviewNodeType, reviewPaletteItem } from '.'; +import { HumanDecisionNodeTemplate } from '../../components/human-decision/node-template/human-decision-template'; +import { nodeTemplates } from '../../data/node-templates'; +import { aiStudioNodeTypes } from '../../data/node-types'; +import { humanDecisionNodeType, humanDecisionPaletteItem } from '../human-decision'; +import { defaultPropertiesData } from './default-properties-data'; + +describe('reviewPaletteItem', () => { + it('is registered in the AI Studio palette exactly once, right after Human decision, 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 === reviewNodeType); + + expect(reviewNodeType).not.toBe(humanDecisionNodeType); + expect(registered).toEqual([reviewPaletteItem]); + expect(items.indexOf(reviewPaletteItem)).toBe(items.indexOf(humanDecisionPaletteItem) + 1); + }); + + it('is Human decision with another preset: same panel, same output, its own request', () => { + expect(reviewPaletteItem.schema).toBe(humanDecisionPaletteItem.schema); + expect(reviewPaletteItem.uischema).toBe(humanDecisionPaletteItem.uischema); + expect(reviewPaletteItem.outputSchema).toBe(humanDecisionPaletteItem.outputSchema); + expect(reviewPaletteItem.defaultPropertiesData).toBe(defaultPropertiesData); + expect(reviewPaletteItem.label).toBe('Review'); + expect(reviewPaletteItem.description).toBe('Approve, escalate or reject'); + expect(reviewPaletteItem.icon).toBe('Scales'); + }); + + it('is drawn by the Human decision template, under the type a template node and a dropped node carry', () => { + expect(nodeTemplates[reviewNodeType]).toBe(HumanDecisionNodeTemplate); + }); +}); diff --git a/apps/ai-studio/src/nodes/review/index.ts b/apps/ai-studio/src/nodes/review/index.ts new file mode 100644 index 000000000..75d44de16 --- /dev/null +++ b/apps/ai-studio/src/nodes/review/index.ts @@ -0,0 +1,18 @@ +import type { PaletteItem } from '@workflowbuilder/sdk'; + +import { humanDecisionPaletteItem } from '../human-decision'; +import type { HumanDecisionSchema } from '../human-decision/schema'; +import { defaultPropertiesData } from './default-properties-data'; + +// Also the key of the node's template in `nodeTemplates`; a custom template keyed by the palette type wins. +export const reviewNodeType = 'ai-studio/review'; + +// Human decision with another preset. The author cannot edit the actions yet (follow-up: decision-request-properties-ui). +export const reviewPaletteItem: PaletteItem = { + ...humanDecisionPaletteItem, + label: 'Review', + description: 'Approve, escalate or reject', + type: reviewNodeType, + icon: 'Scales', + defaultPropertiesData, +}; diff --git a/apps/backend/README.md b/apps/backend/README.md index 98a089e42..43f03c4ec 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -83,7 +83,7 @@ A review with two normal outcomes and a rejection: `complete` and `incomplete` a }, { "id": "review-1", - "type": "node", + "type": "ai-studio/human-decision", "position": { "x": 350, "y": 200 }, "data": { "type": "ai-studio/human-decision", diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 30e3ac05e..051637916 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -102,7 +102,7 @@ Each judgment is made at the throw site that owns the error. The runner and the | AI Agent: `AI_*` variables missing | permanent | `ai_not_configured` | | AI Agent, Decision: template reference malformed or unresolved | permanent | `template_malformed`, `template_unresolved` | | Decision: no branch matched | permanent | `no_branch_matched` | -| Human decision: node carries no decision request | permanent | `decision_request_missing` | +| Human decision or Review: node carries no decision request | permanent | `decision_request_missing` | The provider's own error is attached as `cause`, and `node_failed` reports the deepest non-empty cause's text, so the provider's message reaches the UI as it did before classification. A refused connection is the exception: the SDK reports it as `Cannot connect to API:` with nothing after the colon, because the reason sits in an `AggregateError` it wraps — one entry per address tried. Only messages survive the activity boundary, so the classifier attaches the first entry (`connect ECONNREFUSED ::1:11434`) as the cause instead of the SDK error. The classifier's own message, which names the HTTP status, is one level up and visible only in Temporal's failure record. 409 is permanent on purpose, unlike the AI SDK's own retry default: no chat provider is known to answer 409 for a condition a retry would clear. Two kinds of SDK error stay unclassified and keep the profile's uniform retry: a response the SDK could not parse (a 2xx with a non-JSON body, typically a proxy answering with HTML) and errors raised without any provider response (a malformed tool call from the model, no output generated), which describe model behaviour a retry can change. Marking a failure transient does not buy extra attempts — the node profile still caps them. diff --git a/apps/execution-worker/src/domain/ai-studio-nodes.ts b/apps/execution-worker/src/domain/ai-studio-nodes.ts index f9cb4bcf3..cf248fcea 100644 --- a/apps/execution-worker/src/domain/ai-studio-nodes.ts +++ b/apps/execution-worker/src/domain/ai-studio-nodes.ts @@ -51,4 +51,6 @@ type VisualizeNode = ProductNode<'ai-studio/visualize', VisualizeNodeConfig>; export type HumanDecisionNode = ProductNode<'ai-studio/human-decision', HumanDecisionNodeConfig>; -export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode | VisualizeNode | HumanDecisionNode; +export type ReviewNode = ProductNode<'ai-studio/review', HumanDecisionNodeConfig>; + +export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode | VisualizeNode | HumanDecisionNode | ReviewNode; diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 13a688bce..bfc849682 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -43,6 +43,7 @@ const plugin = new WorkflowBuilderPlugin({ 'ai-studio/ai-agent': executeAIAgent, 'ai-studio/visualize': executeVisualize, 'ai-studio/human-decision': executeHumanDecision, + 'ai-studio/review': 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 index 0c36ddc67..335a42893 100644 --- a/apps/execution-worker/src/executors/human-decision.test.ts +++ b/apps/execution-worker/src/executors/human-decision.test.ts @@ -3,7 +3,7 @@ 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 type { HumanDecisionNode, ReviewNode } from '../domain/ai-studio-nodes'; import { executeHumanDecision } from './human-decision'; const request: DecisionRequest = { @@ -29,6 +29,12 @@ describe('executeHumanDecision', () => { expect(executeHumanDecision(humanDecisionNode(request))).toStrictEqual({ waiting: true }); }); + it('parks a Review node the same way: the preset differs, the executor does not', () => { + const review: ReviewNode = { id: 'review-1', type: 'ai-studio/review', config: {}, decisionRequest: request }; + + expect(executeHumanDecision(review)).toStrictEqual({ waiting: true }); + }); + it('is not a second validator: a request with no actions still parks', () => { expect(executeHumanDecision(humanDecisionNode({ ...request, actions: [] }))).toStrictEqual({ waiting: true }); }); diff --git a/apps/execution-worker/src/executors/human-decision.ts b/apps/execution-worker/src/executors/human-decision.ts index 345dfe738..844f12d12 100644 --- a/apps/execution-worker/src/executors/human-decision.ts +++ b/apps/execution-worker/src/executors/human-decision.ts @@ -1,9 +1,9 @@ // 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'; +import type { HumanDecisionNode, ReviewNode } from '../domain/ai-studio-nodes'; -export function executeHumanDecision(node: HumanDecisionNode): NodeExecutionResult { +export function executeHumanDecision(node: HumanDecisionNode | ReviewNode): NodeExecutionResult { if (node.decisionRequest === undefined) { throw new PermanentNodeExecutionError( 'decision_request_missing',