@xdelivered/emberflow 0.2.0 → 0.4.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.
Files changed (88) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -5,11 +5,13 @@ import type {
5
5
  import { InMemoryTraceSink, startRun, type FlowRun, type NodeRegistry } from '../src/engine';
6
6
  import { createDefaultRegistry } from '../src/nodes';
7
7
  import { redactSecrets } from './redact';
8
- import { makeSubflowRunner } from './subflowRunner';
8
+ import { makeSubflowRunner, type DrillEntry, type DrillState } from './subflowRunner';
9
9
 
10
10
  /** SSE-shaped events, mirroring ExecutorEvents 1:1. */
11
11
  export type RunEvent =
12
- | { type: 'nodeState'; nodeId: string; state: NodeRunState }
12
+ /** `workflowId` is the flow the node belongs to: the root flow for the
13
+ * run's own nodes, the CHILD flow's id for a stepped subflow's nodes. */
14
+ | { type: 'nodeState'; workflowId: string; nodeId: string; state: NodeRunState }
13
15
  | { type: 'log'; line: LogLine }
14
16
  | {
15
17
  type: 'finished';
@@ -27,6 +29,25 @@ interface LiveRun {
27
29
  listeners: Set<Listener>;
28
30
  finished: boolean;
29
31
  cleanupTimer?: ReturnType<typeof setTimeout>;
32
+ /** Serializes step() calls per run: a second concurrent step() would
33
+ * re-arm `state.onChildStarted` (last writer wins) and double-drive the
34
+ * executor, hanging the first caller. */
35
+ stepChain?: Promise<unknown>;
36
+ /** Present only for stepped runs: subflow drill-in state (see step()). */
37
+ drill?: {
38
+ state: DrillState;
39
+ /** The ROOT executor's step() promise left pending while a child runs. */
40
+ rootPendingStep?: Promise<boolean>;
41
+ };
42
+ }
43
+
44
+ /** Result of one composite step on a stepped run (see RunRegistry.step). */
45
+ export interface StepResult {
46
+ done: boolean;
47
+ /** Set when this step drove execution INTO a subflow child. */
48
+ entered?: { workflowId: string; nodeId: string };
49
+ /** Set when this step completed the deepest child and popped back out. */
50
+ exited?: true;
30
51
  }
31
52
 
32
53
  const RETAIN_FINISHED_MS = 10 * 60 * 1000; // 10 minutes
@@ -93,6 +114,10 @@ export class RunRegistry {
93
114
  * error-handler run — stamped onto this run's `finished` event as
94
115
  * `errorHandler: { firedBy }`. Only meaningful alongside isErrorHandler. */
95
116
  errorHandlerFiredBy?: string;
117
+ /** When true (a step-mode run), Subflow children become step-drivable:
118
+ * entering one pauses inside it instead of running it to completion in
119
+ * a single opaque step. Drive the run via `step(runId)`. */
120
+ stepped?: boolean;
96
121
  },
97
122
  ): { runId: string; handle: FlowRun } {
98
123
  this.evictIfNeeded();
@@ -116,6 +141,11 @@ export class RunRegistry {
116
141
  // the child via loadFlow, runs it to completion on the same registry
117
142
  // sharing the parent's environment, and forwards its (prefixed) logs into
118
143
  // this run's SSE stream via `onLog`.
144
+ // Stepped runs share ONE drill state across the root's runner and every
145
+ // nested child's runner (makeSubflowRunner threads `opts` through), so a
146
+ // grandchild pushes onto the same stack and nesting works.
147
+ const drillState: DrillState | undefined = opts.stepped ? { stack: [] } : undefined;
148
+
119
149
  const subflowRunner = makeSubflowRunner(
120
150
  {
121
151
  loadFlow: (id) => this.loadFlow?.(id),
@@ -127,6 +157,18 @@ export class RunRegistry {
127
157
  mockRun: opts.mockRun,
128
158
  trace: this.sink,
129
159
  onLog: (line) => emit({ type: 'log', line }),
160
+ drill: drillState,
161
+ // Stepped children stream node states into this run's SSE stream,
162
+ // tagged with the CHILD flow's id so the client can route them.
163
+ // After a drill-aware cancel the rejected parent executions unwind in
164
+ // the background (recording their Subflow nodes as failed) — child
165
+ // states from that unwinding must not reach subscribers post-finish.
166
+ onNodeState: drillState
167
+ ? (workflowId, nodeId, state) => {
168
+ if (drillState.cancelled) return;
169
+ emit({ type: 'nodeState', workflowId, nodeId, state });
170
+ }
171
+ : undefined,
130
172
  },
131
173
  [flow.id],
132
174
  );
@@ -145,9 +187,14 @@ export class RunRegistry {
145
187
  trace: this.sink,
146
188
  subflowRunner,
147
189
  events: {
148
- onNodeStateChange: (nodeId, state) => emit({ type: 'nodeState', nodeId, state }),
190
+ onNodeStateChange: (nodeId, state) =>
191
+ emit({ type: 'nodeState', workflowId: flow.id, nodeId, state }),
149
192
  onLog: (line) => emit({ type: 'log', line }),
150
193
  onRunFinished: (run) => {
194
+ // Idempotent: after a drill-aware cancel the root executor's pending
195
+ // Subflow execution can fail in the background and re-finish the run
196
+ // — subscribers already saw the cancelled `finished` event.
197
+ if (entry.finished) return;
151
198
  emit({
152
199
  type: 'finished',
153
200
  run,
@@ -163,10 +210,160 @@ export class RunRegistry {
163
210
  });
164
211
 
165
212
  entry.handle = handle;
213
+ if (drillState) entry.drill = { state: drillState };
166
214
  this.runs.set(handle.run.id, entry);
167
215
  return { runId: handle.run.id, handle };
168
216
  }
169
217
 
218
+ /**
219
+ * One composite step of a run, drill-aware. For stepped runs a Subflow node
220
+ * is no longer one opaque step: entering it pauses inside the child
221
+ * (`entered`), subsequent steps drive the child's nodes, and completing the
222
+ * child pops back out (`exited`). Non-stepped runs (no drill state) fall
223
+ * back to a plain executor step. Unknown run → undefined.
224
+ */
225
+ async step(runId: string): Promise<StepResult | undefined> {
226
+ const entry = this.runs.get(runId);
227
+ if (!entry) return undefined;
228
+ // Serialize per run: concurrent step() calls would each arm
229
+ // `state.onChildStarted` (last writer wins) and double-drive the
230
+ // executor, so the second caller waits for the first to settle.
231
+ const result = (entry.stepChain ?? Promise.resolve()).then(() => this.stepLevels(entry));
232
+ entry.stepChain = result.catch(() => undefined);
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * One composite step, drill-aware. Structured as a loop over levels: each
238
+ * pass drives the current deepest level (a pending stashed step, or a fresh
239
+ * executor step) while racing "a new child started". Popping a finished
240
+ * child continues the loop on its parent's stashed step WITH the race still
241
+ * armed — the parent's still-pending Subflow node execution may call
242
+ * runSubflow again (retry, or a custom node making sequential calls), and
243
+ * that re-entry must surface as `entered`, not deadlock the fold.
244
+ */
245
+ private async stepLevels(entry: LiveRun): Promise<StepResult> {
246
+ const drill = entry.drill;
247
+ if (!drill) {
248
+ const more = await entry.handle.step();
249
+ return { done: !more };
250
+ }
251
+
252
+ const { state } = drill;
253
+ // Cancelled mid-drill: the stack was already unwound by cancel(); report
254
+ // done without driving anything.
255
+ if (state.cancelled) return { done: true };
256
+
257
+ // Set once this step pops a child; carried onto whatever this step
258
+ // ultimately reports (including an `entered` for a re-run child).
259
+ let exited = false;
260
+ const withExit = (r: StepResult): StepResult => (exited ? { ...r, exited: true } : r);
261
+
262
+ for (;;) {
263
+ // The level being stepped: the deepest drilled-in child, or the root run.
264
+ const level = state.stack.length > 0 ? state.stack[state.stack.length - 1] : undefined;
265
+ const handle = level ? level.handle : entry.handle;
266
+ const pendingStep = level ? level.pendingStep : drill.rootPendingStep;
267
+ const setPending = (p: Promise<boolean> | undefined): void => {
268
+ if (level) level.pendingStep = p;
269
+ else drill.rootPendingStep = p;
270
+ };
271
+
272
+ // A Subflow node's execution blocks inside runSubflow until its child
273
+ // completes, so the step promise alone can never report "entered a
274
+ // child" — arm the child-started signal BEFORE awaiting and race them.
275
+ const childStarted = new Promise<DrillEntry>((res) => {
276
+ state.onChildStarted = res;
277
+ });
278
+ const stepPromise = pendingStep ?? handle.step();
279
+ setPending(undefined);
280
+
281
+ type Raced = { kind: 'stepped'; more: boolean } | { kind: 'entered'; child: DrillEntry };
282
+ let raced: Raced;
283
+ try {
284
+ raced = await Promise.race([
285
+ stepPromise.then((more): Raced => ({ kind: 'stepped', more })),
286
+ childStarted.then((child): Raced => ({ kind: 'entered', child })),
287
+ ]);
288
+ } catch (err) {
289
+ state.onChildStarted = undefined;
290
+ if (!level) throw err;
291
+ // A child level's step threw outright (not a recorded node failure —
292
+ // the executor catches those). Reject the parent's blocked runSubflow,
293
+ // which surfaces it as the Subflow node's error, then fold the parent
294
+ // (next loop pass, race re-armed).
295
+ state.stack.pop();
296
+ level.reject(err);
297
+ exited = true;
298
+ continue;
299
+ }
300
+ state.onChildStarted = undefined;
301
+
302
+ if (raced.kind === 'entered') {
303
+ // The level's step stays pending for the whole child run — stash it;
304
+ // the step that later pops this child folds its boolean back in.
305
+ setPending(stepPromise);
306
+ return withExit({
307
+ done: false,
308
+ entered: { workflowId: raced.child.workflowId, nodeId: raced.child.viaNodeId },
309
+ });
310
+ }
311
+
312
+ if (raced.more) return withExit({ done: false });
313
+
314
+ // This level has no more nodes: its run is complete.
315
+ if (!level) return withExit({ done: true });
316
+
317
+ // Pop the finished child and hand its completed run (succeeded OR failed)
318
+ // to the parent's blocked runSubflow — the success/failure semantics live
319
+ // in subflowRunner, byte-identical to the non-stepped path. The parent's
320
+ // stashed step is folded in by the next loop pass, with the child-started
321
+ // race re-armed so a retrying Subflow node re-entering its child yields
322
+ // `exited` + `entered` instead of hanging.
323
+ state.stack.pop();
324
+ level.resolve(level.handle.run);
325
+ exited = true;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Cancel a run, drill-aware. For a stepped run drilled into subflows this
331
+ * cancels every stacked child deepest-first and rejects the promise each
332
+ * parent's Subflow node execution is blocked on — those pending executions
333
+ * then unwind in the background exactly like an in-flight node after a
334
+ * plain root cancel today (recorded, but the run is already finished).
335
+ * step() after cancel reports { done: true } without executing anything.
336
+ * Returns false for an unknown run.
337
+ */
338
+ /** Cancels every live (non-finished) run — the server's shutdown path, so
339
+ * in-flight executions (and their drilled subflow children) don't keep
340
+ * running headless after the process is asked to die. */
341
+ shutdown(): void {
342
+ for (const [id, run] of this.runs) {
343
+ if (!run.finished) this.cancel(id);
344
+ }
345
+ }
346
+
347
+ cancel(runId: string): boolean {
348
+ const entry = this.runs.get(runId);
349
+ if (!entry) return false;
350
+ const drill = entry.drill;
351
+ if (drill) {
352
+ const { state } = drill;
353
+ state.cancelled = true;
354
+ state.onChildStarted = undefined;
355
+ while (state.stack.length > 0) {
356
+ const child = state.stack.pop()!;
357
+ child.pendingStep = undefined;
358
+ child.handle.cancel();
359
+ child.reject(new Error('run cancelled'));
360
+ }
361
+ drill.rootPendingStep = undefined;
362
+ }
363
+ entry.handle.cancel();
364
+ return true;
365
+ }
366
+
170
367
  /**
171
368
  * Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
172
369
  * callback Subflow nodes use) as a new run when `failedRun` finished with
@@ -78,6 +78,9 @@ describe('GET /setup-status', () => {
78
78
  const body = await res.json();
79
79
 
80
80
  expect(Array.isArray(body.agents)).toBe(true);
81
+ // The temp project dir isn't inside a git repo — reported as such so the
82
+ // checklist can gate the agent features on it.
83
+ expect(body.git).toEqual({ repo: false });
81
84
  // A fresh project has no environments file → the synthesized fallback is
82
85
  // reported as unconfigured with zero real environments.
83
86
  expect(body.environments).toMatchObject({
@@ -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
+ });