@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,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';
@@ -15,10 +16,12 @@ import { loadInfrastructure } from './infrastructure.js';
15
16
  import { configPathFor, loadProjectConfig } from './projectConfig.js';
16
17
  import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './projectMode.js';
17
18
  import { nodesPayload } from './nodesPayload.js';
19
+ import { getSourceFile } from './sourceNav.js';
18
20
  import { openBrowser } from './openBrowser.js';
19
21
  import { AgentRunManager, AgentStartError } from './agents/runManager.js';
22
+ import { isGitRepo } from './agents/gitScope.js';
20
23
  import { isMountablePath } from './pathGuard.js';
21
- import { detectAgents } from './agents/detect.js';
24
+ import { detectAgentsCached } from './agents/detect.js';
22
25
  import { ExpressAdapter } from './runtime/expressAdapter.js';
23
26
  import { makeOperationHandler } from './httpOperations.js';
24
27
  import { mockResponseFor } from './mockHandler.js';
@@ -27,6 +30,7 @@ import { isPathWithin } from './pathSafety.js';
27
30
  import { attachCredential } from './authAttach.js';
28
31
  import { redactSecrets } from './redact.js';
29
32
  import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner.js';
33
+ import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck.js';
30
34
  import { diagnoseOperation } from '../src/engine/diagnostics.js';
31
35
  import { seedParamDefaults } from './normalizeFlow.js';
32
36
  const PORT = Number(process.env.EMBERFLOW_RUNNER_PORT ?? 8092);
@@ -124,10 +128,32 @@ if (!project) {
124
128
  apiStore.save(flow, flow.id.includes('/') ? flow.id : `default/${flow.id}`);
125
129
  }
126
130
  }
127
- 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',
131
+ const agentRuns = new AgentRunManager(project ? project.root : projectDir, apiStore.dir, apiStore.pathOf.bind(apiStore), () => nodesPayload(validationRegistry, project ? project.root : process.cwd()).nodes.map(({ type, label, description }) => ({ type, label, description })), project?.language ?? 'typescript',
128
132
  // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
129
133
  // earlier this session primes later prompts. Malformed → null (agent guesses).
130
- () => loadInfrastructure(project ? project.root : projectDir));
134
+ () => loadInfrastructure(project ? project.root : projectDir),
135
+ // Runner-verified setup snapshot for guided-setup prompts: the same facts
136
+ // /setup-status reports, computed fresh at run start, so the agent starts
137
+ // from KNOWN state instead of probing for it.
138
+ () => {
139
+ const root = project ? project.root : process.cwd();
140
+ let envs = environmentsFile;
141
+ try {
142
+ envs = loadEnvironments(root);
143
+ }
144
+ catch {
145
+ /* keep last good */
146
+ }
147
+ const ops = apiStore.list();
148
+ return {
149
+ gitRepo: isGitRepo(root),
150
+ skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
151
+ environmentsConfigured: envs.configured,
152
+ infrastructurePresent: readInfrastructure() !== null,
153
+ opCount: ops.length,
154
+ onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
155
+ };
156
+ });
131
157
  // Hot-reload agent-authored nodes: watch the project's config and rebuild the
132
158
  // node registries IN-PROCESS (no process restart), so registering a node in
133
159
  // `registerNodes` takes effect live. Critically, this survives an in-flight
@@ -187,7 +213,27 @@ api.post('/serving', (req, res) => {
187
213
  res.status(204).end();
188
214
  });
189
215
  api.get('/nodes', (_req, res) => {
190
- res.json(nodesPayload(validationRegistry));
216
+ res.json(nodesPayload(validationRegistry, project ? project.root : process.cwd()));
217
+ });
218
+ // Whole-file source view + symbol table for the studio's source navigator.
219
+ // Works in project mode AND no-project mode (root = cwd) — path guards in
220
+ // sourceNav (isPathWithin, node_modules, secret basenames) bound what is
221
+ // servable either way. 400s stay generic: the requested path is never echoed.
222
+ api.get('/source-file', (req, res) => {
223
+ void (async () => {
224
+ const path = req.query.path;
225
+ if (typeof path !== 'string' || path.length === 0) {
226
+ res.status(400).json({ error: 'Query param path is required' });
227
+ return;
228
+ }
229
+ const root = project ? project.root : process.cwd();
230
+ const result = await getSourceFile(root, path);
231
+ if (!result.ok) {
232
+ res.status(result.status).json({ error: result.error });
233
+ return;
234
+ }
235
+ res.json(result.payload);
236
+ })();
191
237
  });
