@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
@@ -1,4 +1,5 @@
1
1
  import { existsSync, watch } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -17,8 +18,9 @@ import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './pr
17
18
  import { nodesPayload } from './nodesPayload.js';
18
19
  import { openBrowser } from './openBrowser.js';
19
20
  import { AgentRunManager, AgentStartError } from './agents/runManager.js';
21
+ import { isGitRepo } from './agents/gitScope.js';
20
22
  import { isMountablePath } from './pathGuard.js';
21
- import { detectAgents } from './agents/detect.js';
23
+ import { detectAgentsCached } from './agents/detect.js';
22
24
  import { ExpressAdapter } from './runtime/expressAdapter.js';
23
25
  import { makeOperationHandler } from './httpOperations.js';
24
26
  import { mockResponseFor } from './mockHandler.js';
@@ -27,6 +29,7 @@ import { isPathWithin } from './pathSafety.js';
27
29
  import { attachCredential } from './authAttach.js';
28
30
  import { redactSecrets } from './redact.js';
29
31
  import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner.js';
32
+ import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck.js';
30
33
  import { diagnoseOperation } from '../src/engine/diagnostics.js';
31
34
  import { seedParamDefaults } from './normalizeFlow.js';
32
35
  const PORT = Number(process.env.EMBERFLOW_RUNNER_PORT ?? 8092);
@@ -127,7 +130,29 @@ if (!project) {
127
130
  const agentRuns = new AgentRunManager(project ? project.root : projectDir, apiStore.dir, apiStore.pathOf.bind(apiStore), () => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })), project?.language ?? 'typescript',
128
131
  // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
129
132
  // earlier this session primes later prompts. Malformed → null (agent guesses).
130
- () => loadInfrastructure(project ? project.root : projectDir));
133
+ () => loadInfrastructure(project ? project.root : projectDir),
134
+ // Runner-verified setup snapshot for guided-setup prompts: the same facts
135
+ // /setup-status reports, computed fresh at run start, so the agent starts
136
+ // from KNOWN state instead of probing for it.
137
+ () => {
138
+ const root = project ? project.root : process.cwd();
139
+ let envs = environmentsFile;
140
+ try {
141
+ envs = loadEnvironments(root);
142
+ }
143
+ catch {
144
+ /* keep last good */
145
+ }
146
+ const ops = apiStore.list();
147
+ return {
148
+ gitRepo: isGitRepo(root),
149
+ skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
150
+ environmentsConfigured: envs.configured,
151
+ infrastructurePresent: readInfrastructure() !== null,
152
+ opCount: ops.length,
153
+ onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
154
+ };
155
+ });
131
156
  // Hot-reload agent-authored nodes: watch the project's config and rebuild the
132
157
  // node registries IN-PROCESS (no process restart), so registering a node in
133
158
  // `registerNodes` takes effect live. Critically, this survives an in-flight
@@ -660,6 +685,8 @@ api.post('/runs', async (req, res) => {
660
685
  input: effectiveInput,
661
686
  mockRun,
662
687
  mocks: effectiveMocks,
688
+ // Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
689
+ stepped: mode === 'step',
663
690
  }));
664
691
  }
665
692
  catch (err) {
@@ -673,22 +700,98 @@ api.post('/runs', async (req, res) => {
673
700
  }
674
701
  res.status(201).json({ runId });
675
702
  });
