@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
package/server/index.ts CHANGED
@@ -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';
@@ -9,6 +10,7 @@ import {
9
10
  mergeMocks,
10
11
  validateFlow,
11
12
  verifyArtifact,
13
+ type LogLine,
12
14
  type ScenarioDefinition,
13
15
  type WorkflowDefinition,
14
16
  } from '../src/engine';
@@ -30,10 +32,12 @@ import { loadInfrastructure } from './infrastructure';
30
32
  import { configPathFor, loadProjectConfig } from './projectConfig';
31
33
  import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './projectMode';
32
34
  import { nodesPayload } from './nodesPayload';
35
+ import { getSourceFile } from './sourceNav';
33
36
  import { openBrowser } from './openBrowser';
34
37
  import { AgentRunManager, AgentStartError } from './agents/runManager';
38
+ import { isGitRepo } from './agents/gitScope';
35
39
  import { isMountablePath } from './pathGuard';
36
- import { detectAgents } from './agents/detect';
40
+ import { detectAgentsCached } from './agents/detect';
37
41
  import type { AgentEvent } from './agents/types';
38
42
  import type { AgentIntent } from './agents/prompt';
39
43
  import { ExpressAdapter } from './runtime/expressAdapter';
@@ -44,6 +48,7 @@ import { isPathWithin } from './pathSafety';
44
48
  import { attachCredential } from './authAttach';
45
49
  import { redactSecrets } from './redact';
46
50
  import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner';
51
+ import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck';
47
52
  import { diagnoseOperation } from '../src/engine/diagnostics';
48
53
  import { seedParamDefaults } from './normalizeFlow';
49
54
 
@@ -155,11 +160,32 @@ const agentRuns = new AgentRunManager(
155
160
  project ? project.root : projectDir,
156
161
  apiStore.dir,
157
162
  apiStore.pathOf.bind(apiStore),
158
- () => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })),
163
+ () => nodesPayload(validationRegistry, project ? project.root : process.cwd()).nodes.map(({ type, label, description }) => ({ type, label, description })),
159
164
  project?.language ?? 'typescript',
160
165
  // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
161
166
  // earlier this session primes later prompts. Malformed → null (agent guesses).
162
167
  () => loadInfrastructure(project ? project.root : projectDir),
168
+ // Runner-verified setup snapshot for guided-setup prompts: the same facts
169
+ // /setup-status reports, computed fresh at run start, so the agent starts
170
+ // from KNOWN state instead of probing for it.
171
+ () => {
172
+ const root = project ? project.root : process.cwd();
173
+ let envs = environmentsFile;
174
+ try {
175
+ envs = loadEnvironments(root);
176
+ } catch {
177
+ /* keep last good */
178
+ }
179
+ const ops = apiStore.list();
180
+ return {
181
+ gitRepo: isGitRepo(root),
182
+ skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
183
+ environmentsConfigured: envs.configured,
184
+ infrastructurePresent: readInfrastructure() !== null,
185
+ opCount: ops.length,
186
+ onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
187
+ };
188
+ },
163
189
  );
164
190
 
165
191
  // Hot-reload agent-authored nodes: watch the project's config and rebuild the
@@ -228,7 +254,28 @@ api.post('/serving', (req: Request, res: Response) => {
228
254
  });
229
255
 
230
256
  api.get('/nodes', (_req, res) => {
231
- res.json(nodesPayload(validationRegistry));
257
+ res.json(nodesPayload(validationRegistry, project ? project.root : process.cwd()));
258
+ });
259
+
260
+ // Whole-file source view + symbol table for the studio's source navigator.
261
+ // Works in project mode AND no-project mode (root = cwd) — path guards in
262
+ // sourceNav (isPathWithin, node_modules, secret basenames) bound what is
263
+ // servable either way. 400s stay generic: the requested path is never echoed.
264
+ api.get('/source-file', (req: Request, res: Response) => {
265
+ void (async () => {
266
+ const path = req.query.path;
267
+ if (typeof path !== 'string' || path.length === 0) {
268
+ res.status(400).json({ error: 'Query param path is required' });
269
+ return;
270
+ }
271
+ const root = project ? project.root : process.cwd();
272
+ const result = await getSourceFile(root, path);
273
+ if (!result.ok) {
274
+ res.status(result.status).json({ error: result.error });
275
+ return;
276
+ }
277
+ res.json(result.payload);
278
+ })();
232
279
  });