192
238
  // Validate a flow against the runner's LIVE registry (built-ins + project
193
239
  // nodes) so the CLI `validate` command doesn't false-reject ops that use
@@ -660,6 +706,8 @@ api.post('/runs', async (req, res) => {
660
706
  input: effectiveInput,
661
707
  mockRun,
662
708
  mocks: effectiveMocks,
709
+ // Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
710
+ stepped: mode === 'step',
663
711
  }));
664
712
  }
665
713
  catch (err) {
@@ -673,22 +721,98 @@ api.post('/runs', async (req, res) => {
673
721
  }
674
722
  res.status(201).json({ runId });
675
723
  });
724
+ // Run a single node in isolation (studio's "Run node" affordance). Executes the
725
+ // node's implementation in-process against the resolved environment's
726
+ // secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
727
+ // output through the same choke point runs use. Unknown type → 404. The studio
728
+ // bundles no implementations, so this is the ONLY path to a lone node run.
729
+ api.post('/node-run', async (req, res) => {
730
+ const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
731
+ if (typeof type !== 'string' || type.length === 0) {
732
+ res.status(400).json({ error: 'Body must include a node "type"' });
733
+ return;
734
+ }
735
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
736
+ res.status(400).json({ error: 'input must be a plain object' });
737
+ return;
738
+ }
739
+ if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
740
+ res.status(400).json({ error: 'config must be a plain object' });
741
+ return;
742
+ }
743
+ if (environment !== undefined && typeof environment !== 'string') {
744
+ res.status(400).json({ error: 'environment must be a string' });
745
+ return;
746
+ }
747
+ if (safeMode !== undefined && typeof safeMode !== 'boolean') {
748
+ res.status(400).json({ error: 'safeMode must be a boolean' });
749
+ return;
750
+ }
751
+ if (!runs.executionRegistry.has(type)) {
752
+ res.status(404).json({ error: `Unknown node type: ${type}` });
753
+ return;
754
+ }
755
+ const environmentName = environment ?? environmentsFile.defaultEnvironment;
756
+ const environmentDef = environmentsFile.environments[environmentName];
757
+ if (!environmentDef) {
758
+ res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
759
+ return;
760
+ }
761
+ const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
762
+ if (!safety.ok) {
763
+ res.status(400).json({ error: safety.error });
764
+ return;
765
+ }
766
+ const { implementation } = runs.executionRegistry.get(type);
767
+ const runId = `node-run-${randomUUID().slice(0, 8)}`;
768
+ const logs = [];
769
+ const log = (level, message) => {
770
+ logs.push({ timestamp: new Date().toISOString(), level, runId, message });
771
+ };
772
+ const nodeInput = (input ?? {});
773
+ let result;
774
+ try {
775
+ const output = await implementation({
776
+ input: nodeInput,
777
+ config: (config ?? {}),
778
+ secrets: environmentDef.secrets,
779
+ vars: environmentDef.vars,
780
+ environment: environmentName,
781
+ safeMode: safety.safeMode,
782
+ runInput: nodeInput,
783
+ log,
784
+ // A node run in isolation has no flow context to resolve child workflows.
785
+ runSubflow: async () => ({
786
+ status: 'failed',
787
+ error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
788
+ }),
789
+ });
790
+ result = { output, logs };
791
+ }
792
+ catch (err) {
793
+ result = { error: err instanceof Error ? err.message : String(err), logs };
794
+ }
795
+ // Redact secret values from the output AND captured logs before responding.
796
+ res.json(redactSecrets(result, environmentDef.secrets));
797
+ });
676
798
  api.post('/runs/:id/step', async (req, res) => {
677
- const handle = runs.get(req.params.id);
678
- if (!handle) {
799
+ // Drill-aware composite step: stepping INTO a Subflow reports
800
+ // `entered: { workflowId, nodeId }`, completing the child reports
801
+ // `exited: true` (see RunRegistry.step). Plain steps report just `done`.
802
+ const result = await runs.step(req.params.id);
803
+ if (!result) {
679
804
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
680
805
  return;
681
806
  }
682
- const more = await handle.step();
683
- res.json({ done: !more });
807
+ res.json(result);
684
808
  });
685
809
  api.post('/runs/:id/cancel', (req, res) => {
686
- const handle = runs.get(req.params.id);
687
- if (!handle) {
810
+ // Drill-aware: a stepped run drilled into subflows also cancels every
811
+ // stacked child and unblocks the parent executions (see RunRegistry.cancel).
812
+ if (!runs.cancel(req.params.id)) {
688
813
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
689
814
  return;
690
815
  }
691
- handle.cancel();
692
816
  res.status(204).end();
693
817
  });
694
818
  api.get('/runs/:id/events', (req, res) => {
@@ -709,7 +833,7 @@ api.get('/runs/:id/events', (req, res) => {
709
833
  unsubscribe = runs.subscribe(runId, listener);
710
834
  });
711
835
  api.get('/agent/available', (_req, res) => {
712
- res.json({ agents: detectAgents() });
836
+ res.json({ agents: detectAgentsCached() });
713
837
  });
714
838
  // First-run onboarding aggregate for the Welcome checklist (studio's
715
839
  // WelcomeDialog). Recomputed per request (cheap; mirrors /environments'
@@ -732,7 +856,10 @@ api.get('/setup-status', (_req, res) => {
732
856
  // A pristine project ships exactly the single `default/hello` example op.
733
857
  const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
734
858
  res.json({
735
- agents: detectAgents(),
859
+ agents: detectAgentsCached(),
860
+ // Agent features snapshot changes with git (server/agents/runManager.ts);
861
+ // the checklist gates them on this. Same notion gitScope's isGitRepo uses.
862
+ git: { repo: isGitRepo(root) },
736
863
  environments: {
737
864
  configured: envs.configured,
738
865
  // The synthesized "local" fallback is "no environments yet" — report 0.
@@ -758,6 +885,52 @@ api.get('/setup-status', (_req, res) => {
758
885
  function skillInstalled(root, harness) {
759
886
  return [root, homedir()].some((base) => existsSync(join(base, `.${harness}`, 'skills', 'emberflow-basics', 'SKILL.md')));
760
887
  }
888
+ // Package update notifier: the studio asks once per page load; checkForUpdate
889
+ // caches registry hits for an hour and fails silent — an unreachable registry
890
+ // (or a pre-publish package) just reports "no update".
891
+ api.get('/update-status', async (_req, res) => {
892
+ const current = ownVersion() ?? '0.0.0';
893
+ const check = await checkForUpdate(current);
894
+ if (!check) {
895
+ res.json({ current, updateAvailable: false });
896
+ return;
897
+ }
898
+ res.json(check);
899
+ });
900
+ // One-click updater: npm-installs the latest package into the consumer
901
+ // project. Single-flight — a second click while npm runs gets a 409, not a
902
+ // second npm process fighting over node_modules.
903
+ let updateInFlight = false;
904
+ api.post('/update', async (_req, res) => {
905
+ if (!project) {
906
+ res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
907
+ return;
908
+ }
909
+ if (isLinkedInstall(project.root)) {
910
+ res.status(400).json({
911
+ ok: false,
912
+ error: 'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
913
+ });
914
+ return;
915
+ }
916
+ if (updateInFlight) {
917
+ res.status(409).json({ ok: false, error: 'update already running' });
918
+ return;
919
+ }
920
+ updateInFlight = true;
921
+ try {
922
+ const result = await runNpmInstall(project.root);
923
+ if (result.ok) {
924
+ res.json({ ok: true, restartRequired: true });
925
+ }
926
+ else {
927
+ res.status(500).json({ ok: false, error: result.error });
928
+ }
929
+ }
930
+ finally {
931
+ updateInFlight = false;
932
+ }
933
+ });
761
934
  /** The /setup-status `infrastructure` field: presence + a shallow summary. */
762
935
  function infraStatus() {
763
936
  const manifest = readInfrastructure();
@@ -786,10 +959,11 @@ api.post('/agent', (req, res) => {
786
959
  intent.action !== 'setup-auth' &&
787
960
  intent.action !== 'setup-environments' &&
788
961
  intent.action !== 'scout-infrastructure' &&
962
+ intent.action !== 'guided-setup' &&
789
963
  intent.action !== 'cover-operation' &&
790
964
  intent.action !== 'ask') {
791
965
  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.`,
966
+ 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
967
  });
794
968
  return;
795
969
  }
@@ -805,7 +979,9 @@ api.post('/agent', (req, res) => {
805
979
  return;
806
980
  }
807
981
  }
808
- else if (intent.action === 'setup-environments' || intent.action === 'scout-infrastructure') {
982
+ else if (intent.action === 'setup-environments' ||
983
+ intent.action === 'scout-infrastructure' ||
984
+ intent.action === 'guided-setup') {
809
985
  // instruction alone is required, already validated above; no flowId/environment needed.
810
986
  }
811
987
  else if (intent.action === 'ask') {
@@ -899,7 +1075,7 @@ const operationRunEnv = {
899
1075
  // operation whose http.path collides with one of these would silently never
900
1076
  // be reached (the internal route, registered first, always wins) — guarded
901
1077
  // against below rather than left as a silent trap.
902
- const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
1078
+ const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
903
1079
  function collidesWithReservedPath(path) {
904
1080
  return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
905
1081
  }
@@ -1007,6 +1183,22 @@ if (serveStudio) {
1007
1183
  console.warn(`[runner] EMBERFLOW_SERVE_STUDIO=1 but no studio-dist found near ${here} — run \`npm run build:studio\``);
1008
1184
  }
1009
1185
  }
1186
+ // Agent CLIs and node executions must not outlive the runner: agents are
1187
+ // spawned detached (their own process group), so without this a killed runner
1188
+ // leaves an orphaned agent editing the project — and possibly writing files
1189
+ // long after the studio is gone (observed in the wild). Cancel everything,
1190
+ // then exit.
1191
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1192
+ process.on(signal, () => {
1193
+ try {
1194
+ agentRuns.shutdown();
1195
+ runs.shutdown();
1196
+ }
1197
+ finally {
1198
+ process.exit(0);
1199
+ }
1200
+ });
1201
+ }
1010
1202
  app.listen(PORT, HOST, () => {
1011
1203
  console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
1012
1204
  if (serveStudio) {
@@ -5,10 +5,23 @@ import type { NodeDefinition, NodeRegistry } from '../src/engine/index.js';
5
5
  * implementation's toString() so the Inspector can show a node's code without
6
6
  * the browser bundling the implementation — the key to consumer nodes
7
7
  * appearing in the palette without a per-project browser build.
8
+ *
9
+ * `sourceRef` (repo-relative file + line of the register() call) is present
10
+ * only when registration provenance was captured AND the file lives inside
11
+ * the project root — the studio uses it to open the real source via
12
+ * GET /source-file. A captured ref OUTSIDE the root (Emberflow's own
13
+ * built-ins registered from the package) is flagged `builtin: true` instead:
14
+ * those files are never served (node_modules exposure rule), so the
15
+ * Inspector keeps the toString() view for them.
8
16
  */
9
17
  export interface NodeMetaPayload {
10
18
  nodes: Array<NodeDefinition & {
11
19
  source?: string;
20
+ sourceRef?: {
21
+ file: string;
22
+ line?: number;
23
+ };
24
+ builtin?: boolean;
12
25
  }>;
13
26
  }
14
- export declare function nodesPayload(registry: NodeRegistry): NodeMetaPayload;
27
+ export declare function nodesPayload(registry: NodeRegistry, projectRoot?: string): NodeMetaPayload;
@@ -1,4 +1,6 @@
1
- export function nodesPayload(registry) {
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ export function nodesPayload(registry, projectRoot) {
3
+ const root = resolve(projectRoot ?? process.cwd());
2
4
  return {
3
5
  nodes: registry.list().map((definition) => {
4
6
  let source;
@@ -8,7 +10,18 @@ export function nodesPayload(registry) {
8
10
  catch {
9
11
  source = undefined;
10
12
  }
11
- return { ...definition, source };
13
+ const ref = registry.getSourceRef(definition.type);
14
+ if (!ref)
15
+ return { ...definition, source };
16
+ const rel = relative(root, resolve(root, ref.file));
17
+ const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
18
+ if (!inside)
19
+ return { ...definition, source, builtin: true };
20
+ return {
21
+ ...definition,
22
+ source,
23
+ sourceRef: ref.line !== undefined ? { file: rel, line: ref.line } : { file: rel },
24
+ };
12
25
  }),
13
26
  };
14
27
  }
@@ -34,8 +34,11 @@ export function buildApiStore(project) {
34
34
  return new ApiStore(apisDir);
35
35
  }
36
36
  export function buildRegistries(project) {
37
- const validation = createDefaultRegistry();
38
- const execution = createDefaultRegistry();
37
+ // Server-side registries capture registration provenance (file:line of each
38
+ // register() call) so the studio can navigate to a node's real source.
39
+ // Browser registries never enable this — see createDefaultRegistry.
40
+ const validation = createDefaultRegistry(undefined, { captureSourceRefs: true });
41
+ const execution = createDefaultRegistry(undefined, { captureSourceRefs: true });
39
42
  if (project?.registerNodes) {
40
43
  project.registerNodes(validation);
41
44
  project.registerNodes(execution);
@@ -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