703
+ // Run a single node in isolation (studio's "Run node" affordance). Executes the
704
+ // node's implementation in-process against the resolved environment's
705
+ // secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
706
+ // output through the same choke point runs use. Unknown type → 404. The studio
707
+ // bundles no implementations, so this is the ONLY path to a lone node run.
708
+ api.post('/node-run', async (req, res) => {
709
+ const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
710
+ if (typeof type !== 'string' || type.length === 0) {
711
+ res.status(400).json({ error: 'Body must include a node "type"' });
712
+ return;
713
+ }
714
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
715
+ res.status(400).json({ error: 'input must be a plain object' });
716
+ return;
717
+ }
718
+ if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
719
+ res.status(400).json({ error: 'config must be a plain object' });
720
+ return;
721
+ }
722
+ if (environment !== undefined && typeof environment !== 'string') {
723
+ res.status(400).json({ error: 'environment must be a string' });
724
+ return;
725
+ }
726
+ if (safeMode !== undefined && typeof safeMode !== 'boolean') {
727
+ res.status(400).json({ error: 'safeMode must be a boolean' });
728
+ return;
729
+ }
730
+ if (!runs.executionRegistry.has(type)) {
731
+ res.status(404).json({ error: `Unknown node type: ${type}` });
732
+ return;
733
+ }
734
+ const environmentName = environment ?? environmentsFile.defaultEnvironment;
735
+ const environmentDef = environmentsFile.environments[environmentName];
736
+ if (!environmentDef) {
737
+ res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
738
+ return;
739
+ }
740
+ const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
741
+ if (!safety.ok) {
742
+ res.status(400).json({ error: safety.error });
743
+ return;
744
+ }
745
+ const { implementation } = runs.executionRegistry.get(type);
746
+ const runId = `node-run-${randomUUID().slice(0, 8)}`;
747
+ const logs = [];
748
+ const log = (level, message) => {
749
+ logs.push({ timestamp: new Date().toISOString(), level, runId, message });
750
+ };
751
+ const nodeInput = (input ?? {});
752
+ let result;
753
+ try {
754
+ const output = await implementation({
755
+ input: nodeInput,
756
+ config: (config ?? {}),
757
+ secrets: environmentDef.secrets,
758
+ vars: environmentDef.vars,
759
+ environment: environmentName,
760
+ safeMode: safety.safeMode,
761
+ runInput: nodeInput,
762
+ log,
763
+ // A node run in isolation has no flow context to resolve child workflows.
764
+ runSubflow: async () => ({
765
+ status: 'failed',
766
+ error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
767
+ }),
768
+ });
769
+ result = { output, logs };
770
+ }
771
+ catch (err) {
772
+ result = { error: err instanceof Error ? err.message : String(err), logs };
773
+ }
774
+ // Redact secret values from the output AND captured logs before responding.
775
+ res.json(redactSecrets(result, environmentDef.secrets));
776
+ });
676
777
  api.post('/runs/:id/step', async (req, res) => {
677
- const handle = runs.get(req.params.id);
678
- if (!handle) {
778
+ // Drill-aware composite step: stepping INTO a Subflow reports
779
+ // `entered: { workflowId, nodeId }`, completing the child reports
780
+ // `exited: true` (see RunRegistry.step). Plain steps report just `done`.
781
+ const result = await runs.step(req.params.id);
782
+ if (!result) {
679
783
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
680
784
  return;
681
785
  }
682
- const more = await handle.step();
683
- res.json({ done: !more });
786
+ res.json(result);
684
787
  });
685
788
  api.post('/runs/:id/cancel', (req, res) => {
686
- const handle = runs.get(req.params.id);
687
- if (!handle) {
789
+ // Drill-aware: a stepped run drilled into subflows also cancels every
790
+ // stacked child and unblocks the parent executions (see RunRegistry.cancel).
791
+ if (!runs.cancel(req.params.id)) {
688
792
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
689
793
  return;
690
794
  }
691
- handle.cancel();
692
795
  res.status(204).end();
693
796
  });
694
797
  api.get('/runs/:id/events', (req, res) => {
@@ -709,7 +812,7 @@ api.get('/runs/:id/events', (req, res) => {
709
812
  unsubscribe = runs.subscribe(runId, listener);
710
813
  });
711
814
  api.get('/agent/available', (_req, res) => {
712
- res.json({ agents: detectAgents() });
815
+ res.json({ agents: detectAgentsCached() });
713
816
  });
714
817
  // First-run onboarding aggregate for the Welcome checklist (studio's
715
818
  // WelcomeDialog). Recomputed per request (cheap; mirrors /environments'
@@ -732,7 +835,10 @@ api.get('/setup-status', (_req, res) => {
732
835
  // A pristine project ships exactly the single `default/hello` example op.
733
836
  const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
734
837
  res.json({
735
- agents: detectAgents(),
838
+ agents: detectAgentsCached(),
839
+ // Agent features snapshot changes with git (server/agents/runManager.ts);
840
+ // the checklist gates them on this. Same notion gitScope's isGitRepo uses.
841
+ git: { repo: isGitRepo(root) },
736
842
  environments: {
737
843
  configured: envs.configured,
738
844
  // The synthesized "local" fallback is "no environments yet" — report 0.
@@ -758,6 +864,52 @@ api.get('/setup-status', (_req, res) => {
758
864
  function skillInstalled(root, harness) {
759
865
  return [root, homedir()].some((base) => existsSync(join(base, `.${harness}`, 'skills', 'emberflow-basics', 'SKILL.md')));
760
866
  }
867
+ // Package update notifier: the studio asks once per page load; checkForUpdate
868
+ // caches registry hits for an hour and fails silent — an unreachable registry
869
+ // (or a pre-publish package) just reports "no update".
870
+ api.get('/update-status', async (_req, res) => {
871
+ const current = ownVersion() ?? '0.0.0';
872
+ const check = await checkForUpdate(current);
873
+ if (!check) {
874
+ res.json({ current, updateAvailable: false });
875
+ return;
876
+ }
877
+ res.json(check);
878
+ });
879
+ // One-click updater: npm-installs the latest package into the consumer
880
+ // project. Single-flight — a second click while npm runs gets a 409, not a
881
+ // second npm process fighting over node_modules.
882
+ let updateInFlight = false;
883
+ api.post('/update', async (_req, res) => {
884
+ if (!project) {
885
+ res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
886
+ return;
887
+ }
888
+ if (isLinkedInstall(project.root)) {
889
+ res.status(400).json({
890
+ ok: false,
891
+ error: 'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
892
+ });
893
+ return;
894
+ }
895
+ if (updateInFlight) {
896
+ res.status(409).json({ ok: false, error: 'update already running' });
897
+ return;
898
+ }
899
+ updateInFlight = true;
900
+ try {
901
+ const result = await runNpmInstall(project.root);
902
+ if (result.ok) {
903
+ res.json({ ok: true, restartRequired: true });
904
+ }
905
+ else {
906
+ res.status(500).json({ ok: false, error: result.error });
907
+ }
908
+ }
909
+ finally {
910
+ updateInFlight = false;
911
+ }
912
+ });
761
913
  /** The /setup-status `infrastructure` field: presence + a shallow summary. */
762
914
  function infraStatus() {
763
915
  const manifest = readInfrastructure();
@@ -786,10 +938,11 @@ api.post('/agent', (req, res) => {
786
938
  intent.action !== 'setup-auth' &&
787
939
  intent.action !== 'setup-environments' &&
788
940
  intent.action !== 'scout-infrastructure' &&
941
+ intent.action !== 'guided-setup' &&
789
942
  intent.action !== 'cover-operation' &&
790
943
  intent.action !== 'ask') {
791
944
  res.status(400).json({
792
- error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, cover-operation, ask.`,
945
+ error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, guided-setup, cover-operation, ask.`,
793
946
  });
794
947
  return;
795
948
  }
@@ -805,7 +958,9 @@ api.post('/agent', (req, res) => {
805
958
  return;
806
959
  }
807
960
  }
808
- else if (intent.action === 'setup-environments' || intent.action === 'scout-infrastructure') {
961
+ else if (intent.action === 'setup-environments' ||
962
+ intent.action === 'scout-infrastructure' ||
963
+ intent.action === 'guided-setup') {
809
964
  // instruction alone is required, already validated above; no flowId/environment needed.
810
965
  }
811
966
  else if (intent.action === 'ask') {
@@ -899,7 +1054,7 @@ const operationRunEnv = {
899
1054
  // operation whose http.path collides with one of these would silently never
900
1055
  // be reached (the internal route, registered first, always wins) — guarded
901
1056
  // against below rather than left as a silent trap.
902
- const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
1057
+ const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
903
1058
  function collidesWithReservedPath(path) {
904
1059
  return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
905
1060
  }
@@ -1007,6 +1162,22 @@ if (serveStudio) {
1007
1162
  console.warn(`[runner] EMBERFLOW_SERVE_STUDIO=1 but no studio-dist found near ${here} — run \`npm run build:studio\``);
1008
1163
  }
1009
1164
  }
1165
+ // Agent CLIs and node executions must not outlive the runner: agents are
1166
+ // spawned detached (their own process group), so without this a killed runner
1167
+ // leaves an orphaned agent editing the project — and possibly writing files
1168
+ // long after the studio is gone (observed in the wild). Cancel everything,
1169
+ // then exit.
1170
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1171
+ process.on(signal, () => {
1172
+ try {
1173
+ agentRuns.shutdown();
1174
+ runs.shutdown();
1175
+ }
1176
+ finally {
1177
+ process.exit(0);
1178
+ }
1179
+ });
1180
+ }
1010
1181
  app.listen(PORT, HOST, () => {
1011
1182
  console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
1012
1183
  if (serveStudio) {
@@ -1,8 +1,12 @@
1
1
  import type { LogLine, NodeExecutionSample, NodeRunState, WorkflowDefinition, WorkflowRun } from '../src/engine/index.js';
2
2
  import { InMemoryTraceSink, type FlowRun, type NodeRegistry } from '../src/engine/index.js';
3
3
  /** SSE-shaped events, mirroring ExecutorEvents 1:1. */
4
- export type RunEvent = {
4
+ export type RunEvent =
5
+ /** `workflowId` is the flow the node belongs to: the root flow for the
6
+ * run's own nodes, the CHILD flow's id for a stepped subflow's nodes. */
7
+ {
5
8
  type: 'nodeState';
9
+ workflowId: string;
6
10
  nodeId: string;
7
11
  state: NodeRunState;
8
12
  } | {
@@ -18,6 +22,17 @@ export type RunEvent = {
18
22
  };
19
23
  };
20
24
  type Listener = (event: RunEvent) => void;
25
+ /** Result of one composite step on a stepped run (see RunRegistry.step). */
26
+ export interface StepResult {
27
+ done: boolean;
28
+ /** Set when this step drove execution INTO a subflow child. */
29
+ entered?: {
30
+ workflowId: string;
31
+ nodeId: string;
32
+ };
33
+ /** Set when this step completed the deepest child and popped back out. */
34
+ exited?: true;
35
+ }
21
36
  /** Manages live runs: buffers engine events per run and fans them out to SSE subscribers. */
22
37
  export declare class RunRegistry {
23
38
  private readonly loadFlow?;
@@ -62,10 +77,46 @@ export declare class RunRegistry {
62
77
  * error-handler run — stamped onto this run's `finished` event as
63
78
  * `errorHandler: { firedBy }`. Only meaningful alongside isErrorHandler. */
64
79
  errorHandlerFiredBy?: string;
80
+ /** When true (a step-mode run), Subflow children become step-drivable:
81
+ * entering one pauses inside it instead of running it to completion in
82
+ * a single opaque step. Drive the run via `step(runId)`. */
83
+ stepped?: boolean;
65
84
  }): {
66
85
  runId: string;
67
86
  handle: FlowRun;
68
87
  };
88
+ /**
89
+ * One composite step of a run, drill-aware. For stepped runs a Subflow node
90
+ * is no longer one opaque step: entering it pauses inside the child
91
+ * (`entered`), subsequent steps drive the child's nodes, and completing the
92
+ * child pops back out (`exited`). Non-stepped runs (no drill state) fall
93
+ * back to a plain executor step. Unknown run → undefined.
94
+ */
95
+ step(runId: string): Promise<StepResult | undefined>;
96
+ /**
97
+ * One composite step, drill-aware. Structured as a loop over levels: each
98
+ * pass drives the current deepest level (a pending stashed step, or a fresh
99
+ * executor step) while racing "a new child started". Popping a finished
100
+ * child continues the loop on its parent's stashed step WITH the race still
101
+ * armed — the parent's still-pending Subflow node execution may call
102
+ * runSubflow again (retry, or a custom node making sequential calls), and
103
+ * that re-entry must surface as `entered`, not deadlock the fold.
104
+ */
105
+ private stepLevels;
106
+ /**
107
+ * Cancel a run, drill-aware. For a stepped run drilled into subflows this
108
+ * cancels every stacked child deepest-first and rejects the promise each
109
+ * parent's Subflow node execution is blocked on — those pending executions
110
+ * then unwind in the background exactly like an in-flight node after a
111
+ * plain root cancel today (recorded, but the run is already finished).
112
+ * step() after cancel reports { done: true } without executing anything.
113
+ * Returns false for an unknown run.
114
+ */
115
+ /** Cancels every live (non-finished) run — the server's shutdown path, so
116
+ * in-flight executions (and their drilled subflow children) don't keep
117
+ * running headless after the process is asked to die. */
118
+ shutdown(): void;
119
+ cancel(runId: string): boolean;
69
120
  /**
70
121
  * Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
71
122
  * callback Subflow nodes use) as a new run when `failedRun` finished with
@@ -57,6 +57,10 @@ export class RunRegistry {
57
57
  // the child via loadFlow, runs it to completion on the same registry
58
58
  // sharing the parent's environment, and forwards its (prefixed) logs into
59
59
  // this run's SSE stream via `onLog`.
60
+ // Stepped runs share ONE drill state across the root's runner and every
61
+ // nested child's runner (makeSubflowRunner threads `opts` through), so a
62
+ // grandchild pushes onto the same stack and nesting works.
63
+ const drillState = opts.stepped ? { stack: [] } : undefined;
60
64
  const subflowRunner = makeSubflowRunner({
61
65
  loadFlow: (id) => this.loadFlow?.(id),
62
66
  registry: this.registry,
@@ -67,6 +71,19 @@ export class RunRegistry {
67
71
  mockRun: opts.mockRun,
68
72
  trace: this.sink,
69
73
  onLog: (line) => emit({ type: 'log', line }),
74
+ drill: drillState,
75
+ // Stepped children stream node states into this run's SSE stream,
76
+ // tagged with the CHILD flow's id so the client can route them.
77
+ // After a drill-aware cancel the rejected parent executions unwind in
78
+ // the background (recording their Subflow nodes as failed) — child
79
+ // states from that unwinding must not reach subscribers post-finish.
80
+ onNodeState: drillState
81
+ ? (workflowId, nodeId, state) => {
82
+ if (drillState.cancelled)
83
+ return;
84
+ emit({ type: 'nodeState', workflowId, nodeId, state });
85
+ }
86
+ : undefined,
70
87
  }, [flow.id]);
71
88
  const handle = startRun({
72
89
  flow,
@@ -82,9 +99,14 @@ export class RunRegistry {
82
99
  trace: this.sink,
83
100
  subflowRunner,
84
101
  events: {
85
- onNodeStateChange: (nodeId, state) => emit({ type: 'nodeState', nodeId, state }),
102
+ onNodeStateChange: (nodeId, state) => emit({ type: 'nodeState', workflowId: flow.id, nodeId, state }),
86
103
  onLog: (line) => emit({ type: 'log', line }),
87
104
  onRunFinished: (run) => {
105
+ // Idempotent: after a drill-aware cancel the root executor's pending
106
+ // Subflow execution can fail in the background and re-finish the run
107
+ // — subscribers already saw the cancelled `finished` event.
108
+ if (entry.finished)
109
+ return;
88
110
  emit({
89
111
  type: 'finished',
90
112
  run,
@@ -100,9 +122,156 @@ export class RunRegistry {
100
122
  },
101
123
  });
102
124
  entry.handle = handle;
125
+ if (drillState)
126
+ entry.drill = { state: drillState };
103
127
  this.runs.set(handle.run.id, entry);
104
128
  return { runId: handle.run.id, handle };
105
129
  }
130
+ /**
131
+ * One composite step of a run, drill-aware. For stepped runs a Subflow node
132
+ * is no longer one opaque step: entering it pauses inside the child
133
+ * (`entered`), subsequent steps drive the child's nodes, and completing the
134
+ * child pops back out (`exited`). Non-stepped runs (no drill state) fall
135
+ * back to a plain executor step. Unknown run → undefined.
136
+ */
137
+ async step(runId) {
138
+ const entry = this.runs.get(runId);
139
+ if (!entry)
140
+ return undefined;
141
+ // Serialize per run: concurrent step() calls would each arm
142
+ // `state.onChildStarted` (last writer wins) and double-drive the
143
+ // executor, so the second caller waits for the first to settle.
144
+ const result = (entry.stepChain ?? Promise.resolve()).then(() => this.stepLevels(entry));
145
+ entry.stepChain = result.catch(() => undefined);
146
+ return result;
147
+ }
148
+ /**
149
+ * One composite step, drill-aware. Structured as a loop over levels: each
150
+ * pass drives the current deepest level (a pending stashed step, or a fresh
151
+ * executor step) while racing "a new child started". Popping a finished
152
+ * child continues the loop on its parent's stashed step WITH the race still
153
+ * armed — the parent's still-pending Subflow node execution may call
154
+ * runSubflow again (retry, or a custom node making sequential calls), and
155
+ * that re-entry must surface as `entered`, not deadlock the fold.
156
+ */
157
+ async stepLevels(entry) {
158
+ const drill = entry.drill;
159
+ if (!drill) {
160
+ const more = await entry.handle.step();
161
+ return { done: !more };
162
+ }
163
+ const { state } = drill;
164
+ // Cancelled mid-drill: the stack was already unwound by cancel(); report
165
+ // done without driving anything.
166
+ if (state.cancelled)
167
+ return { done: true };
168
+ // Set once this step pops a child; carried onto whatever this step
169
+ // ultimately reports (including an `entered` for a re-run child).
170
+ let exited = false;
171
+ const withExit = (r) => (exited ? { ...r, exited: true } : r);
172
+ for (;;) {
173
+ // The level being stepped: the deepest drilled-in child, or the root run.
174
+ const level = state.stack.length > 0 ? state.stack[state.stack.length - 1] : undefined;
175
+ const handle = level ? level.handle : entry.handle;
176
+ const pendingStep = level ? level.pendingStep : drill.rootPendingStep;
177
+ const setPending = (p) => {
178
+ if (level)
179
+ level.pendingStep = p;
180
+ else
181
+ drill.rootPendingStep = p;
182
+ };
183
+ // A Subflow node's execution blocks inside runSubflow until its child
184
+ // completes, so the step promise alone can never report "entered a
185
+ // child" — arm the child-started signal BEFORE awaiting and race them.
186
+ const childStarted = new Promise((res) => {
187
+ state.onChildStarted = res;
188
+ });
189
+ const stepPromise = pendingStep ?? handle.step();
190
+ setPending(undefined);
191
+ let raced;
192
+ try {
193
+ raced = await Promise.race([
194
+ stepPromise.then((more) => ({ kind: 'stepped', more })),
195
+ childStarted.then((child) => ({ kind: 'entered', child })),
196
+ ]);
197
+ }
198
+ catch (err) {
199
+ state.onChildStarted = undefined;
200
+ if (!level)
201
+ throw err;
202
+ // A child level's step threw outright (not a recorded node failure —
203
+ // the executor catches those). Reject the parent's blocked runSubflow,
204
+ // which surfaces it as the Subflow node's error, then fold the parent
205
+ // (next loop pass, race re-armed).
206
+ state.stack.pop();
207
+ level.reject(err);
208
+ exited = true;
209
+ continue;
210
+ }
211
+ state.onChildStarted = undefined;
212
+ if (raced.kind === 'entered') {
213
+ // The level's step stays pending for the whole child run — stash it;
214
+ // the step that later pops this child folds its boolean back in.
215
+ setPending(stepPromise);
216
+ return withExit({
217
+ done: false,
218
+ entered: { workflowId: raced.child.workflowId, nodeId: raced.child.viaNodeId },
219
+ });
220
+ }
221
+ if (raced.more)
222
+ return withExit({ done: false });
223
+ // This level has no more nodes: its run is complete.
224
+ if (!level)
225
+ return withExit({ done: true });
226
+ // Pop the finished child and hand its completed run (succeeded OR failed)
227
+ // to the parent's blocked runSubflow — the success/failure semantics live
228
+ // in subflowRunner, byte-identical to the non-stepped path. The parent's
229
+ // stashed step is folded in by the next loop pass, with the child-started
230
+ // race re-armed so a retrying Subflow node re-entering its child yields
231
+ // `exited` + `entered` instead of hanging.
232
+ state.stack.pop();
233
+ level.resolve(level.handle.run);
234
+ exited = true;
235
+ }
236
+ }
237
+ /**
238
+ * Cancel a run, drill-aware. For a stepped run drilled into subflows this
239
+ * cancels every stacked child deepest-first and rejects the promise each
240
+ * parent's Subflow node execution is blocked on — those pending executions
241
+ * then unwind in the background exactly like an in-flight node after a
242
+ * plain root cancel today (recorded, but the run is already finished).
243
+ * step() after cancel reports { done: true } without executing anything.
244
+ * Returns false for an unknown run.
245
+ */
246
+ /** Cancels every live (non-finished) run — the server's shutdown path, so
247
+ * in-flight executions (and their drilled subflow children) don't keep
248
+ * running headless after the process is asked to die. */
249
+ shutdown() {
250
+ for (const [id, run] of this.runs) {
251
+ if (!run.finished)
252
+ this.cancel(id);
253
+ }
254
+ }
255
+ cancel(runId) {
256
+ const entry = this.runs.get(runId);
257
+ if (!entry)
258
+ return false;
259
+ const drill = entry.drill;
260
+ if (drill) {
261
+ const { state } = drill;
262
+ state.cancelled = true;
263
+ state.onChildStarted = undefined;
264
+ while (state.stack.length > 0) {
265
+ const child = state.stack.pop();
266
+ child.pendingStep = undefined;
267
+ child.handle.cancel();
268
+ child.reject(new Error('run cancelled'));
269
+ }
270
+ drill.rootPendingStep = undefined;
271
+ }
272
+ entry.handle.cancel();
273
+ return true;
274
+ }
106
275
  /**
107
276
  * Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
108
277
  * callback Subflow nodes use) as a new run when `failedRun` finished with
@@ -1,6 +1,42 @@
1
- import type { LogLine, NodeRegistry, StartRunOptions, TraceSink, WorkflowDefinition } from '../src/engine/index.js';
1
+ import type { FlowRun, LogLine, NodeRegistry, NodeRunState, StartRunOptions, TraceSink, WorkflowDefinition, WorkflowRun } from '../src/engine/index.js';
2
2
  /** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
3
3
  export declare const SUBFLOW_DEPTH_CAP = 8;
4
+ /**
5
+ * One live drilled-into child on a stepped run. Pushed by the subflow runner
6
+ * when a Subflow node starts its child; popped by the registry's composite
7
+ * step() when the child's own stepping completes. `resolve`/`reject` settle
8
+ * the promise the parent's Subflow node execution is blocked on.
9
+ */
10
+ export interface DrillEntry {
11
+ handle: FlowRun;
12
+ /** The child flow's id (what the caller drilled into). */
13
+ workflowId: string;
14
+ /** The parent Subflow node that triggered the child. */
15
+ viaNodeId: string;
16
+ /** Hands the completed child run back to the blocked parent Subflow node. */
17
+ resolve: (run: WorkflowRun) => void;
18
+ reject: (err: unknown) => void;
19
+ /**
20
+ * This level's own executor.step() promise, left pending while a DEEPER
21
+ * child runs (a Subflow node's execution blocks on its child). Stashed by
22
+ * the registry when it observes `entered`; folded back in on exit.
23
+ */
24
+ pendingStep?: Promise<boolean>;
25
+ }
26
+ /**
27
+ * Per-root-run drill state for stepped runs. The root's subflow runner and
28
+ * every nested child's runner share the SAME instance, so grandchildren push
29
+ * onto the same stack and nesting Just Works.
30
+ */
31
+ export interface DrillState {
32
+ stack: DrillEntry[];
33
+ /** Armed by the registry's step() while it awaits a level's step, so a
34
+ * child starting can be raced against the (now blocked) step promise. */
35
+ onChildStarted?: (entry: DrillEntry) => void;
36
+ /** Set by RunRegistry.cancel(): unwinding parent executions must not start
37
+ * (or block on) new children — runSubflow fails fast instead. */
38
+ cancelled?: boolean;
39
+ }
4
40
  export interface SubflowRunnerOptions {
5
41
  /** Resolves a child workflow by id (server hosts this via apiStore.load). */
6
42
  loadFlow: (id: string) => WorkflowDefinition | undefined;
@@ -19,6 +55,17 @@ export interface SubflowRunnerOptions {
19
55
  * prefixed with the child flow's name and the caller node's id, exactly as
20
56
  * the live runner streams them over SSE. Absent for headless runs. */
21
57
  onLog?: (line: LogLine) => void;
58
+ /**
59
+ * Present only for stepped runs: children become step-drivable instead of
60
+ * running to completion. The runner pushes each started child onto
61
+ * `drill.stack` and blocks the Subflow node until the registry pops it.
62
+ */
63
+ drill?: DrillState;
64
+ /** Child nodeState forwarder for stepped runs — receives the CHILD flow's
65
+ * id so the client can route states to the drilled-in view. Only wired
66
+ * when `drill` is present (non-stepped children keep today's behavior:
67
+ * their node states are discarded, only the output returns). */
68
+ onNodeState?: (workflowId: string, nodeId: string, state: NodeRunState) => void;
22
69
  }
23
70
  /**
24
71
  * Shared Subflow-runner factory used by BOTH the live runner