233
280
 
234
281
  // Validate a flow against the runner's LIVE registry (built-ins + project
@@ -717,6 +764,8 @@ api.post('/runs', async (req: Request, res: Response) => {
717
764
  input: effectiveInput,
718
765
  mockRun,
719
766
  mocks: effectiveMocks,
767
+ // Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
768
+ stepped: mode === 'step',
720
769
  }));
721
770
  } catch (err) {
722
771
  res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
@@ -731,23 +780,101 @@ api.post('/runs', async (req: Request, res: Response) => {
731
780
  res.status(201).json({ runId });
732
781
  });
733
782
 
783
+ // Run a single node in isolation (studio's "Run node" affordance). Executes the
784
+ // node's implementation in-process against the resolved environment's
785
+ // secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
786
+ // output through the same choke point runs use. Unknown type → 404. The studio
787
+ // bundles no implementations, so this is the ONLY path to a lone node run.
788
+ api.post('/node-run', async (req: Request, res: Response) => {
789
+ const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
790
+ if (typeof type !== 'string' || type.length === 0) {
791
+ res.status(400).json({ error: 'Body must include a node "type"' });
792
+ return;
793
+ }
794
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
795
+ res.status(400).json({ error: 'input must be a plain object' });
796
+ return;
797
+ }
798
+ if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
799
+ res.status(400).json({ error: 'config must be a plain object' });
800
+ return;
801
+ }
802
+ if (environment !== undefined && typeof environment !== 'string') {
803
+ res.status(400).json({ error: 'environment must be a string' });
804
+ return;
805
+ }
806
+ if (safeMode !== undefined && typeof safeMode !== 'boolean') {
807
+ res.status(400).json({ error: 'safeMode must be a boolean' });
808
+ return;
809
+ }
810
+ if (!runs.executionRegistry.has(type)) {
811
+ res.status(404).json({ error: `Unknown node type: ${type}` });
812
+ return;
813
+ }
814
+ const environmentName = environment ?? environmentsFile.defaultEnvironment;
815
+ const environmentDef = environmentsFile.environments[environmentName];
816
+ if (!environmentDef) {
817
+ res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
818
+ return;
819
+ }
820
+ const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
821
+ if (!safety.ok) {
822
+ res.status(400).json({ error: safety.error });
823
+ return;
824
+ }
825
+
826
+ const { implementation } = runs.executionRegistry.get(type);
827
+ const runId = `node-run-${randomUUID().slice(0, 8)}`;
828
+ const logs: LogLine[] = [];
829
+ const log = (level: LogLine['level'], message: string): void => {
830
+ logs.push({ timestamp: new Date().toISOString(), level, runId, message });
831
+ };
832
+ const nodeInput = (input ?? {}) as Record<string, unknown>;
833
+
834
+ let result: { output?: unknown; error?: string; logs: LogLine[] };
835
+ try {
836
+ const output = await implementation({
837
+ input: nodeInput,
838
+ config: (config ?? {}) as Record<string, unknown>,
839
+ secrets: environmentDef.secrets,
840
+ vars: environmentDef.vars,
841
+ environment: environmentName,
842
+ safeMode: safety.safeMode,
843
+ runInput: nodeInput,
844
+ log,
845
+ // A node run in isolation has no flow context to resolve child workflows.
846
+ runSubflow: async () => ({
847
+ status: 'failed',
848
+ error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
849
+ }),
850
+ });
851
+ result = { output, logs };
852
+ } catch (err) {
853
+ result = { error: err instanceof Error ? err.message : String(err), logs };
854
+ }
855
+ // Redact secret values from the output AND captured logs before responding.
856
+ res.json(redactSecrets(result, environmentDef.secrets));
857
+ });
858
+
734
859
  api.post('/runs/:id/step', async (req, res) => {
735
- const handle = runs.get(req.params.id);
736
- if (!handle) {
860
+ // Drill-aware composite step: stepping INTO a Subflow reports
861
+ // `entered: { workflowId, nodeId }`, completing the child reports
862
+ // `exited: true` (see RunRegistry.step). Plain steps report just `done`.
863
+ const result = await runs.step(req.params.id);
864
+ if (!result) {
737
865
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
738
866
  return;
739
867
  }
740
- const more = await handle.step();
741
- res.json({ done: !more });
868
+ res.json(result);
742
869
  });
743
870
 
744
871
  api.post('/runs/:id/cancel', (req, res) => {
745
- const handle = runs.get(req.params.id);
746
- if (!handle) {
872
+ // Drill-aware: a stepped run drilled into subflows also cancels every
873
+ // stacked child and unblocks the parent executions (see RunRegistry.cancel).
874
+ if (!runs.cancel(req.params.id)) {
747
875
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
748
876
  return;
749
877
  }
750
- handle.cancel();
751
878
  res.status(204).end();
752
879
  });
753
880
 
@@ -773,7 +900,7 @@ api.get('/runs/:id/events', (req, res) => {
773
900
  });
774
901
 
775
902
  api.get('/agent/available', (_req: Request, res: Response) => {
776
- res.json({ agents: detectAgents() });
903
+ res.json({ agents: detectAgentsCached() });
777
904
  });
778
905
 
779
906
  // First-run onboarding aggregate for the Welcome checklist (studio's
@@ -796,7 +923,10 @@ api.get('/setup-status', (_req: Request, res: Response) => {
796
923
  // A pristine project ships exactly the single `default/hello` example op.
797
924
  const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
798
925
  res.json({
799
- agents: detectAgents(),
926
+ agents: detectAgentsCached(),
927
+ // Agent features snapshot changes with git (server/agents/runManager.ts);
928
+ // the checklist gates them on this. Same notion gitScope's isGitRepo uses.
929
+ git: { repo: isGitRepo(root) },
800
930
  environments: {
801
931
  configured: envs.configured,
802
932
  // The synthesized "local" fallback is "no environments yet" — report 0.
@@ -826,6 +956,53 @@ function skillInstalled(root: string, harness: 'claude' | 'codex'): boolean {
826
956
  );
827
957
  }
828
958
 
959
+ // Package update notifier: the studio asks once per page load; checkForUpdate
960
+ // caches registry hits for an hour and fails silent — an unreachable registry
961
+ // (or a pre-publish package) just reports "no update".
962
+ api.get('/update-status', async (_req: Request, res: Response) => {
963
+ const current = ownVersion() ?? '0.0.0';
964
+ const check = await checkForUpdate(current);
965
+ if (!check) {
966
+ res.json({ current, updateAvailable: false });
967
+ return;
968
+ }
969
+ res.json(check);
970
+ });
971
+
972
+ // One-click updater: npm-installs the latest package into the consumer
973
+ // project. Single-flight — a second click while npm runs gets a 409, not a
974
+ // second npm process fighting over node_modules.
975
+ let updateInFlight = false;
976
+ api.post('/update', async (_req: Request, res: Response) => {
977
+ if (!project) {
978
+ res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
979
+ return;
980
+ }
981
+ if (isLinkedInstall(project.root)) {
982
+ res.status(400).json({
983
+ ok: false,
984
+ error:
985
+ 'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
986
+ });
987
+ return;
988
+ }
989
+ if (updateInFlight) {
990
+ res.status(409).json({ ok: false, error: 'update already running' });
991
+ return;
992
+ }
993
+ updateInFlight = true;
994
+ try {
995
+ const result = await runNpmInstall(project.root);
996
+ if (result.ok) {
997
+ res.json({ ok: true, restartRequired: true });
998
+ } else {
999
+ res.status(500).json({ ok: false, error: result.error });
1000
+ }
1001
+ } finally {
1002
+ updateInFlight = false;
1003
+ }
1004
+ });
1005
+
829
1006
  /** The /setup-status `infrastructure` field: presence + a shallow summary. */
830
1007
  function infraStatus(): { present: boolean; scannedAt?: string; itemCount?: number } {
831
1008
  const manifest = readInfrastructure();
@@ -855,11 +1032,12 @@ api.post('/agent', (req: Request, res: Response) => {
855
1032
  intent.action !== 'setup-auth' &&
856
1033
  intent.action !== 'setup-environments' &&
857
1034
  intent.action !== 'scout-infrastructure' &&
1035
+ intent.action !== 'guided-setup' &&
858
1036
  intent.action !== 'cover-operation' &&
859
1037
  intent.action !== 'ask'
860
1038
  ) {
861
1039
  res.status(400).json({
862
- 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.`,
1040
+ 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.`,
863
1041
  });
864
1042
  return;
865
1043
  }
@@ -873,7 +1051,11 @@ api.post('/agent', (req: Request, res: Response) => {
873
1051
  res.status(400).json({ error: 'Body must include intent: { action: "setup-auth", environment, instruction }' });
874
1052
  return;
875
1053
  }
876
- } else if (intent.action === 'setup-environments' || intent.action === 'scout-infrastructure') {
1054
+ } else if (
1055
+ intent.action === 'setup-environments' ||
1056
+ intent.action === 'scout-infrastructure' ||
1057
+ intent.action === 'guided-setup'
1058
+ ) {
877
1059
  // instruction alone is required, already validated above; no flowId/environment needed.
878
1060
  } else if (intent.action === 'ask') {
879
1061
  if (intent.flowId !== undefined && typeof intent.flowId !== 'string') {
@@ -975,7 +1157,7 @@ const operationRunEnv = {
975
1157
  // operation whose http.path collides with one of these would silently never
976
1158
  // be reached (the internal route, registered first, always wins) — guarded
977
1159
  // against below rather than left as a silent trap.
978
- const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
1160
+ const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
979
1161
 
980
1162
  function collidesWithReservedPath(path: string): boolean {
981
1163
  return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
@@ -1097,6 +1279,22 @@ if (serveStudio) {
1097
1279
  }
1098
1280
  }
1099
1281
 
1282
+ // Agent CLIs and node executions must not outlive the runner: agents are
1283
+ // spawned detached (their own process group), so without this a killed runner
1284
+ // leaves an orphaned agent editing the project — and possibly writing files
1285
+ // long after the studio is gone (observed in the wild). Cancel everything,
1286
+ // then exit.
1287
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
1288
+ process.on(signal, () => {
1289
+ try {
1290
+ agentRuns.shutdown();
1291
+ runs.shutdown();
1292
+ } finally {
1293
+ process.exit(0);
1294
+ }
1295
+ });
1296
+ }
1297
+
1100
1298
  app.listen(PORT, HOST, () => {
1101
1299
  console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
1102
1300
  if (serveStudio) {
@@ -0,0 +1,189 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ // Verifies POST /node-run: the studio's "Run node" affordance runs a single
8
+ // node in-process on the runner (execution moved server-side; the studio bundles
9
+ // no node implementations). Boots the actual runner subprocess against a scratch
10
+ // project dir, mirroring runsAuth.test.ts / apiMount.test.ts.
11
+
12
+ let proc: ChildProcess;
13
+ const PORT = 8155;
14
+ const base = `http://127.0.0.1:${PORT}`;
15
+ let projectDir: string;
16
+
17
+ async function waitHealthy(url: string, tries = 40): Promise<void> {
18
+ for (let i = 0; i < tries; i++) {
19
+ try {
20
+ if ((await fetch(url)).ok) return;
21
+ } catch {
22
+ /* not up yet */
23
+ }
24
+ await new Promise((r) => setTimeout(r, 150));
25
+ }
26
+ throw new Error('runner did not become healthy');
27
+ }
28
+
29
+ function bootRunner(port: number, env: Record<string, string | undefined>): ChildProcess {
30
+ return spawn('npx', ['tsx', 'server/index.ts'], {
31
+ env: { ...process.env, EMBERFLOW_RUNNER_PORT: String(port), ...env },
32
+ stdio: 'ignore',
33
+ });
34
+ }
35
+
36
+ async function nodeRun(body: unknown): Promise<{ status: number; json: any }> {
37
+ const res = await fetch(`${base}/api/node-run`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+ return { status: res.status, json: await res.json().catch(() => undefined) };
43
+ }
44
+
45
+ // Fixture secret value — looks like a real credential to redaction logic
46
+ // (>= 6 chars) but is clearly a test fixture, never a live secret.
47
+ const FIXTURE_SECRET = 'fixture-secret-123';
48
+
49
+ beforeAll(async () => {
50
+ projectDir = mkdtempSync(join(tmpdir(), 'noderun-'));
51
+ mkdirSync(join(projectDir, 'emberflow', 'apis', 'default'), { recursive: true });
52
+ writeFileSync(
53
+ join(projectDir, 'emberflow.config.mjs'),
54
+ `export default {
55
+ registerNodes(registry) {
56
+ // Embeds a secret in its output AND in a log line — exercises redaction
57
+ // across both response fields.
58
+ registry.register(
59
+ { type: 'EchoSecret', label: 'Echo Secret', traceKind: 'pure', inputSchema: { fields: [] } },
60
+ async (ctx) => {
61
+ ctx.log('info', \`fetched key \${ctx.secrets.API_KEY}\`);
62
+ return { message: \`key is \${ctx.secrets.API_KEY}\` };
63
+ },
64
+ );
65
+ // Throws with the secret embedded in the error message — exercises
66
+ // redaction on the error path.
67
+ registry.register(
68
+ { type: 'ThrowSecret', label: 'Throw Secret', traceKind: 'pure', inputSchema: { fields: [] } },
69
+ async (ctx) => {
70
+ throw new Error(\`boom \${ctx.secrets.API_KEY}\`);
71
+ },
72
+ );
73
+ // Echoes ctx.safeMode so tests can observe the resolved safety mode.
74
+ registry.register(
75
+ { type: 'EchoSafeMode', label: 'Echo Safe Mode', traceKind: 'pure', inputSchema: { fields: [] } },
76
+ async (ctx) => ({ safeMode: ctx.safeMode }),
77
+ );
78
+ },
79
+ };\n`,
80
+ );
81
+ writeFileSync(
82
+ join(projectDir, 'emberflow.environments.json'),
83
+ JSON.stringify({
84
+ defaultEnvironment: 'local',
85
+ environments: {
86
+ local: { vars: {}, secrets: {} },
87
+ leaky: { vars: {}, secrets: { API_KEY: FIXTURE_SECRET } },
88
+ prod: { protected: true, vars: {}, secrets: {} },
89
+ },
90
+ }),
91
+ );
92
+
93
+ proc = bootRunner(PORT, { EMBERFLOW_PROJECT: projectDir });
94
+ await waitHealthy(`${base}/healthz`);
95
+ }, 20_000);
96
+
97
+ afterAll(() => {
98
+ proc?.kill();
99
+ if (projectDir) rmSync(projectDir, { recursive: true, force: true });
100
+ });
101
+
102
+ describe('POST /node-run', () => {
103
+ it('runs a single built-in node in-process and returns its output + logs', async () => {
104
+ // CheckPlan is a pure built-in: input.user.plan -> { plan, features } + a log.
105
+ const { status, json } = await nodeRun({
106
+ type: 'CheckPlan',
107
+ input: { user: { plan: 'pro' } },
108
+ });
109
+ expect(status).toBe(200);
110
+ expect(json.output).toEqual({ plan: 'pro', features: ['sso', 'audit-log', 'priority-support'] });
111
+ expect(json.error).toBeUndefined();
112
+ expect(json.logs.some((l: { message: string }) => l.message.includes('pro'))).toBe(true);
113
+ });
114
+
115
+ it('404s for an unknown node type', async () => {
116
+ const { status, json } = await nodeRun({ type: 'NoSuchNode', input: {} });
117
+ expect(status).toBe(404);
118
+ expect(json.error).toContain('Unknown node type');
119
+ });
120
+
121
+ it('400s when the body omits a node type', async () => {
122
+ const { status } = await nodeRun({ input: {} });
123
+ expect(status).toBe(400);
124
+ });
125
+
126
+ it('400s for an unknown environment', async () => {
127
+ const { status, json } = await nodeRun({
128
+ type: 'CheckPlan',
129
+ input: { user: {} },
130
+ environment: 'ghost',
131
+ });
132
+ expect(status).toBe(400);
133
+ expect(json.error).toContain('Unknown environment');
134
+ });
135
+ });
136
+
137
+ describe('POST /node-run redacts secrets at the boundary', () => {
138
+ it('redacts a secret value embedded in the output and logs, never leaking the raw value', async () => {
139
+ const { status, json } = await nodeRun({ type: 'EchoSecret', input: {}, environment: 'leaky' });
140
+ expect(status).toBe(200);
141
+ expect(json.error).toBeUndefined();
142
+
143
+ expect(json.output.message).toContain('«secret:API_KEY»');
144
+ expect(json.output.message).not.toContain(FIXTURE_SECRET);
145
+
146
+ const logMessages = json.logs.map((l: { message: string }) => l.message).join('\n');
147
+ expect(logMessages).toContain('«secret:API_KEY»');
148
+ expect(logMessages).not.toContain(FIXTURE_SECRET);
149
+ });
150
+
151
+ it('redacts a secret value embedded in a thrown error message', async () => {
152
+ const { status, json } = await nodeRun({ type: 'ThrowSecret', input: {}, environment: 'leaky' });
153
+ expect(status).toBe(200);
154
+ expect(json.output).toBeUndefined();
155
+ expect(json.error).toContain('«secret:API_KEY»');
156
+ expect(json.error).not.toContain(FIXTURE_SECRET);
157
+ });
158
+ });
159
+
160
+ describe('POST /node-run gates unsafe runs on protected environments', () => {
161
+ it('400s an explicit safeMode:false on a protected environment without a matching confirm', async () => {
162
+ const { status, json } = await nodeRun({
163
+ type: 'EchoSafeMode',
164
+ input: {},
165
+ environment: 'prod',
166
+ safeMode: false,
167
+ });
168
+ expect(status).toBe(400);
169
+ expect(json.error).toContain("unsafe run on protected environment 'prod' requires confirm");
170
+ });
171
+
172
+ it('accepts safeMode:false on a protected environment with the matching confirm, and the node observes it', async () => {
173
+ const { status, json } = await nodeRun({
174
+ type: 'EchoSafeMode',
175
+ input: {},
176
+ environment: 'prod',
177
+ safeMode: false,
178
+ confirm: 'prod',
179
+ });
180
+ expect(status).toBe(200);
181
+ expect(json.output).toEqual({ safeMode: false });
182
+ });
183
+
184
+ it('defaults to safeMode:true on a protected environment when unspecified', async () => {
185
+ const { status, json } = await nodeRun({ type: 'EchoSafeMode', input: {}, environment: 'prod' });
186
+ expect(status).toBe(200);
187
+ expect(json.output).toEqual({ safeMode: true });
188
+ });
189
+ });
@@ -26,4 +26,42 @@ describe('nodesPayload', () => {
26
26
  expect(round.nodes[0].type).toBe('A');
27
27
  expect(typeof round.nodes[0].source).toBe('string');
28
28
  });
29
+
30
+ it('adds a repo-relative sourceRef when the captured file is inside the project root', () => {
31
+ const r = new NodeRegistry();
32
+ r.register({ type: 'X', label: 'X' }, async () => ({}), {
33
+ sourceRef: { file: '/proj/nodes/logic.mjs', line: 12 },
34
+ });
35
+ const node = nodesPayload(r, '/proj').nodes[0];
36
+ expect(node.sourceRef).toEqual({ file: 'nodes/logic.mjs', line: 12 });
37
+ expect(node.builtin).toBeUndefined();
38
+ });
39
+
40
+ it('flags builtin: true when the sourceRef points outside the project root', () => {
41
+ const r = new NodeRegistry();
42
+ r.register({ type: 'Y', label: 'Y' }, async () => ({}), {
43
+ sourceRef: { file: '/somewhere/else/pkg/node.ts', line: 3 },
44
+ });
45
+ const node = nodesPayload(r, '/proj').nodes[0];
46
+ expect(node.builtin).toBe(true);
47
+ expect(node.sourceRef).toBeUndefined();
48
+ });
49
+
50
+ it('omits sourceRef and builtin entirely when nothing was captured', () => {
51
+ const r = new NodeRegistry();
52
+ r.register({ type: 'Z', label: 'Z' }, async () => ({}));
53
+ const node = nodesPayload(r, '/proj').nodes[0];
54
+ expect('sourceRef' in node).toBe(false);
55
+ expect('builtin' in node).toBe(false);
56
+ });
57
+
58
+ it('preserves the source (toString) field alongside sourceRef', () => {
59
+ const r = new NodeRegistry();
60
+ r.register({ type: 'W', label: 'W' }, async () => ({ shout: true }), {
61
+ sourceRef: { file: '/proj/w.mjs', line: 1 },
62
+ });
63
+ const node = nodesPayload(r, '/proj').nodes[0];
64
+ expect(node.source).toContain('shout');
65
+ expect(node.sourceRef).toEqual({ file: 'w.mjs', line: 1 });
66
+ });
29
67
  });
@@ -1,3 +1,4 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
1
2
  import type { NodeDefinition, NodeRegistry } from '../src/engine';
2
3
 
3
4
  /**
@@ -6,12 +7,27 @@ import type { NodeDefinition, NodeRegistry } from '../src/engine';
6
7
  * implementation's toString() so the Inspector can show a node's code without
7
8
  * the browser bundling the implementation — the key to consumer nodes
8
9
  * appearing in the palette without a per-project browser build.
10
+ *
11
+ * `sourceRef` (repo-relative file + line of the register() call) is present
12
+ * only when registration provenance was captured AND the file lives inside
13
+ * the project root — the studio uses it to open the real source via
14
+ * GET /source-file. A captured ref OUTSIDE the root (Emberflow's own
15
+ * built-ins registered from the package) is flagged `builtin: true` instead:
16
+ * those files are never served (node_modules exposure rule), so the
17
+ * Inspector keeps the toString() view for them.
9
18
  */
10
19
  export interface NodeMetaPayload {
11
- nodes: Array<NodeDefinition & { source?: string }>;
20
+ nodes: Array<
21
+ NodeDefinition & {
22
+ source?: string;
23
+ sourceRef?: { file: string; line?: number };
24
+ builtin?: boolean;
25
+ }
26
+ >;
12
27
  }
13
28
 
14
- export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
29
+ export function nodesPayload(registry: NodeRegistry, projectRoot?: string): NodeMetaPayload {
30
+ const root = resolve(projectRoot ?? process.cwd());
15
31
  return {
16
32
  nodes: registry.list().map((definition) => {
17
33
  let source: string | undefined;
@@ -20,7 +36,16 @@ export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
20
36
  } catch {
21
37
  source = undefined;
22
38
  }
23
- return { ...definition, source };
39
+ const ref = registry.getSourceRef(definition.type);
40
+ if (!ref) return { ...definition, source };
41
+ const rel = relative(root, resolve(root, ref.file));
42
+ const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
43
+ if (!inside) return { ...definition, source, builtin: true };
44
+ return {
45
+ ...definition,
46
+ source,
47
+ sourceRef: ref.line !== undefined ? { file: rel, line: ref.line } : { file: rel },
48
+ };
24
49
  }),
25
50
  };
26
51
  }
@@ -49,8 +49,11 @@ export function buildRegistries(project: ProjectConfig | null): {
49
49
  validation: NodeRegistry;
50
50
  execution: NodeRegistry;
51
51
  } {
52
- const validation = createDefaultRegistry();
53
- const execution = createDefaultRegistry();
52
+ // Server-side registries capture registration provenance (file:line of each
53
+ // register() call) so the studio can navigate to a node's real source.
54
+ // Browser registries never enable this — see createDefaultRegistry.
55
+ const validation = createDefaultRegistry(undefined, { captureSourceRefs: true });
56
+ const execution = createDefaultRegistry(undefined, { captureSourceRefs: true });
54
57
  if (project?.registerNodes) {
55
58
  project.registerNodes(validation);
56
59
  project.registerNodes(execution);