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
18 changes: 14 additions & 4 deletions apps/backend/src/domain/mapper/from-integration-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type BaseNode,
NODE_ERROR_POLICIES,
type NodeErrorPolicy,
type NodeRole,
type WorkflowDefinition,
type WorkflowEdgeDefinition,
} from '@workflow-builder/types/workflow-execution/execution-model';
Expand All @@ -16,6 +17,12 @@ type FrontendEdge = WorkflowSnapshot['edges'][number];
// out of sync with the runner's union.
const ERROR_POLICIES: ReadonlySet<NodeErrorPolicy> = new Set(NODE_ERROR_POLICIES);

// The editor's kind for an entrypoint node, mirroring `NodeType.StartNode` in
// `@workflowbuilder/sdk`. Spelled out as a literal rather than imported: the
// backend deliberately does not depend on the React SDK. If the SDK renames the
// kind, this has to move with it.
const START_NODE_KIND = 'start-node';

// Structural pass-through. The backend treats nodes as opaque `{ id, type, config }`;
// the worker narrows `config` against its own concrete node union when it dispatches
// the executor. Unknown types reach the worker and fail there as `node_failed`.
Expand All @@ -25,18 +32,21 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot):
return { workflowId, nodes, edges };
}

// `errorPolicy` is authored in the UI as a regular JSONForms property
// (via `sharedProperties` in the SDK), so it arrives nested in
// `data.properties`. The runner expects it at the top level of `BaseNode`,
// so we lift it here and keep `config` free of runner-only fields.
// Two runner-level fields are lifted out of the frontend shape here so `config`
// stays free of them. `errorPolicy` is authored in the UI as a regular JSONForms
// property (via `sharedProperties` in the SDK) and arrives nested in
// `data.properties`. `role` is derived from the editor's node kind, which lives
// on the node itself rather than in its properties.
function mapNode(node: FrontendNode): BaseNode {
const { errorPolicy: rawErrorPolicy, ...config } = node.data.properties ?? {};
const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined;
const role: NodeRole | undefined = node.type === START_NODE_KIND ? 'start' : undefined;
return {
id: node.id,
type: node.data.type,
config,
...(errorPolicy === undefined ? {} : { errorPolicy }),
...(role === undefined ? {} : { role }),
};
}

Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/domain/mapper/snapshot-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ describe('workflowSnapshotSchema', () => {
expect(workflowSnapshotSchema.safeParse(snapshot).success).toBe(true);
});

it("keeps the editor's node kind so entrypoints stay identifiable", () => {
const snapshot = {
nodes: [{ id: 'n1', type: 'start-node', data: { type: 'my-product/trigger' } }],
edges: [],
};

const result = workflowSnapshotSchema.parse(snapshot);
expect(result.nodes[0]!.type).toBe('start-node');
});

