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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/ai-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 1 addition & 4 deletions apps/ai-studio/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions apps/ai-studio/src/data/node-templates.ts
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 2 additions & 0 deletions apps/ai-studio/src/data/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,6 +16,7 @@ export const aiStudioNodeTypes: PaletteItemOrGroup[] = [
aiAgentPaletteItem,
decisionPaletteItem,
humanDecisionPaletteItem,
reviewPaletteItem,
visualizePaletteItem,
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
36 changes: 36 additions & 0 deletions apps/ai-studio/src/nodes/review/default-properties-data.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
38 changes: 38 additions & 0 deletions apps/ai-studio/src/nodes/review/default-properties-data.ts
Original file line number Diff line number Diff line change
@@ -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<HumanDecisionSchema> = {
label: 'Review',
description: '',
decisionRequest: reviewDecisionRequest,
};
33 changes: 33 additions & 0 deletions apps/ai-studio/src/nodes/review/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions apps/ai-studio/src/nodes/review/index.ts
Original file line number Diff line number Diff line change
@@ -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<HumanDecisionSchema> = {
...humanDecisionPaletteItem,
label: 'Review',
description: 'Approve, escalate or reject',
type: reviewNodeType,
icon: 'Scales',
defaultPropertiesData,
};
2 changes: 1 addition & 1 deletion apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/execution-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion apps/execution-worker/src/domain/ai-studio-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions apps/execution-worker/src/engines/temporal/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const plugin = new WorkflowBuilderPlugin<AiStudioNode>({
'ai-studio/ai-agent': executeAIAgent,
'ai-studio/visualize': executeVisualize,
'ai-studio/human-decision': executeHumanDecision,
'ai-studio/review': executeHumanDecision,
},
store: withPayloadSizeWarning(database, logger),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 });
});
Expand Down
4 changes: 2 additions & 2 deletions apps/execution-worker/src/executors/human-decision.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Loading