@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.
Files changed (116) 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 +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -1,11 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { pinsOf, useBuilderStore } from './builderStore';
3
3
  import { createLoginFlow } from '../flows/login-flow';
4
- import { createDefaultRegistry } from '../nodes';
5
4
  import * as serverRunner from './serverRunner';
6
5
  import * as agentClient from './agentClient';
7
- import type { WorkflowDefinition, WorkflowNode } from '../engine';
8
- import type { ScenarioTestReport, ServerRunHandlers } from './serverRunner';
6
+ import type { WorkflowDefinition } from '../engine';
7
+ import type { ScenarioTestReport, ServerRunHandlers, StepResult } from './serverRunner';
9
8
 
10
9
  vi.mock('./serverRunner', async () => {
11
10
  const actual = await vi.importActual<typeof import('./serverRunner')>('./serverRunner');
@@ -22,8 +21,10 @@ vi.mock('./serverRunner', async () => {
22
21
  runnerHealthy: vi.fn(),
23
22
  testWorkflow: vi.fn(),
24
23
  startServerRun: vi.fn(),
24
+ stepServerRun: vi.fn(),
25
25
  subscribeServerRun: vi.fn(),
26
26
  setServingMode: vi.fn(),
27
+ runNodeOnServer: vi.fn(),
27
28
  };
28
29
  });
29
30
 
@@ -173,18 +174,23 @@ describe('builderStore workflows', () => {
173
174
 
174
175
  describe('builderStore pinning', () => {
175
176
  beforeEach(() => {
176
- useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
177
+ vi.mocked(serverRunner.startServerRun).mockReset().mockResolvedValue('run-p');
178
+ vi.mocked(serverRunner.subscribeServerRun).mockReset().mockImplementation(() => () => {});
179
+ useBuilderStore.setState({
180
+ flow: createLoginFlow(), run: null, logs: [], activeRun: null,
181
+ activeServerRunId: null, runnerOnline: true,
182
+ });
177
183
  });
178
184
 
179
- it('pinned output skips execution and feeds downstream mapping', async () => {
185
+ it('threads pinned outputs to the server run (the runner honours the pin)', async () => {
180
186
  useBuilderStore
181
187
  .getState()
182
188
  .pinNodeOutput('validate', { userId: 'user-pinned', username: 'pinned' });
183
189
  await useBuilderStore.getState().runToEnd();
184
- const run = useBuilderStore.getState().run!;
185
- expect(run.nodeStates.validate.pinned).toBe(true);
186
- expect((run.nodeStates.fetch.input as { userId: string }).userId).toBe('user-pinned');
187
- }, 10_000);
190
+ // pins are startServerRun's 3rd arg (flow, mode, pins, input, options).
191
+ const pins = vi.mocked(serverRunner.startServerRun).mock.calls[0][2];
192
+ expect(pins).toEqual({ validate: { userId: 'user-pinned', username: 'pinned' } });
193
+ });
188
194
 
189
195
  it('unpinNode clears the pin and empties metadata', () => {
190
196
  useBuilderStore.getState().pinNodeOutput('validate', { userId: 'x' });
@@ -205,37 +211,66 @@ describe('builderStore pinning', () => {
205
211
  });
206
212
  });
207
213
 
208
- describe('builderStore isolated node run', () => {
214
+ describe('builderStore isolated node run (server-backed)', () => {
209
215
  beforeEach(() => {
210
- useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
216
+ vi.mocked(serverRunner.runNodeOnServer).mockReset();
217
+ useBuilderStore.setState({
218
+ flow: createLoginFlow(),
219
+ run: null,
220
+ logs: [],
221
+ activeRun: null,
222
+ runnerOnline: true,
223
+ selectedEnvironment: 'local',
224
+ safeMode: true,
225
+ });
211
226
  });
212
227
 
213
- it('executes one node against supplied input, capturing logs and a sample', async () => {
228
+ it('calls the runner with the node type, input, config and env, and records a local sample', async () => {
229
+ vi.mocked(serverRunner.runNodeOnServer).mockResolvedValue({
230
+ output: { plan: 'pro' },
231
+ logs: [{ timestamp: 't', level: 'info', runId: 'node-run', message: 'plan pro' }],
232
+ });
214
233
  const before = useBuilderStore.getState().trace.samplesFor('checkPlan').length;
234
+
215
235
  const result = await useBuilderStore
216
236
  .getState()
217
237
  .runNodeIsolated('checkPlan', { user: { plan: 'pro' } });
238
+
218
239
  expect(result.error).toBeUndefined();
219
240
  expect((result.output as { plan: string }).plan).toBe('pro');
220
241
  expect(result.logs.some((l) => l.message.includes('pro'))).toBe(true);
242
+ const call = vi.mocked(serverRunner.runNodeOnServer).mock.calls[0][0];
243
+ // Sends the node TYPE (CheckPlan), not the node id (checkPlan).
244
+ expect(call.type).toBe('CheckPlan');
245
+ expect(call.input).toEqual({ user: { plan: 'pro' } });
246
+ expect(call.environment).toBe('local');
221
247
  const samples = useBuilderStore.getState().trace.samplesFor('checkPlan');
222
248
  expect(samples.length).toBe(before + 1);
223
249
  expect(samples[0].input).toEqual({ user: { plan: 'pro' } });
224
250
  expect(useBuilderStore.getState().run).toBeNull();
225
251
  });
226
252
 
227
- it('reports node failure as error with a failed sample', async () => {
228
- const result = await useBuilderStore
229
- .getState()
230
- .runNodeIsolated('validate', { username: 'ada', password: 'lovelace' });
231
- expect(result.error).toBeUndefined();
232
-
253
+ it('surfaces a runner error as the result error and records a failed sample', async () => {
254
+ vi.mocked(serverRunner.runNodeOnServer).mockResolvedValue({
255
+ error: 'Password too short',
256
+ logs: [],
257
+ });
233
258
  const failed = await useBuilderStore
234
259
  .getState()
235
260
  .runNodeIsolated('validate', { username: 'ada', password: 'x' });
236
261
  expect(failed.error).toContain('Password too short');
237
262
  expect(useBuilderStore.getState().trace.samplesFor('validate')[0].status).toBe('failed');
238
263
  });
264
+
265
+ it('shows the offline notice when the runner is unreachable', async () => {
266
+ useBuilderStore.setState({ runnerOnline: false });
267
+ vi.mocked(serverRunner.runNodeOnServer).mockRejectedValue(new Error('Failed to fetch'));
268
+ const result = await useBuilderStore
269
+ .getState()
270
+ .runNodeIsolated('validate', { username: 'ada', password: 'x' });
271
+ expect(result.error).toContain('Runner offline');
272
+ expect(result.logs).toEqual([]);
273
+ });
239
274
  });
240
275
 
241
276
  describe('builderStore runner sync', () => {
@@ -428,6 +463,8 @@ describe('builderStore run history', () => {
428
463
 
429
464
  describe('builderStore scenarios', () => {
430
465
  beforeEach(() => {
466
+ vi.mocked(serverRunner.startServerRun).mockReset().mockResolvedValue('run-s');
467
+ vi.mocked(serverRunner.subscribeServerRun).mockReset().mockImplementation(() => () => {});
431
468
  // Start from a scenario-free flow: the seeded examples would offset counts.
432
469
  const flow = createLoginFlow();
433
470
  delete flow.scenarios;
@@ -436,9 +473,10 @@ describe('builderStore scenarios', () => {
436
473
  run: null,
437
474
  logs: [],
438
475
  activeRun: null,
476
+ activeServerRunId: null,
439
477
  runHistory: [],
440
478
  activeScenarioId: null,
441
- executionMode: 'browser',
479
+ runnerOnline: true,
442
480
  });
443
481
  });
444
482
 
@@ -458,7 +496,12 @@ describe('builderStore scenarios', () => {
458
496
  expect(useBuilderStore.getState().flow.scenarios).toBeUndefined();
459
497
  });
460
498
 
461
- it('runScenario feeds the payload through the Input node and tags history', async () => {
499
+ it('runScenario starts a server run with the scenario payload + name and tags history', async () => {
500
+ let capturedHandlers: ServerRunHandlers | undefined;
501
+ vi.mocked(serverRunner.subscribeServerRun).mockImplementation((_runId, handlers) => {
502
+ capturedHandlers = handlers;
503
+ return () => {};
504
+ });
462
505
  useBuilderStore
463
506
  .getState()
464
507
  .addScenario('new user', { username: 'newton', password: 'apple123' });
@@ -466,25 +509,50 @@ describe('builderStore scenarios', () => {
466
509
 
467
510
  await useBuilderStore.getState().runScenario(id);
468
511
 
469
- const run = useBuilderStore.getState().run!;
470
- expect(run.status).toBe('succeeded');
471
- expect((run.nodeStates.input.output as { username: string }).username).toBe('newton');
472
- // 'newton' is a new user: the welcome branch fires and checkPlan is skipped.
473
- expect(run.nodeStates.welcome.status).toBe('succeeded');
474
- expect(run.nodeStates.checkPlan.status).toBe('skipped');
512
+ const call = vi.mocked(serverRunner.startServerRun).mock.calls[0];
513
+ expect(call[1]).toBe('run');
514
+ expect(call[3]).toEqual({ username: 'newton', password: 'apple123' });
515
+ expect(call[4]).toMatchObject({ scenarioName: 'new user' });
516
+
517
+ const flowId = useBuilderStore.getState().flow.id;
518
+ capturedHandlers!.onFinished({
519
+ id: 'run-s',
520
+ workflowId: flowId,
521
+ status: 'succeeded',
522
+ startedAt: '2026-07-10T10:00:00Z',
523
+ completedAt: '2026-07-10T10:00:01Z',
524
+ nodeStates: {},
525
+ });
475
526
  expect(useBuilderStore.getState().runHistory[0].scenarioName).toBe('new user');
476
- }, 15_000);
527
+ });
477
528
 
478
529
  it('a plain run after a scenario run is not tagged', async () => {
530
+ const handlersByRun: Record<string, ServerRunHandlers> = {};
531
+ vi.mocked(serverRunner.subscribeServerRun).mockImplementation((runId, handlers) => {
532
+ handlersByRun[runId] = handlers;
533
+ return () => {};
534
+ });
479
535
  useBuilderStore
480
536
  .getState()
481
537
  .addScenario('new user', { username: 'newton', password: 'apple123' });
482
538
  const id = useBuilderStore.getState().flow.scenarios![0].id;
539
+ const flowId = useBuilderStore.getState().flow.id;
540
+
541
+ vi.mocked(serverRunner.startServerRun).mockResolvedValueOnce('run-a');
483
542
  await useBuilderStore.getState().runScenario(id);
543
+ handlersByRun['run-a'].onFinished({
544
+ id: 'run-a', workflowId: flowId, status: 'succeeded',
545
+ startedAt: '2026-07-10T10:00:00Z', completedAt: '2026-07-10T10:00:01Z', nodeStates: {},
546
+ });
484
547
 
548
+ vi.mocked(serverRunner.startServerRun).mockResolvedValueOnce('run-b');
485
549
  await useBuilderStore.getState().runToEnd();
550
+ handlersByRun['run-b'].onFinished({
551
+ id: 'run-b', workflowId: flowId, status: 'succeeded',
552
+ startedAt: '2026-07-10T10:00:02Z', completedAt: '2026-07-10T10:00:03Z', nodeStates: {},
553
+ });
486
554
  expect(useBuilderStore.getState().runHistory[0].scenarioName).toBeUndefined();
487
- }, 20_000);
555
+ });
488
556
  });
489
557
 
490
558
  describe('builderStore environments + safe mode', () => {
@@ -665,6 +733,103 @@ describe('builderStore agent picker', () => {
665
733
  .runAgent({ action: 'edit-flow', flowId: 'f1', instruction: 'go' }, { model: 'other' });
666
734
  expect(agentClient.startAgent).toHaveBeenCalledWith(expect.anything(), { model: 'other' });
667
735
  });
736
+
737
+ it('beginInfrastructureScout: a leaked MouseEvent (onClick wiring) falls back to the full-rescan instruction', () => {
738
+ // Regression: the Dock's scout button once passed the click event as
739
+ // `instruction`, crashing on instruction.trim(). Non-strings = full rescan.
740
+ useBuilderStore.getState().beginInfrastructureScout(
741
+ { type: 'click' } as unknown as string,
742
+ );
743
+ expect(agentClient.startAgent).toHaveBeenCalledWith(
744
+ expect.objectContaining({
745
+ action: 'scout-infrastructure',
746
+ instruction: expect.stringContaining('Scan this project'),
747
+ }),
748
+ expect.anything(),
749
+ );
750
+ });
751
+
752
+ it('beginInfrastructureScout: a real instruction threads verbatim', () => {
753
+ useBuilderStore.getState().beginInfrastructureScout('add our Redis cache');
754
+ expect(agentClient.startAgent).toHaveBeenCalledWith(
755
+ expect.objectContaining({ instruction: 'add our Redis cache' }),
756
+ expect.anything(),
757
+ );
758
+ });
759
+ });
760
+
761
+ describe('builderStore guided setup transcript', () => {
762
+ beforeEach(() => {
763
+ vi.mocked(agentClient.startAgent).mockClear().mockResolvedValue('run-1');
764
+ useBuilderStore.setState({ agentChoice: {}, agentRun: null, guidedTranscript: [] });
765
+ });
766
+
767
+ const QUESTION_TEXT =
768
+ 'Two things to decide.\n\n' +
769
+ '```emberflow-questions\n{"questions":[{"id":"envs","text":"Which environments?","options":["dev + prod"]}]}\n```\n';
770
+
771
+ it('continuation: appends the prior guided run (question blocks stripped) + a **You:** message', () => {
772
+ useBuilderStore.setState({
773
+ guidedTranscript: [{ type: 'message', text: 'Earlier turn.' }],
774
+ agentRun: {
775
+ id: 'run-0',
776
+ status: 'done',
777
+ guided: true,
778
+ events: [
779
+ { type: 'message', text: 'Reading the ground truth first.' },
780
+ { type: 'command', command: 'git status', commandStatus: 'completed' },
781
+ { type: 'message', text: QUESTION_TEXT },
782
+ ],
783
+ },
784
+ });
785
+
786
+ useBuilderStore.getState().beginGuidedSetup('Which environments?: dev + prod');
787
+
788
+ expect(useBuilderStore.getState().guidedTranscript).toEqual([
789
+ { type: 'message', text: 'Earlier turn.' },
790
+ { type: 'message', text: 'Reading the ground truth first.' },
791
+ { type: 'command', command: 'git status', commandStatus: 'completed' },
792
+ // The answered question block is stripped — only the prose survives.
793
+ { type: 'message', text: 'Two things to decide.' },
794
+ { type: 'message', text: '**You:** Which environments?: dev + prod' },
795
+ ]);
796
+ expect(agentClient.startAgent).toHaveBeenCalledWith(
797
+ { action: 'guided-setup', instruction: 'Which environments?: dev + prod' },
798
+ expect.anything(),
799
+ );
800
+ });
801
+
802
+ it('fresh start (no notes) clears the transcript', () => {
803
+ useBuilderStore.setState({
804
+ guidedTranscript: [{ type: 'message', text: 'Stale conversation.' }],
805
+ });
806
+ useBuilderStore.getState().beginGuidedSetup();
807
+ expect(useBuilderStore.getState().guidedTranscript).toEqual([]);
808
+ expect(agentClient.startAgent).toHaveBeenCalledWith(
809
+ { action: 'guided-setup', instruction: '' },
810
+ expect.anything(),
811
+ );
812
+ });
813
+
814
+ it('a leaked MouseEvent (onClick wiring) counts as a fresh start, not a continuation', () => {
815
+ useBuilderStore.setState({
816
+ guidedTranscript: [{ type: 'message', text: 'Stale conversation.' }],
817
+ agentRun: { id: 'run-0', status: 'done', guided: true, events: [] },
818
+ });
819
+ useBuilderStore.getState().beginGuidedSetup({ type: 'click' } as unknown as string);
820
+ expect(useBuilderStore.getState().guidedTranscript).toEqual([]);
821
+ });
822
+
823
+ it('notes without a prior guided run leave the transcript untouched', () => {
824
+ useBuilderStore.setState({
825
+ guidedTranscript: [{ type: 'message', text: 'Kept.' }],
826
+ agentRun: { id: 'run-9', status: 'done', events: [{ type: 'message', text: 'edit-flow run' }] },
827
+ });
828
+ useBuilderStore.getState().beginGuidedSetup('an answer');
829
+ expect(useBuilderStore.getState().guidedTranscript).toEqual([
830
+ { type: 'message', text: 'Kept.' },
831
+ ]);
832
+ });
668
833
  });
669
834
 
670
835
  describe('builderStore node sizing', () => {
@@ -924,75 +1089,8 @@ describe('builderStore run console dismissal', () => {
924
1089
  // the toolbar's visual reflection of it, is only verifiable in a rendered
925
1090
  // browser/component-test environment, not from the store alone.
926
1091
 
927
- describe('builderStore subflow execution', () => {
928
- const node = (id: string, type: string, extra: Partial<WorkflowNode> = {}): WorkflowNode => ({
929
- id, type, label: id, position: { x: 0, y: 0 }, config: {}, ...extra,
930
- });
931
- const mkFlow = (
932
- id: string, name: string, nodes: WorkflowNode[], edges: WorkflowDefinition['edges'],
933
- ): WorkflowDefinition => ({
934
- id, name, version: 1, nodes, edges,
935
- createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
936
- });
937
-
938
- beforeEach(() => {
939
- // Delay-free registry so end-to-end runs are fast; browser mode so runs
940
- // execute in-tab through the store's subflow runner.
941
- useBuilderStore.setState({
942
- registry: createDefaultRegistry(0),
943
- runnerOnline: false,
944
- executionMode: 'browser',
945
- run: null,
946
- logs: [],
947
- activeRun: null,
948
- activeServerRunId: null,
949
- });
950
- });
951
-
952
- it('resolves a shelf workflow and forwards its logs with prefixing', async () => {
953
- const child = mkFlow('child', 'Child', [node('cres', 'Result', { label: 'Result' })], []);
954
- const parent = mkFlow(
955
- 'parent', 'Parent',
956
- [
957
- node('sub', 'Subflow', { config: { workflowId: 'child' } }),
958
- node('res', 'Result', { inputMap: { data: { sourceNodeId: 'sub', sourceField: '$' } } }),
959
- ],
960
- [{ id: 'e', source: 'sub', target: 'res', targetHandle: 'data' }],
961
- );
962
- useBuilderStore.setState({ flow: parent, shelf: [child] });
963
-
964
- await useBuilderStore.getState().runToEnd();
965
- const s = useBuilderStore.getState();
966
- expect(s.run?.status).toBe('succeeded');
967
- expect(s.run?.nodeStates.sub.status).toBe('succeeded');
968
- // The child's Result-collected output flows into the parent's Result.
969
- expect(s.run?.nodeStates.res.output).toEqual({ data: {} });
970
-
971
- const forwarded = s.logs.find((l) => l.nodeId === 'sub/cres');
972
- expect(forwarded, JSON.stringify(s.logs)).toBeDefined();
973
- expect(forwarded?.nodeLabel).toBe('Child › Result');
974
- });
975
-
976
- it('rejects an unknown workflow id', async () => {
977
- const parent = mkFlow('parent', 'Parent', [node('sub', 'Subflow', { config: { workflowId: 'nope' } })], []);
978
- useBuilderStore.setState({ flow: parent, shelf: [] });
979
- await useBuilderStore.getState().runToEnd();
980
- const s = useBuilderStore.getState();
981
- expect(s.run?.status).toBe('failed');
982
- expect(s.run?.nodeStates.sub.error).toBe('Unknown workflow: nope');
983
- });
984
-
985
- it('catches an A→B→A cycle at runtime', async () => {
986
- const a = mkFlow('a', 'A', [node('subA', 'Subflow', { config: { workflowId: 'b' } })], []);
987
- const b = mkFlow('b', 'B', [node('subB', 'Subflow', { config: { workflowId: 'a' } })], []);
988
- useBuilderStore.setState({ flow: a, shelf: [b] });
989
- await useBuilderStore.getState().runToEnd();
990
- const s = useBuilderStore.getState();
991
- expect(s.run?.status).toBe('failed');
992
- // The cycle is reported from deep in the child; its error log is forwarded up.
993
- expect(s.logs.some((l) => l.message === 'subflow cycle: a → b → a')).toBe(true);
994
- });
995
- });
1092
+ // Subflow execution moved server-side (server/subflowRunner.ts, exercised by the
1093
+ // runner's run tests) the studio no longer runs child workflows in-tab.
996
1094
 
997
1095
  describe('builderStore.syncNodeMeta', () => {
998
1096
  afterEach(() => vi.unstubAllGlobals());
@@ -1097,7 +1195,6 @@ describe('builderStore mock runs (studio wiring)', () => {
1097
1195
  logs: [],
1098
1196
  activeRun: null,
1099
1197
  activeServerRunId: null,
1100
- executionMode: 'server',
1101
1198
  runnerOnline: true,
1102
1199
  runnerMock: true,
1103
1200
  runHistory: [],
@@ -1174,7 +1271,6 @@ describe('builderStore server-run finished event → errorHandler tag', () => {
1174
1271
  logs: [],
1175
1272
  activeRun: null,
1176
1273
  activeServerRunId: null,
1177
- executionMode: 'server',
1178
1274
  runnerOnline: true,
1179
1275
  runHistory: [],
1180
1276
  activeScenarioId: null,
@@ -1232,3 +1328,249 @@ describe('builderStore server-run finished event → errorHandler tag', () => {
1232
1328
  expect(entry?.errorHandler).toBeUndefined();
1233
1329
  });
1234
1330
  });
1331
+
1332
+ describe('builderStore subflow step drill-in', () => {
1333
+ const drillFlow = (id: string, name: string, nodeIds: string[]): WorkflowDefinition => ({
1334
+ id,
1335
+ name,
1336
+ version: 1,
1337
+ nodes: nodeIds.map((nid, i) => ({
1338
+ id: nid,
1339
+ type: 'Noop',
1340
+ label: nid,
1341
+ position: { x: i * 100, y: 0 },
1342
+ config: {},
1343
+ })),
1344
+ edges: [],
1345
+ createdAt: '2026-07-13T00:00:00Z',
1346
+ updatedAt: '2026-07-13T00:00:00Z',
1347
+ });
1348
+
1349
+ let handlers: ServerRunHandlers | undefined;
1350
+
1351
+ beforeEach(() => {
1352
+ handlers = undefined;
1353
+ vi.mocked(serverRunner.startServerRun).mockReset().mockResolvedValue('run-d');
1354
+ vi.mocked(serverRunner.stepServerRun).mockReset();
1355
+ vi.mocked(serverRunner.subscribeServerRun).mockReset().mockImplementation((_runId, h) => {
1356
+ handlers = h;
1357
+ return () => {};
1358
+ });
1359
+ useBuilderStore.setState({
1360
+ flow: drillFlow('parent', 'Parent Flow', ['start', 'sub', 'finish']),
1361
+ shelf: [drillFlow('child', 'Child Flow', ['c1', 'c2']), drillFlow('grandchild', 'Grandchild Flow', ['g1'])],
1362
+ run: null,
1363
+ logs: [],
1364
+ activeRun: null,
1365
+ activeServerRunId: null,
1366
+ runnerOnline: true,
1367
+ runHistory: [],
1368
+ activeScenarioId: null,
1369
+ stepMode: false,
1370
+ stepDrill: [],
1371
+ drillPeek: null,
1372
+ selectedNodeId: null,
1373
+ });
1374
+ });
1375
+
1376
+ /** One Step click: queue the next step response and drive stepRun. */
1377
+ const step = (result: StepResult): Promise<void> => {
1378
+ vi.mocked(serverRunner.stepServerRun).mockResolvedValueOnce(result);
1379
+ return useBuilderStore.getState().stepRun();
1380
+ };
1381
+
1382
+ it('entered pushes a drill level, swaps the view to the child, and routes nodeState by workflowId', async () => {
1383
+ await step({ done: false }); // first parent node
1384
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1385
+
1386
+ let s = useBuilderStore.getState();
1387
+ expect(s.flow.id).toBe('child');
1388
+ expect(s.stepDrill).toHaveLength(1);
1389
+ expect(s.stepDrill[0]).toMatchObject({ workflowId: 'child', viaNodeId: 'sub' });
1390
+ expect(s.stepDrill[0].savedFlow.id).toBe('parent');
1391
+ // Synthetic child run: running, no node states yet (first child node runs
1392
+ // on the NEXT step), same id as the root run so SSE routing applies.
1393
+ expect(s.run?.workflowId).toBe('child');
1394
+ expect(s.run?.status).toBe('running');
1395
+ expect(s.run?.nodeStates).toEqual({});
1396
+ expect(s.run?.id).toBe('run-d');
1397
+ // The live server run and step mode are untouched by drilling.
1398
+ expect(s.activeServerRunId).toBe('run-d');
1399
+ expect(s.stepMode).toBe(true);
1400
+
1401
+ // Child workflowId → lights the visible (child) run.
1402
+ handlers!.onNodeState('child', 'c1', { status: 'running' });
1403
+ expect(useBuilderStore.getState().run?.nodeStates.c1?.status).toBe('running');
1404
+
1405
+ // Parent workflowId → lands on the stashed parent run, not the child view.
1406
+ handlers!.onNodeState('parent', 'sub', { status: 'running' });
1407
+ s = useBuilderStore.getState();
1408
+ expect(s.run?.nodeStates.sub).toBeUndefined();
1409
+ expect(s.stepDrill[0].savedRun?.nodeStates.sub?.status).toBe('running');
1410
+
1411
+ // Unknown workflowId → ignored.
1412
+ handlers!.onNodeState('elsewhere', 'x', { status: 'running' });
1413
+ expect(useBuilderStore.getState().run?.nodeStates.x).toBeUndefined();
1414
+ });
1415
+
1416
+ it('drilling does not clear the root run logs', async () => {
1417
+ await step({ done: false });
1418
+ useBuilderStore.setState({
1419
+ logs: [{ timestamp: 't', level: 'info', runId: 'run-d', message: 'parent log' }],
1420
+ });
1421
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1422
+ expect(useBuilderStore.getState().logs.map((l) => l.message)).toEqual(['parent log']);
1423
+ });
1424
+
1425
+ it('exited restores the parent view including its updated Subflow node state and selection', async () => {
1426
+ await step({ done: false });
1427
+ useBuilderStore.setState({ selectedNodeId: 'start' });
1428
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1429
+ expect(useBuilderStore.getState().selectedNodeId).toBeNull();
1430
+
1431
+ handlers!.onNodeState('child', 'c1', { status: 'succeeded' });
1432
+ handlers!.onNodeState('parent', 'sub', { status: 'succeeded' });
1433
+ await step({ done: false, exited: true });
1434
+
1435
+ const s = useBuilderStore.getState();
1436
+ expect(s.flow.id).toBe('parent');
1437
+ expect(s.stepDrill).toHaveLength(0);
1438
+ expect(s.run?.workflowId).toBe('parent');
1439
+ expect(s.run?.nodeStates.sub?.status).toBe('succeeded');
1440
+ expect(s.selectedNodeId).toBe('start');
1441
+ });
1442
+
1443
+ it('nested enter/enter/exit/exit walks the stack one level at a time', async () => {
1444
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1445
+ await step({ done: false, entered: { workflowId: 'grandchild', nodeId: 'c2' } });
1446
+
1447
+ let s = useBuilderStore.getState();
1448
+ expect(s.flow.id).toBe('grandchild');
1449
+ expect(s.stepDrill.map((d) => d.workflowId)).toEqual(['child', 'grandchild']);
1450
+ expect(s.stepDrill[1].savedFlow.id).toBe('child');
1451
+
1452
+ // Mid-level (child) states land on its stashed run while two deep.
1453
+ handlers!.onNodeState('child', 'c2', { status: 'running' });
1454
+ s = useBuilderStore.getState();
1455
+ expect(s.stepDrill[1].savedRun?.nodeStates.c2?.status).toBe('running');
1456
+
1457
+ await step({ done: false, exited: true });
1458
+ s = useBuilderStore.getState();
1459
+ expect(s.flow.id).toBe('child');
1460
+ expect(s.stepDrill).toHaveLength(1);
1461
+ expect(s.run?.nodeStates.c2?.status).toBe('running');
1462
+
1463
+ await step({ done: false, exited: true });
1464
+ s = useBuilderStore.getState();
1465
+ expect(s.flow.id).toBe('parent');
1466
+ expect(s.stepDrill).toHaveLength(0);
1467
+ });
1468
+
1469
+ it('entered with an unknown child flow id stays on the parent but pushes a placeholder level', async () => {
1470
+ await step({ done: false, entered: { workflowId: 'missing', nodeId: 'sub' } });
1471
+ let s = useBuilderStore.getState();
1472
+ expect(s.flow.id).toBe('parent');
1473
+ // The server's drill stack grew — the client mirrors the depth with a
1474
+ // placeholder so the matching `exited` pops the right level.
1475
+ expect(s.stepDrill).toHaveLength(1);
1476
+ expect(s.stepDrill[0]).toMatchObject({ workflowId: 'missing', viaNodeId: 'sub', placeholder: true });
1477
+ expect(s.stepDrill[0].savedFlow.id).toBe('parent');
1478
+
1479
+ // Parent states keep applying to the visible run as before.
1480
+ handlers!.onNodeState('parent', 'sub', { status: 'running' });
1481
+ expect(useBuilderStore.getState().run?.nodeStates.sub?.status).toBe('running');
1482
+
1483
+ // The matching exited pops the placeholder and leaves the parent view —
1484
+ // including states received while "inside" — untouched.
1485
+ await step({ done: false, exited: true });
1486
+ s = useBuilderStore.getState();
1487
+ expect(s.flow.id).toBe('parent');
1488
+ expect(s.stepDrill).toHaveLength(0);
1489
+ expect(s.run?.nodeStates.sub?.status).toBe('running');
1490
+ });
1491
+
1492
+ it('exited+entered in one step (Subflow retry) pops the failed child before pushing the fresh one', async () => {
1493
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1494
+ expect(useBuilderStore.getState().flow.id).toBe('child');
1495
+
1496
+ // The retrying Subflow node pops its failed child AND re-enters a new
1497
+ // child run in the same composite step.
1498
+ await step({ done: false, exited: true, entered: { workflowId: 'child', nodeId: 'sub' } });
1499
+ const s = useBuilderStore.getState();
1500
+ expect(s.flow.id).toBe('child');
1501
+ expect(s.stepDrill).toHaveLength(1);
1502
+ expect(s.stepDrill[0].savedFlow.id).toBe('parent');
1503
+ // Fresh synthetic child run for the retry attempt.
1504
+ expect(s.run?.workflowId).toBe('child');
1505
+ expect(s.run?.nodeStates).toEqual({});
1506
+
1507
+ await step({ done: false, exited: true });
1508
+ expect(useBuilderStore.getState().flow.id).toBe('parent');
1509
+ expect(useBuilderStore.getState().stepDrill).toHaveLength(0);
1510
+ });
1511
+
1512
+ it('exited plus done pops the drill and ends the live run', async () => {
1513
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1514
+ await step({ done: true, exited: true });
1515
+ const s = useBuilderStore.getState();
1516
+ expect(s.flow.id).toBe('parent');
1517
+ expect(s.stepDrill).toHaveLength(0);
1518
+ expect(s.activeServerRunId).toBeNull();
1519
+ });
1520
+
1521
+ it('peek shows an ancestor without popping; child states during the peek still land on the child run', async () => {
1522
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1523
+
1524
+ useBuilderStore.getState().peekDrill(0);
1525
+ let s = useBuilderStore.getState();
1526
+ expect(s.drillPeek).toBe(0);
1527
+ // View-only: the drill stack and the live (deepest) level are untouched —
1528
+ // the runbook derives the peeked flow/run from stepDrill[drillPeek].
1529
+ expect(s.stepDrill).toHaveLength(1);
1530
+ expect(s.flow.id).toBe('child');
1531
+ expect(s.stepDrill[0].savedFlow.id).toBe('parent');
1532
+
1533
+ // Child states arriving during the peek land on the child's (live) run.
1534
+ handlers!.onNodeState('child', 'c1', { status: 'succeeded' });
1535
+ expect(useBuilderStore.getState().run?.nodeStates.c1?.status).toBe('succeeded');
1536
+ // Parent states still land on the stashed parent run.
1537
+ handlers!.onNodeState('parent', 'sub', { status: 'running' });
1538
+ expect(useBuilderStore.getState().stepDrill[0].savedRun?.nodeStates.sub?.status).toBe('running');
1539
+
1540
+ // The last crumb returns to the deepest level.
1541
+ useBuilderStore.getState().peekDrill(null);
1542
+ expect(useBuilderStore.getState().drillPeek).toBeNull();
1543
+
1544
+ // Out-of-range peek indexes clear rather than dangle.
1545
+ useBuilderStore.getState().peekDrill(7);
1546
+ expect(useBuilderStore.getState().drillPeek).toBeNull();
1547
+ });
1548
+
1549
+ it('a further entered while peeking resets the peek to the new deepest level', async () => {
1550
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1551
+ useBuilderStore.getState().peekDrill(0);
1552
+ await step({ done: false, entered: { workflowId: 'grandchild', nodeId: 'c2' } });
1553
+ const s = useBuilderStore.getState();
1554
+ expect(s.drillPeek).toBeNull();
1555
+ expect(s.flow.id).toBe('grandchild');
1556
+ expect(s.stepDrill).toHaveLength(2);
1557
+ });
1558
+
1559
+ it('the finished SSE event unwinds any remaining drill before recording the root run', async () => {
1560
+ await step({ done: false, entered: { workflowId: 'child', nodeId: 'sub' } });
1561
+ handlers!.onFinished({
1562
+ id: 'run-d',
1563
+ workflowId: 'parent',
1564
+ status: 'failed',
1565
+ startedAt: '2026-07-13T10:00:00Z',
1566
+ completedAt: '2026-07-13T10:00:01Z',
1567
+ nodeStates: {},
1568
+ });
1569
+ const s = useBuilderStore.getState();
1570
+ expect(s.flow.id).toBe('parent');
1571
+ expect(s.stepDrill).toHaveLength(0);
1572
+ expect(s.run?.id).toBe('run-d');
1573
+ expect(s.run?.workflowId).toBe('parent');
1574
+ expect(s.runHistory[0]?.id).toBe('run-d');
1575
+ });
1576
+ });