it('rejects a node missing `id`', () => {
const snapshot = {
nodes: [{ data: { type: 'x/y' } }],
Expand Down Expand Up @@ -151,6 +161,23 @@ describe('mapToExecutionModel', () => {
]);
});

it("lifts the editor's start-node kind to role 'start'", () => {
const result = mapToExecutionModel('wf-1', {
nodes: [
{ id: 'n1', type: 'start-node', data: { type: 'my-product/trigger' } },
{ id: 'n2', type: 'node', data: { type: 'my-product/action' } },
{ id: 'n3', data: { type: 'my-product/action' } },
],
edges: [],
});

// Only the start node carries a role; the runner reads it to pick entrypoints
// instead of inferring them from in-degree.
expect(result.nodes[0]!.role).toBe('start');
expect(result.nodes[1]!.role).toBeUndefined();
expect(result.nodes[2]!.role).toBeUndefined();
});

it('passes unknown node types through unchanged — backend does not know any vocabulary', () => {
// The whole point of the structural mapper: a type the backend has never
// heard of reaches the worker, where the registry-miss becomes a
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/domain/mapper/snapshot-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { z } from 'zod';

const frontendNodeSchema = z.object({
id: z.string(),
// The editor's node kind (`start-node`, `node`, `decision-node`, ...), as
// opposed to `data.type`, which is the product's own vocabulary. Kept because
// the runner needs to know which nodes are entrypoints: zod strips whatever it
// is not told about, and dropping this is what let a node with no incoming
// edge act as a second trigger.
type: z.string().optional(),
data: z.object({
type: z.string(),
properties: z.record(z.string(), z.unknown()).optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ function node(id: string): TestNode {
return { id, type: 'test/node', config: {} };
}

function startNode(id: string): TestNode {
return { id, type: 'test/node', config: {}, role: 'start' };
}

type TestEdge = WorkflowExecutionInput<TestNode>['definition']['edges'][number];

function edge(id: string, source: string, target: string): TestEdge {
Expand Down Expand Up @@ -84,7 +88,7 @@ const runner: ActivityRunnerPort<TestNode> = {
// A→{B,C,D}→E: one four-wide wave, so four node_started emits are in flight at once.
function fanOutGraph(): WorkflowExecutionInput<TestNode> {
return makeInput(
[node('A'), node('B'), node('C'), node('D'), node('E')],
[startNode('A'), node('B'), node('C'), node('D'), node('E')],
[
edge('e1', 'A', 'B'),
edge('e2', 'A', 'C'),
Expand Down Expand Up @@ -204,7 +208,7 @@ describe('createSequencedEventEmitter', () => {

const outcome = await runGraph(
makeInput(
[node('A'), { ...node('B'), errorPolicy: 'continue' }, node('C')],
[startNode('A'), { ...node('B'), errorPolicy: 'continue' }, node('C')],
[edge('e1', 'A', 'B'), edge('e2', 'B', 'C')],
),
runner,
Expand Down
1 change: 1 addition & 0 deletions packages/execution-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The mapped-type registry refuses to compile if a key drifts away from the union
```
src/
├── graph-runner.ts # Topological scheduler over nodes/edges — engine-agnostic, generic in TNode
├── resolve-start-node.ts # Entry-shape rule: exactly one `role: 'start'` node, no orphans
├── execution-context.ts # Readonly context passed to every node executor
├── ports/
│ ├── workflow-engine.port.ts # submit(), cancel() — implemented by adapters (TemporalEngine, …)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ function trigger(id: string): TestNode {
return { id, type: 'test/node', config: {} };
}

function start(id: string): TestNode {
return { id, type: 'test/node', config: {}, role: 'start' };
}

function edge(id: string, source: string, target: string, sourceHandle?: string): WorkflowEdgeDefinition {
return { id, sourceNodeId: source, targetNodeId: target, sourceHandle };
}
Expand Down Expand Up @@ -129,7 +133,7 @@ const RUNS = 10;

describe('runGraph — replay determinism (re-execution equivalence)', () => {
it('linear A→B→C — every run produces the same activity order, events, statuses', async () => {
const input = makeInput([trigger('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'B', 'C')]);
const input = makeInput([start('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'B', 'C')]);

const records = await runNTimes(input, {}, RUNS);
expectAllRunsIdentical(records);
Expand All @@ -144,7 +148,7 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
// Promise.all resolves with results in input order. The runner reads
// them positionally, so the recorded event sequence must be identical
// even though B and C run concurrently.
const input = makeInput([trigger('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'A', 'C')]);
const input = makeInput([start('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'A', 'C')]);

const records = await runNTimes(input, {}, RUNS);
expectAllRunsIdentical(records);
Expand All @@ -156,7 +160,7 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {

it('diamond A→{B,C}→D — fan-in join sees both upstreams in deterministic order', async () => {
const input = makeInput(
[trigger('A'), trigger('B'), trigger('C'), trigger('D')],
[start('A'), trigger('B'), trigger('C'), trigger('D')],
[edge('e1', 'A', 'B'), edge('e2', 'A', 'C'), edge('e3', 'B', 'D'), edge('e4', 'C', 'D')],
);

Expand All @@ -169,7 +173,7 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
// The pruning decision in `propagate` comes from `nextPort` (data) and
// `sourceHandle` (data) — both injected, no randomness possible. Pin it.
const input = makeInput(
[trigger('D'), trigger('B'), trigger('C')],
[start('D'), trigger('B'), trigger('C')],
[edge('e1', 'D', 'B', 'X'), edge('e2', 'D', 'C', 'Y')],
);

Expand All @@ -182,7 +186,7 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
// The catch branch builds errorPayload from the thrown error. Across
// replays the activity returns the same error (cached in history), so
// the same payload must surface. Pin it.
const input = makeInput([trigger('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'B', 'C')]);
const input = makeInput([start('A'), trigger('B'), trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'B', 'C')]);

const records = await runNTimes(input, { B: { throws: { message: 'slow down', code: 'rate_limited' } } }, RUNS);
expectAllRunsIdentical(records);
Expand All @@ -198,7 +202,7 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
// switched to Set or to Object.keys (no longer Map) could re-order the
// stalled-nodes list and change the message. Pin both.
const input = makeInput(
[trigger('A'), trigger('B'), trigger('C')],
[start('A'), trigger('B'), trigger('C')],
[edge('e1', 'A', 'B'), edge('e2', 'B', 'C'), edge('e3', 'C', 'B')],
);

Expand All @@ -212,10 +216,17 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
it('asymmetric fan-in — depth-mismatched join waits for both, every run', async () => {
// The scheduler's job is exactly this case (B depth 1, Aprime depth 2,
// join at C). Replay determinism here doubles as a regression pin for
// the scheduling algorithm.
// the scheduling algorithm. S fans out to the two legs, since a second
// root is no longer a legal shape.
const input = makeInput(
[trigger('A'), trigger('Aprime'), trigger('B'), trigger('C')],
[edge('e1', 'A', 'Aprime'), edge('e2', 'Aprime', 'C'), edge('e3', 'B', 'C')],
[start('S'), trigger('A'), trigger('Aprime'), trigger('B'), trigger('C')],
[
edge('e1', 'S', 'A'),
edge('e2', 'A', 'Aprime'),
edge('e3', 'Aprime', 'C'),
edge('e4', 'S', 'B'),
edge('e5', 'B', 'C'),
],
);

const records = await runNTimes(input, {}, RUNS);
Expand Down
Loading
Loading