@xdelivered/emberflow 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +208 -16
- package/dist/server/nodesPayload.d.ts +14 -1
- package/dist/server/nodesPayload.js +15 -2
- package/dist/server/projectMode.js +5 -2
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/sourceNav.d.ts +77 -0
- package/dist/server/sourceNav.js +503 -0
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/dist/src/engine/registry.d.ts +27 -2
- package/dist/src/engine/registry.js +84 -2
- package/dist/src/nodes/index.d.ts +8 -2
- package/dist/src/nodes/index.js +7 -3
- package/dist/src/nodes/login.d.ts +3 -1
- package/dist/src/nodes/login.js +2 -2
- package/package.json +28 -7
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +213 -15
- package/server/nodeRun.test.ts +189 -0
- package/server/nodesPayload.test.ts +38 -0
- package/server/nodesPayload.ts +28 -3
- package/server/projectMode.ts +5 -2
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/sourceNav.test.ts +382 -0
- package/server/sourceNav.ts +644 -0
- package/server/sourceNavRoute.test.ts +163 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/Inspector.tsx +9 -26
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/SourceNavigator.test.tsx +226 -0
- package/src/components/SourceNavigator.tsx +520 -0
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/engine/registry.sourceRef.test.ts +112 -0
- package/src/engine/registry.ts +103 -2
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/nodes/index.ts +10 -3
- package/src/nodes/login.ts +5 -2
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +385 -314
- package/src/store/nodeMeta.ts +10 -2
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/sourceNavClient.ts +48 -0
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-BbzppDpt.js +65 -0
- package/studio-dist/assets/index-CLf6blcA.css +1 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-basics/SKILL.md +29 -1
- package/templates/skills/emberflow-model-process/SKILL.md +92 -9
- package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
- package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// End-to-end coverage of GET /source-file plus the nodesPayload sourceRef
|
|
8
|
+
// wiring, through the real runner subprocess (same harness as
|
|
9
|
+
// diagnosticsRoute.test.ts). Uses the regression fixture shape:
|
|
10
|
+
// DeriveShipmentActuals registered from nodes.mjs, whose handler imports
|
|
11
|
+
// deriveTrackingActual from shipment-logic.mjs.
|
|
12
|
+
|
|
13
|
+
let proc: ChildProcess;
|
|
14
|
+
const PORT = 8157;
|
|
15
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
16
|
+
let projectDir: string;
|
|
17
|
+
|
|
18
|
+
async function waitHealthy(url: string, tries = 40): Promise<void> {
|
|
19
|
+
for (let i = 0; i < tries; i++) {
|
|
20
|
+
try {
|
|
21
|
+
if ((await fetch(url)).ok) return;
|
|
22
|
+
} catch {
|
|
23
|
+
/* not up yet */
|
|
24
|
+
}
|
|
25
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
26
|
+
}
|
|
27
|
+
throw new Error('runner did not become healthy');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sourceFileUrl(path: string): string {
|
|
31
|
+
return `${base}/api/source-file?${new URLSearchParams({ path })}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenavroute-')));
|
|
36
|
+
const w = (rel: string, content: string): void => writeFileSync(join(projectDir, rel), content);
|
|
37
|
+
w(
|
|
38
|
+
'shipment-logic.mjs',
|
|
39
|
+
[
|
|
40
|
+
'function nestedHelper(shipment) {', // 1
|
|
41
|
+
' return (shipment.weight ?? 0) * 2;', // 2
|
|
42
|
+
'}', // 3
|
|
43
|
+
'', // 4
|
|
44
|
+
'export function deriveTrackingActual(shipment) {', // 5
|
|
45
|
+
' return nestedHelper(shipment);', // 6
|
|
46
|
+
'}', // 7
|
|
47
|
+
'',
|
|
48
|
+
].join('\n'),
|
|
49
|
+
);
|
|
50
|
+
w(
|
|
51
|
+
'nodes.mjs',
|
|
52
|
+
[
|
|
53
|
+
"import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
|
|
54
|
+
'', // 2
|
|
55
|
+
'export function registerNodes(registry) {', // 3
|
|
56
|
+
' registry.register(', // 4
|
|
57
|
+
" { type: 'DeriveShipmentActuals', label: 'Derive Shipment Actuals' },", // 5
|
|
58
|
+
' async (ctx) => ({ actual: deriveTrackingActual(ctx.input) }),', // 6
|
|
59
|
+
' );', // 7
|
|
60
|
+
'}', // 8
|
|
61
|
+
'',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
);
|
|
64
|
+
w(
|
|
65
|
+
'emberflow.config.mjs',
|
|
66
|
+
["import { registerNodes } from './nodes.mjs';", 'export default { registerNodes };', ''].join(
|
|
67
|
+
'\n',
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
w('.env', 'TOP_SECRET=shh\n');
|
|
71
|
+
|
|
72
|
+
proc = spawn('npx', ['tsx', 'server/index.ts'], {
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
EMBERFLOW_RUNNER_PORT: String(PORT),
|
|
76
|
+
EMBERFLOW_PROJECT: projectDir,
|
|
77
|
+
},
|
|
78
|
+
stdio: 'ignore',
|
|
79
|
+
});
|
|
80
|
+
await waitHealthy(`${base}/healthz`);
|
|
81
|
+
}, 20_000);
|
|
82
|
+
|
|
83
|
+
afterAll(() => {
|
|
84
|
+
proc?.kill();
|
|
85
|
+
if (projectDir) rmSync(projectDir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('GET /nodes sourceRef wiring', () => {
|
|
89
|
+
it('carries a repo-relative sourceRef for the project node and builtin flag for package nodes', async () => {
|
|
90
|
+
const res = await fetch(`${base}/api/nodes`);
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
const body = (await res.json()) as {
|
|
93
|
+
nodes: Array<{ type: string; sourceRef?: { file: string; line?: number }; builtin?: boolean }>;
|
|
94
|
+
};
|
|
95
|
+
const project = body.nodes.find((n) => n.type === 'DeriveShipmentActuals');
|
|
96
|
+
expect(project).toBeDefined();
|
|
97
|
+
expect(project!.sourceRef).toEqual({ file: 'nodes.mjs', line: 4 });
|
|
98
|
+
expect(project!.builtin).toBeUndefined();
|
|
99
|
+
|
|
100
|
+
const builtin = body.nodes.find((n) => n.type === 'ValidateCredentials');
|
|
101
|
+
expect(builtin).toBeDefined();
|
|
102
|
+
expect(builtin!.builtin).toBe(true);
|
|
103
|
+
expect(builtin!.sourceRef).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('GET /source-file', () => {
|
|
108
|
+
it('serves the handler module with the imported helper resolved to its project file', async () => {
|
|
109
|
+
const res = await fetch(sourceFileUrl('nodes.mjs'));
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const body = (await res.json()) as {
|
|
112
|
+
path: string;
|
|
113
|
+
content: string;
|
|
114
|
+
language: string;
|
|
115
|
+
symbols: {
|
|
116
|
+
imports: Array<{ local: string; resolution: { kind: string; path?: string; line?: number } }>;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
expect(body.path).toBe('nodes.mjs');
|
|
120
|
+
expect(body.language).toBe('js');
|
|
121
|
+
expect(body.content).toContain('DeriveShipmentActuals');
|
|
122
|
+
const imp = body.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
|
|
123
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('exposes declaration lines on the helper module', async () => {
|
|
127
|
+
const res = await fetch(sourceFileUrl('shipment-logic.mjs'));
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
const body = (await res.json()) as {
|
|
130
|
+
symbols: {
|
|
131
|
+
declarations: Array<{ name: string; line: number; endLine: number; exported: boolean }>;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
const derive = body.symbols.declarations.find((d) => d.name === 'deriveTrackingActual');
|
|
135
|
+
expect(derive).toMatchObject({ line: 5, endLine: 7, exported: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('400s on a traversal path without echoing it', async () => {
|
|
139
|
+
const res = await fetch(sourceFileUrl('../../etc/passwd'));
|
|
140
|
+
expect(res.status).toBe(400);
|
|
141
|
+
const body = (await res.json()) as { error: string };
|
|
142
|
+
expect(body.error).not.toContain('passwd');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('400s on secret basenames and node_modules paths', async () => {
|
|
146
|
+
for (const p of ['.env', 'node_modules/x/index.js']) {
|
|
147
|
+
const res = await fetch(sourceFileUrl(p));
|
|
148
|
+
expect(res.status).toBe(400);
|
|
149
|
+
const body = (await res.json()) as { error: string };
|
|
150
|
+
expect(body.error).not.toContain(p);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('400s when the path param is missing', async () => {
|
|
155
|
+
const res = await fetch(`${base}/api/source-file`);
|
|
156
|
+
expect(res.status).toBe(400);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('404s for a missing file inside the project', async () => {
|
|
160
|
+
const res = await fetch(sourceFileUrl('missing.mjs'));
|
|
161
|
+
expect(res.status).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { WorkflowDefinition, WorkflowNode } from '../src/engine';
|
|
3
|
+
import { createDefaultRegistry } from '../src/nodes';
|
|
4
|
+
import { RunRegistry, type RunEvent, type StepResult } from './runRegistry';
|
|
5
|
+
|
|
6
|
+
const base = { version: 1, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' };
|
|
7
|
+
|
|
8
|
+
const runOpts = { secrets: {}, vars: {}, environment: 'test', safeMode: false };
|
|
9
|
+
|
|
10
|
+
function node(id: string, type: string, extra: Partial<WorkflowNode> = {}): WorkflowNode {
|
|
11
|
+
return { id, type, label: extra.label ?? id, position: { x: 0, y: 0 }, config: {}, ...extra };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function flowStore(flows: WorkflowDefinition[]) {
|
|
15
|
+
const byId = new Map(flows.map((f) => [f.id, f]));
|
|
16
|
+
return (id: string) => byId.get(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Default registry (no artificial node delays) + deterministic test nodes. */
|
|
20
|
+
function registry() {
|
|
21
|
+
const r = createDefaultRegistry(0);
|
|
22
|
+
r.register(
|
|
23
|
+
{ type: 'double', label: 'Double', inputSchema: { fields: [{ name: 'value', type: 'number' }] } },
|
|
24
|
+
async (ctx) => ({ value: Number(ctx.input.value) * 2 }),
|
|
25
|
+
);
|
|
26
|
+
r.register({ type: 'boom', label: 'Boom' }, async () => {
|
|
27
|
+
throw new Error('kaboom');
|
|
28
|
+
});
|
|
29
|
+
return r;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Input → Double → Result. */
|
|
33
|
+
const childFlow: WorkflowDefinition = {
|
|
34
|
+
...base,
|
|
35
|
+
id: 'child-flow',
|
|
36
|
+
name: 'Child Flow',
|
|
37
|
+
nodes: [
|
|
38
|
+
node('cin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
39
|
+
node('cdouble', 'double', { inputMap: { value: { sourceNodeId: 'cin', sourceField: 'value' } } }),
|
|
40
|
+
node('cres', 'Result', { inputMap: { data: { sourceNodeId: 'cdouble', sourceField: '$' } } }),
|
|
41
|
+
],
|
|
42
|
+
edges: [
|
|
43
|
+
{ id: 'e1', source: 'cin', target: 'cdouble' },
|
|
44
|
+
{ id: 'e2', source: 'cdouble', target: 'cres' },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Input → Subflow(child) → Result. */
|
|
49
|
+
const parentFlow: WorkflowDefinition = {
|
|
50
|
+
...base,
|
|
51
|
+
id: 'parent-flow',
|
|
52
|
+
name: 'Parent Flow',
|
|
53
|
+
nodes: [
|
|
54
|
+
node('pin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
55
|
+
node('sub', 'Subflow', {
|
|
56
|
+
config: { workflowId: 'child-flow' },
|
|
57
|
+
inputMap: { value: { sourceNodeId: 'pin', sourceField: 'value' } },
|
|
58
|
+
}),
|
|
59
|
+
node('pres', 'Result', { inputMap: { data: { sourceNodeId: 'sub', sourceField: '$' } } }),
|
|
60
|
+
],
|
|
61
|
+
edges: [
|
|
62
|
+
{ id: 'e1', source: 'pin', target: 'sub' },
|
|
63
|
+
{ id: 'e2', source: 'sub', target: 'pres' },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Drives a stepped run to completion, returning every StepResult in order. */
|
|
68
|
+
async function stepToEnd(runs: RunRegistry, runId: string): Promise<StepResult[]> {
|
|
69
|
+
const seq: StepResult[] = [];
|
|
70
|
+
for (let i = 0; i < 50; i += 1) {
|
|
71
|
+
const result = await runs.step(runId);
|
|
72
|
+
expect(result).toBeDefined();
|
|
73
|
+
seq.push(result!);
|
|
74
|
+
if (result!.done) return seq;
|
|
75
|
+
}
|
|
76
|
+
throw new Error('run did not finish within 50 steps');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('subflow step drill-in', () => {
|
|
80
|
+
it('steps into and out of a child: entered marker, child steps, exited marker, same output as non-stepped', async () => {
|
|
81
|
+
const runs = new RunRegistry(flowStore([parentFlow, childFlow]), registry());
|
|
82
|
+
const { runId, handle } = runs.create(parentFlow, { ...runOpts, input: { value: 3 }, stepped: true });
|
|
83
|
+
|
|
84
|
+
const events: RunEvent[] = [];
|
|
85
|
+
runs.subscribe(runId, (e) => events.push(e));
|
|
86
|
+
|
|
87
|
+
const seq = await stepToEnd(runs, runId);
|
|
88
|
+
|
|
89
|
+
// pin → entered(sub) → cin → cdouble → cres(exits) → pres(done)
|
|
90
|
+
expect(seq).toEqual([
|
|
91
|
+
{ done: false },
|
|
92
|
+
{ done: false, entered: { workflowId: 'child-flow', nodeId: 'sub' } },
|
|
93
|
+
{ done: false },
|
|
94
|
+
{ done: false },
|
|
95
|
+
{ done: false, exited: true },
|
|
96
|
+
{ done: true },
|
|
97
|
+
]);
|
|
98
|
+
expect(handle.run.status).toBe('succeeded');
|
|
99
|
+
|
|
100
|
+
// Child node states streamed into the ROOT run's event stream, tagged
|
|
101
|
+
// with the child flow's id; parent states carry the parent flow's id.
|
|
102
|
+
const stateIds = events
|
|
103
|
+
.filter((e) => e.type === 'nodeState')
|
|
104
|
+
.map((e) => (e.type === 'nodeState' ? `${e.workflowId}:${e.nodeId}` : ''));
|
|
105
|
+
expect(stateIds).toContain('child-flow:cdouble');
|
|
106
|
+
expect(stateIds).toContain('parent-flow:sub');
|
|
107
|
+
expect(stateIds).not.toContain('parent-flow:cdouble');
|
|
108
|
+
|
|
109
|
+
// Output identical to a non-stepped run of the same flow.
|
|
110
|
+
const plain = runs.create(parentFlow, { ...runOpts, input: { value: 3 } });
|
|
111
|
+
await plain.handle.runToEnd();
|
|
112
|
+
expect(plain.handle.run.status).toBe('succeeded');
|
|
113
|
+
expect(handle.run.nodeStates.pres.output).toEqual(plain.handle.run.nodeStates.pres.output);
|
|
114
|
+
expect(handle.run.nodeStates.pres.output).toEqual({ data: { data: { value: 6 } } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports done on the exit step when the Subflow is the parent’s last node (pending-step fold)', async () => {
|
|
118
|
+
const tailFlow: WorkflowDefinition = {
|
|
119
|
+
...base,
|
|
120
|
+
id: 'tail-flow',
|
|
121
|
+
name: 'Tail Flow',
|
|
122
|
+
nodes: [
|
|
123
|
+
node('pin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
124
|
+
node('sub', 'Subflow', {
|
|
125
|
+
config: { workflowId: 'child-flow' },
|
|
126
|
+
inputMap: { value: { sourceNodeId: 'pin', sourceField: 'value' } },
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
edges: [{ id: 'e1', source: 'pin', target: 'sub' }],
|
|
130
|
+
};
|
|
131
|
+
const runs = new RunRegistry(flowStore([tailFlow, childFlow]), registry());
|
|
132
|
+
const { runId, handle } = runs.create(tailFlow, { ...runOpts, input: { value: 2 }, stepped: true });
|
|
133
|
+
|
|
134
|
+
const seq = await stepToEnd(runs, runId);
|
|
135
|
+
// The step that pops the child must fold the parent's pending step()
|
|
136
|
+
// boolean in: the run is over, so done arrives WITH the exit, not late.
|
|
137
|
+
expect(seq[seq.length - 1]).toEqual({ done: true, exited: true });
|
|
138
|
+
expect(handle.run.status).toBe('succeeded');
|
|
139
|
+
expect(handle.run.nodeStates.sub.output).toEqual({ data: { value: 4 } });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('nests: child-in-child yields entered/entered/exited/exited ordering', async () => {
|
|
143
|
+
const midFlow: WorkflowDefinition = {
|
|
144
|
+
...base,
|
|
145
|
+
id: 'mid-flow',
|
|
146
|
+
name: 'Mid Flow',
|
|
147
|
+
nodes: [
|
|
148
|
+
node('min', 'Input', { config: { fields: [], defaults: {} } }),
|
|
149
|
+
node('msub', 'Subflow', {
|
|
150
|
+
config: { workflowId: 'child-flow' },
|
|
151
|
+
inputMap: { value: { sourceNodeId: 'min', sourceField: 'value' } },
|
|
152
|
+
}),
|
|
153
|
+
node('mres', 'Result', { inputMap: { data: { sourceNodeId: 'msub', sourceField: '$' } } }),
|
|
154
|
+
],
|
|
155
|
+
edges: [
|
|
156
|
+
{ id: 'e1', source: 'min', target: 'msub' },
|
|
157
|
+
{ id: 'e2', source: 'msub', target: 'mres' },
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
const topFlow: WorkflowDefinition = {
|
|
161
|
+
...base,
|
|
162
|
+
id: 'top-flow',
|
|
163
|
+
name: 'Top Flow',
|
|
164
|
+
nodes: [
|
|
165
|
+
node('tin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
166
|
+
node('tsub', 'Subflow', {
|
|
167
|
+
config: { workflowId: 'mid-flow' },
|
|
168
|
+
inputMap: { value: { sourceNodeId: 'tin', sourceField: 'value' } },
|
|
169
|
+
}),
|
|
170
|
+
node('tres', 'Result', { inputMap: { data: { sourceNodeId: 'tsub', sourceField: '$' } } }),
|
|
171
|
+
],
|
|
172
|
+
edges: [
|
|
173
|
+
{ id: 'e1', source: 'tin', target: 'tsub' },
|
|
174
|
+
{ id: 'e2', source: 'tsub', target: 'tres' },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const runs = new RunRegistry(flowStore([topFlow, midFlow, childFlow]), registry());
|
|
179
|
+
const { runId, handle } = runs.create(topFlow, { ...runOpts, input: { value: 1 }, stepped: true });
|
|
180
|
+
|
|
181
|
+
const seq = await stepToEnd(runs, runId);
|
|
182
|
+
const markers = seq
|
|
183
|
+
.filter((s) => s.entered || s.exited)
|
|
184
|
+
.map((s) => (s.entered ? { entered: s.entered } : { exited: true }));
|
|
185
|
+
expect(markers).toEqual([
|
|
186
|
+
{ entered: { workflowId: 'mid-flow', nodeId: 'tsub' } },
|
|
187
|
+
{ entered: { workflowId: 'child-flow', nodeId: 'msub' } },
|
|
188
|
+
{ exited: true },
|
|
189
|
+
{ exited: true },
|
|
190
|
+
]);
|
|
191
|
+
expect(handle.run.status).toBe('succeeded');
|
|
192
|
+
expect(handle.run.nodeStates.tres.output).toEqual({ data: { data: { data: { value: 2 } } } });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('a child failure mid-step fails the parent Subflow node with the child’s error, identically to non-stepped', async () => {
|
|
196
|
+
const boomChild: WorkflowDefinition = {
|
|
197
|
+
...base,
|
|
198
|
+
id: 'child-flow',
|
|
199
|
+
name: 'Child Boom',
|
|
200
|
+
nodes: [
|
|
201
|
+
node('cin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
202
|
+
node('cboom', 'boom'),
|
|
203
|
+
],
|
|
204
|
+
edges: [{ id: 'e1', source: 'cin', target: 'cboom' }],
|
|
205
|
+
};
|
|
206
|
+
const runs = new RunRegistry(flowStore([parentFlow, boomChild]), registry());
|
|
207
|
+
const { runId, handle } = runs.create(parentFlow, { ...runOpts, input: { value: 3 }, stepped: true });
|
|
208
|
+
|
|
209
|
+
const seq = await stepToEnd(runs, runId);
|
|
210
|
+
// pin → entered → cin → cboom (child fails; exit folds the parent's
|
|
211
|
+
// failure in, so done arrives with the exit)
|
|
212
|
+
expect(seq[1]).toEqual({ done: false, entered: { workflowId: 'child-flow', nodeId: 'sub' } });
|
|
213
|
+
expect(seq[seq.length - 1]).toEqual({ done: true, exited: true });
|
|
214
|
+
expect(handle.run.status).toBe('failed');
|
|
215
|
+
expect(handle.run.nodeStates.sub.status).toBe('failed');
|
|
216
|
+
expect(handle.run.nodeStates.pres.status).toBe('skipped');
|
|
217
|
+
|
|
218
|
+
// Same error text as a non-stepped run of the same flows.
|
|
219
|
+
const plain = runs.create(parentFlow, { ...runOpts, input: { value: 3 } });
|
|
220
|
+
await plain.handle.runToEnd();
|
|
221
|
+
expect(plain.handle.run.status).toBe('failed');
|
|
222
|
+
expect(handle.run.nodeStates.sub.error).toBe(plain.handle.run.nodeStates.sub.error);
|
|
223
|
+
expect(handle.run.nodeStates.sub.error).toBe('subflow "Child Boom" failed: kaboom');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('a Subflow node with retry re-enters the child after a failed attempt (exited+entered), without hanging', async () => {
|
|
227
|
+
// Child whose work node fails on the FIRST execution only.
|
|
228
|
+
let flakyCalls = 0;
|
|
229
|
+
const r = registry();
|
|
230
|
+
r.register({ type: 'flaky', label: 'Flaky' }, async () => {
|
|
231
|
+
flakyCalls += 1;
|
|
232
|
+
if (flakyCalls === 1) throw new Error('first try fails');
|
|
233
|
+
return { ok: true };
|
|
234
|
+
});
|
|
235
|
+
const flakyChild: WorkflowDefinition = {
|
|
236
|
+
...base,
|
|
237
|
+
id: 'child-flow',
|
|
238
|
+
name: 'Flaky Child',
|
|
239
|
+
nodes: [
|
|
240
|
+
node('cin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
241
|
+
node('cflaky', 'flaky'),
|
|
242
|
+
],
|
|
243
|
+
edges: [{ id: 'e1', source: 'cin', target: 'cflaky' }],
|
|
244
|
+
};
|
|
245
|
+
const retryParent: WorkflowDefinition = {
|
|
246
|
+
...parentFlow,
|
|
247
|
+
nodes: parentFlow.nodes.map((n) =>
|
|
248
|
+
n.id === 'sub' ? { ...n, retry: { maxTries: 2, waitMs: 0 } } : n,
|
|
249
|
+
),
|
|
250
|
+
};
|
|
251
|
+
const runs = new RunRegistry(flowStore([retryParent, flakyChild]), r);
|
|
252
|
+
const { runId, handle } = runs.create(retryParent, { ...runOpts, input: { value: 3 }, stepped: true });
|
|
253
|
+
|
|
254
|
+
const seq = await stepToEnd(runs, runId);
|
|
255
|
+
// pin → entered → cin → cflaky fails (child run failed; the retrying
|
|
256
|
+
// Subflow node immediately re-runs the child: exited + entered in ONE
|
|
257
|
+
// step) → cin → cflaky succeeds (exited) → pres → done.
|
|
258
|
+
expect(seq).toEqual([
|
|
259
|
+
{ done: false },
|
|
260
|
+
{ done: false, entered: { workflowId: 'child-flow', nodeId: 'sub' } },
|
|
261
|
+
{ done: false },
|
|
262
|
+
{ done: false, exited: true, entered: { workflowId: 'child-flow', nodeId: 'sub' } },
|
|
263
|
+
{ done: false },
|
|
264
|
+
{ done: false, exited: true },
|
|
265
|
+
{ done: true },
|
|
266
|
+
]);
|
|
267
|
+
expect(flakyCalls).toBe(2);
|
|
268
|
+
expect(handle.run.status).toBe('succeeded');
|
|
269
|
+
expect(handle.run.nodeStates.sub.status).toBe('succeeded');
|
|
270
|
+
expect(handle.run.nodeStates.sub.attempts).toBe(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('a node making two sequential ctx.runSubflow calls drills both children in turn', async () => {
|
|
274
|
+
const r = registry();
|
|
275
|
+
r.register({ type: 'twice', label: 'Twice' }, async (ctx) => {
|
|
276
|
+
const a = await ctx.runSubflow!('child-flow', { value: 1 });
|
|
277
|
+
const b = await ctx.runSubflow!('child-flow', { value: 10 });
|
|
278
|
+
if (a.status === 'failed' || b.status === 'failed') throw new Error('subflow failed');
|
|
279
|
+
return { a: a.output, b: b.output };
|
|
280
|
+
});
|
|
281
|
+
const twiceParent: WorkflowDefinition = {
|
|
282
|
+
...base,
|
|
283
|
+
id: 'twice-flow',
|
|
284
|
+
name: 'Twice Flow',
|
|
285
|
+
nodes: [
|
|
286
|
+
node('pin', 'Input', { config: { fields: [], defaults: {} } }),
|
|
287
|
+
node('tw', 'twice'),
|
|
288
|
+
],
|
|
289
|
+
edges: [{ id: 'e1', source: 'pin', target: 'tw' }],
|
|
290
|
+
};
|
|
291
|
+
const runs = new RunRegistry(flowStore([twiceParent, childFlow]), r);
|
|
292
|
+
const { runId, handle } = runs.create(twiceParent, { ...runOpts, input: {}, stepped: true });
|
|
293
|
+
|
|
294
|
+
const seq = await stepToEnd(runs, runId);
|
|
295
|
+
const markers = seq
|
|
296
|
+
.filter((s) => s.entered || s.exited)
|
|
297
|
+
.map((s) => ({ ...(s.entered ? { entered: s.entered.nodeId } : {}), ...(s.exited ? { exited: true } : {}) }));
|
|
298
|
+
// First child completes and the SAME step enters the second child.
|
|
299
|
+
expect(markers).toEqual([
|
|
300
|
+
{ entered: 'tw' },
|
|
301
|
+
{ exited: true, entered: 'tw' },
|
|
302
|
+
{ exited: true },
|
|
303
|
+
]);
|
|
304
|
+
expect(handle.run.status).toBe('succeeded');
|
|
305
|
+
expect(handle.run.nodeStates.tw.output).toEqual({
|
|
306
|
+
a: { data: { value: 2 } },
|
|
307
|
+
b: { data: { value: 20 } },
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('cancel mid-drill cancels the stacked child; later steps report done and no child nodeState follows finished', async () => {
|
|
312
|
+
const runs = new RunRegistry(flowStore([parentFlow, childFlow]), registry());
|
|
313
|
+
const { runId, handle } = runs.create(parentFlow, { ...runOpts, input: { value: 3 }, stepped: true });
|
|
314
|
+
|
|
315
|
+
const events: RunEvent[] = [];
|
|
316
|
+
runs.subscribe(runId, (e) => events.push(e));
|
|
317
|
+
|
|
318
|
+
// pin → entered(sub) → cin: cancelled while drilled into the child.
|
|
319
|
+
expect(await runs.step(runId)).toEqual({ done: false });
|
|
320
|
+
expect(await runs.step(runId)).toEqual({
|
|
321
|
+
done: false,
|
|
322
|
+
entered: { workflowId: 'child-flow', nodeId: 'sub' },
|
|
323
|
+
});
|
|
324
|
+
expect(await runs.step(runId)).toEqual({ done: false });
|
|
325
|
+
|
|
326
|
+
expect(runs.cancel(runId)).toBe(true);
|
|
327
|
+
expect(handle.run.status).toBe('cancelled');
|
|
328
|
+
|
|
329
|
+
// Stepping after cancel executes nothing and reports done.
|
|
330
|
+
expect(await runs.step(runId)).toEqual({ done: true });
|
|
331
|
+
expect(await runs.step(runId)).toEqual({ done: true });
|
|
332
|
+
|
|
333
|
+
// Let the rejected parent execution unwind in the background, then check
|
|
334
|
+
// nothing leaked past `finished`: exactly one finished event (status
|
|
335
|
+
// cancelled) and zero child nodeState events after it.
|
|
336
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
337
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
338
|
+
const finishedIdx = events.findIndex((e) => e.type === 'finished');
|
|
339
|
+
expect(finishedIdx).toBeGreaterThan(-1);
|
|
340
|
+
expect(events.filter((e) => e.type === 'finished')).toHaveLength(1);
|
|
341
|
+
const finished = events[finishedIdx];
|
|
342
|
+
expect(finished.type === 'finished' && finished.run.status).toBe('cancelled');
|
|
343
|
+
const childStatesAfter = events
|
|
344
|
+
.slice(finishedIdx + 1)
|
|
345
|
+
.filter((e) => e.type === 'nodeState' && e.workflowId === 'child-flow');
|
|
346
|
+
expect(childStatesAfter).toEqual([]);
|
|
347
|
+
|
|
348
|
+
// cancel of an unknown run reports false (route 404s).
|
|
349
|
+
expect(runs.cancel('nope')).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('concurrent step() calls serialize: both resolve, in order, as two sequential steps', async () => {
|
|
353
|
+
const runs = new RunRegistry(flowStore([parentFlow, childFlow]), registry());
|
|
354
|
+
const { runId } = runs.create(parentFlow, { ...runOpts, input: { value: 3 }, stepped: true });
|
|
355
|
+
|
|
356
|
+
// Fired without awaiting between: without the per-run mutex the second
|
|
357
|
+
// call overwrites onChildStarted and the first request hangs.
|
|
358
|
+
const [r1, r2] = await Promise.all([runs.step(runId), runs.step(runId)]);
|
|
359
|
+
expect(r1).toEqual({ done: false }); // pin
|
|
360
|
+
expect(r2).toEqual({ done: false, entered: { workflowId: 'child-flow', nodeId: 'sub' } });
|
|
361
|
+
|
|
362
|
+
const rest = await stepToEnd(runs, runId);
|
|
363
|
+
expect(rest[rest.length - 1]).toEqual({ done: true });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('non-stepped runs keep today’s behavior: no drill markers, child node states not streamed', async () => {
|
|
367
|
+
const runs = new RunRegistry(flowStore([parentFlow, childFlow]), registry());
|
|
368
|
+
const { runId, handle } = runs.create(parentFlow, { ...runOpts, input: { value: 5 } });
|
|
369
|
+
|
|
370
|
+
const events: RunEvent[] = [];
|
|
371
|
+
runs.subscribe(runId, (e) => events.push(e));
|
|
372
|
+
|
|
373
|
+
const seq = await stepToEnd(runs, runId);
|
|
374
|
+
expect(seq.every((s) => !s.entered && !s.exited)).toBe(true);
|
|
375
|
+
expect(handle.run.status).toBe('succeeded');
|
|
376
|
+
expect(
|
|
377
|
+
events.some((e) => e.type === 'nodeState' && e.workflowId === 'child-flow'),
|
|
378
|
+
).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
});
|
package/server/subflowRunner.ts
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
FlowRun,
|
|
2
3
|
LogLine,
|
|
3
4
|
NodeRegistry,
|
|
5
|
+
NodeRunState,
|
|
4
6
|
StartRunOptions,
|
|
5
7
|
SubflowResult,
|
|
6
8
|
TraceSink,
|
|
7
9
|
WorkflowDefinition,
|
|
10
|
+
WorkflowRun,
|
|
8
11
|
} from '../src/engine';
|
|
9
12
|
import { runOutput, startRun } from '../src/engine';
|
|
10
13
|
|
|
11
14
|
/** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
|
|
12
15
|
export const SUBFLOW_DEPTH_CAP = 8;
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* One live drilled-into child on a stepped run. Pushed by the subflow runner
|
|
19
|
+
* when a Subflow node starts its child; popped by the registry's composite
|
|
20
|
+
* step() when the child's own stepping completes. `resolve`/`reject` settle
|
|
21
|
+
* the promise the parent's Subflow node execution is blocked on.
|
|
22
|
+
*/
|
|
23
|
+
export interface DrillEntry {
|
|
24
|
+
handle: FlowRun;
|
|
25
|
+
/** The child flow's id (what the caller drilled into). */
|
|
26
|
+
workflowId: string;
|
|
27
|
+
/** The parent Subflow node that triggered the child. */
|
|
28
|
+
viaNodeId: string;
|
|
29
|
+
/** Hands the completed child run back to the blocked parent Subflow node. */
|
|
30
|
+
resolve: (run: WorkflowRun) => void;
|
|
31
|
+
reject: (err: unknown) => void;
|
|
32
|
+
/**
|
|
33
|
+
* This level's own executor.step() promise, left pending while a DEEPER
|
|
34
|
+
* child runs (a Subflow node's execution blocks on its child). Stashed by
|
|
35
|
+
* the registry when it observes `entered`; folded back in on exit.
|
|
36
|
+
*/
|
|
37
|
+
pendingStep?: Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per-root-run drill state for stepped runs. The root's subflow runner and
|
|
42
|
+
* every nested child's runner share the SAME instance, so grandchildren push
|
|
43
|
+
* onto the same stack and nesting Just Works.
|
|
44
|
+
*/
|
|
45
|
+
export interface DrillState {
|
|
46
|
+
stack: DrillEntry[];
|
|
47
|
+
/** Armed by the registry's step() while it awaits a level's step, so a
|
|
48
|
+
* child starting can be raced against the (now blocked) step promise. */
|
|
49
|
+
onChildStarted?: (entry: DrillEntry) => void;
|
|
50
|
+
/** Set by RunRegistry.cancel(): unwinding parent executions must not start
|
|
51
|
+
* (or block on) new children — runSubflow fails fast instead. */
|
|
52
|
+
cancelled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
export interface SubflowRunnerOptions {
|
|
15
56
|
/** Resolves a child workflow by id (server hosts this via apiStore.load). */
|
|
16
57
|
loadFlow: (id: string) => WorkflowDefinition | undefined;
|
|
@@ -29,6 +70,17 @@ export interface SubflowRunnerOptions {
|
|
|
29
70
|
* prefixed with the child flow's name and the caller node's id, exactly as
|
|
30
71
|
* the live runner streams them over SSE. Absent for headless runs. */
|
|
31
72
|
onLog?: (line: LogLine) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Present only for stepped runs: children become step-drivable instead of
|
|
75
|
+
* running to completion. The runner pushes each started child onto
|
|
76
|
+
* `drill.stack` and blocks the Subflow node until the registry pops it.
|
|
77
|
+
*/
|
|
78
|
+
drill?: DrillState;
|
|
79
|
+
/** Child nodeState forwarder for stepped runs — receives the CHILD flow's
|
|
80
|
+
* id so the client can route states to the drilled-in view. Only wired
|
|
81
|
+
* when `drill` is present (non-stepped children keep today's behavior:
|
|
82
|
+
* their node states are discarded, only the output returns). */
|
|
83
|
+
onNodeState?: (workflowId: string, nodeId: string, state: NodeRunState) => void;
|
|
32
84
|
}
|
|
33
85
|
|
|
34
86
|
/**
|
|
@@ -45,6 +97,11 @@ export function makeSubflowRunner(
|
|
|
45
97
|
ancestry: string[],
|
|
46
98
|
): NonNullable<StartRunOptions['subflowRunner']> {
|
|
47
99
|
return async (workflowId, input, callerNodeId): Promise<SubflowResult> => {
|
|
100
|
+
// A cancelled stepped run's unwinding parents (their runSubflow was
|
|
101
|
+
// rejected) may retry: never push a new child that nothing will step.
|
|
102
|
+
if (opts.drill?.cancelled) {
|
|
103
|
+
return { status: 'failed', error: 'run cancelled' };
|
|
104
|
+
}
|
|
48
105
|
if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
|
|
49
106
|
return { status: 'failed', error: `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded` };
|
|
50
107
|
}
|
|
@@ -72,18 +129,42 @@ export function makeSubflowRunner(
|
|
|
72
129
|
mocks: (childFlow as { mocks?: Record<string, unknown> }).mocks ?? {},
|
|
73
130
|
trace: opts.trace,
|
|
74
131
|
subflowRunner: makeSubflowRunner(opts, [...ancestry, workflowId]),
|
|
75
|
-
events:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
opts.onLog
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
132
|
+
events:
|
|
133
|
+
opts.onLog || (opts.drill && opts.onNodeState)
|
|
134
|
+
? {
|
|
135
|
+
...(opts.onLog
|
|
136
|
+
? {
|
|
137
|
+
onLog: (line: LogLine) =>
|
|
138
|
+
opts.onLog!({
|
|
139
|
+
...line,
|
|
140
|
+
nodeLabel: `${childFlow.name} › ${line.nodeLabel ?? ''}`,
|
|
141
|
+
nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
// Stepped children stream their node states into the ROOT
|
|
146
|
+
// run's SSE stream, tagged with the child flow's id.
|
|
147
|
+
...(opts.drill && opts.onNodeState
|
|
148
|
+
? {
|
|
149
|
+
onNodeStateChange: (nodeId: string, state: NodeRunState) =>
|
|
150
|
+
opts.onNodeState!(childFlow.id, nodeId, state),
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
}
|
|
154
|
+
: undefined,
|
|
85
155
|
});
|
|
86
|
-
|
|
156
|
+
// Stepped run: don't run the child here. Push it onto the shared drill
|
|
157
|
+
// stack and block this Subflow node until the registry's composite
|
|
158
|
+
// step() drives the child to completion and pops the entry — resolving
|
|
159
|
+
// with the completed child run, which flows into the exact same
|
|
160
|
+
// output-extraction / failure-mapping path runToEnd feeds today.
|
|
161
|
+
const childRun = opts.drill
|
|
162
|
+
? await new Promise<WorkflowRun>((resolve, reject) => {
|
|
163
|
+
const entry: DrillEntry = { handle: childHandle, workflowId, viaNodeId: callerNodeId, resolve, reject };
|
|
164
|
+
opts.drill!.stack.push(entry);
|
|
165
|
+
opts.drill!.onChildStarted?.(entry);
|
|
166
|
+
})
|
|
167
|
+
: await childHandle.runToEnd();
|
|
87
168
|
if (childRun.status !== 'succeeded') {
|
|
88
169
|
// Surface the child's actual failure (e.g. the Mock-mode "would touch
|
|
89
170
|
// real infrastructure" boundary message) through the Subflow node's
|