@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
@@ -0,0 +1,503 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { promises as fsp } from 'node:fs';
3
+ import { builtinModules } from 'node:module';
4
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { isPathWithin } from './pathSafety.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Lazy typescript module
8
+ let tsModulePromise;
9
+ async function loadTs(loader) {
10
+ if (loader) {
11
+ try {
12
+ return await loader();
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ if (!tsModulePromise) {
19
+ tsModulePromise = import('typescript').then((m) => (m.default ?? m));
20
+ }
21
+ try {
22
+ return await tsModulePromise;
23
+ }
24
+ catch {
25
+ tsModulePromise = undefined; // allow a later retry (e.g. install completed)
26
+ return undefined;
27
+ }
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Path guards
31
+ const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
32
+ function languageOf(file) {
33
+ return TS_EXTS.has(extname(file)) ? 'ts' : 'js';
34
+ }
35
+ /** Deny-list beyond isPathWithin: node_modules anywhere, secret basenames. */
36
+ function isRequestPathAllowed(projectRoot, rel) {
37
+ if (!isPathWithin(projectRoot, rel))
38
+ return false;
39
+ const segments = rel.split('/');
40
+ if (segments.includes('node_modules'))
41
+ return false;
42
+ const base = (segments[segments.length - 1] ?? '').toLowerCase();
43
+ if (base.startsWith('.env'))
44
+ return false;
45
+ if (base === 'emberflow.secrets.json')
46
+ return false;
47
+ if (base.endsWith('.pem') || base.endsWith('.key'))
48
+ return false;
49
+ return true;
50
+ }
51
+ const parseCache = new Map();
52
+ /** Clears all sourceNav caches (parse, tsconfig, ts module) — tests only. */
53
+ export function resetSourceNavCaches() {
54
+ parseCache.clear();
55
+ tsconfigCache.clear();
56
+ tsModulePromise = undefined;
57
+ }
58
+ function scriptKindFor(ts, file) {
59
+ switch (extname(file)) {
60
+ case '.ts':
61
+ case '.mts':
62
+ case '.cts':
63
+ return ts.ScriptKind.TS;
64
+ case '.tsx':
65
+ return ts.ScriptKind.TSX;
66
+ case '.jsx':
67
+ return ts.ScriptKind.JSX;
68
+ default:
69
+ return ts.ScriptKind.JS;
70
+ }
71
+ }
72
+ function parseFile(ts, absPath, content) {
73
+ const sf = ts.createSourceFile(absPath, content, ts.ScriptTarget.Latest,
74
+ /* setParentNodes */ true, scriptKindFor(ts, absPath));
75
+ const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
76
+ const hasExportModifier = (node) => {
77
+ const mods = node.modifiers;
78
+ return !!mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
79
+ };
80
+ const declarations = [];
81
+ const imports = [];
82
+ const reexports = [];
83
+ const dynamicImports = [];
84
+ /** Names exported post-hoc via a specifier-less `export { x }`. */
85
+ const localExports = new Set();
86
+ for (const stmt of sf.statements) {
87
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) {
88
+ declarations.push({
89
+ name: stmt.name.text,
90
+ kind: 'fn',
91
+ line: lineOf(stmt.getStart(sf)),
92
+ endLine: lineOf(stmt.end),
93
+ exported: hasExportModifier(stmt),
94
+ });
95
+ }
96
+ else if (ts.isClassDeclaration(stmt) && stmt.name) {
97
+ declarations.push({
98
+ name: stmt.name.text,
99
+ kind: 'class',
100
+ line: lineOf(stmt.getStart(sf)),
101
+ endLine: lineOf(stmt.end),
102
+ exported: hasExportModifier(stmt),
103
+ });
104
+ }
105
+ else if (ts.isVariableStatement(stmt)) {
106
+ for (const decl of stmt.declarationList.declarations) {
107
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
108
+ declarations.push({
109
+ name: decl.name.text,
110
+ kind: 'const',
111
+ line: lineOf(decl.getStart(sf)),
112
+ endLine: lineOf(stmt.end),
113
+ exported: hasExportModifier(stmt),
114
+ });
115
+ }
116
+ }
117
+ }
118
+ else if (ts.isInterfaceDeclaration(stmt) ||
119
+ ts.isTypeAliasDeclaration(stmt) ||
120
+ ts.isEnumDeclaration(stmt)) {
121
+ declarations.push({
122
+ name: stmt.name.text,
123
+ kind: 'other',
124
+ line: lineOf(stmt.getStart(sf)),
125
+ endLine: lineOf(stmt.end),
126
+ exported: hasExportModifier(stmt),
127
+ });
128
+ }
129
+ else if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
130
+ const from = stmt.moduleSpecifier.text;
131
+ const clause = stmt.importClause;
132
+ if (!clause) {
133
+ imports.push({ name: '*', local: '', from }); // side-effect import
134
+ continue;
135
+ }
136
+ if (clause.name)
137
+ imports.push({ name: 'default', local: clause.name.text, from });
138
+ const bindings = clause.namedBindings;
139
+ if (bindings) {
140
+ if (ts.isNamespaceImport(bindings)) {
141
+ imports.push({ name: '*', local: bindings.name.text, from });
142
+ }
143
+ else {
144
+ for (const el of bindings.elements) {
145
+ imports.push({ name: (el.propertyName ?? el.name).text, local: el.name.text, from });
146
+ }
147
+ }
148
+ }
149
+ }
150
+ else if (ts.isExportDeclaration(stmt)) {
151
+ const spec = stmt.moduleSpecifier;
152
+ if (spec && ts.isStringLiteral(spec)) {
153
+ const from = spec.text;
154
+ if (!stmt.exportClause) {
155
+ reexports.push({ exported: '*', from });
156
+ }
157
+ else if (ts.isNamespaceExport(stmt.exportClause)) {
158
+ reexports.push({ exported: stmt.exportClause.name.text, source: '*', from });
159
+ }
160
+ else {
161
+ for (const el of stmt.exportClause.elements) {
162
+ reexports.push({
163
+ exported: el.name.text,
164
+ source: (el.propertyName ?? el.name).text,
165
+ from,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ else if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
171
+ for (const el of stmt.exportClause.elements) {
172
+ localExports.add((el.propertyName ?? el.name).text);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ // `export { x }` after the declaration marks it exported.
178
+ for (const d of declarations) {
179
+ if (!d.exported && localExports.has(d.name))
180
+ d.exported = true;
181
+ }
182
+ // Dynamic imports live anywhere in the tree.
183
+ const visit = (node) => {
184
+ if (ts.isCallExpression(node) &&
185
+ node.expression.kind === ts.SyntaxKind.ImportKeyword) {
186
+ const arg = node.arguments[0];
187
+ dynamicImports.push(arg && ts.isStringLiteral(arg) ? { from: arg.text } : {});
188
+ }
189
+ ts.forEachChild(node, visit);
190
+ };
191
+ visit(sf);
192
+ return { content, language: languageOf(absPath), declarations, imports, dynamicImports, reexports };
193
+ }
194
+ /** Parse (or reuse the cached parse of) an absolute path. Throws when unreadable. */
195
+ function getParsed(ts, absPath) {
196
+ const stat = statSync(absPath);
197
+ const cached = parseCache.get(absPath);
198
+ if (cached && cached.mtimeMs === stat.mtimeMs)
199
+ return cached.parsed;
200
+ const content = readFileSync(absPath, 'utf8');
201
+ const parsed = parseFile(ts, absPath, content);
202
+ parseCache.set(absPath, { mtimeMs: stat.mtimeMs, parsed });
203
+ return parsed;
204
+ }
205
+ function getParsedSafe(ts, absPath) {
206
+ try {
207
+ return getParsed(ts, absPath);
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // Specifier resolution
215
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];
216
+ /** TS-style specifier mapping: an emitted-extension specifier may point at TS source. */
217
+ const JS_TO_TS = {
218
+ '.js': ['.ts', '.tsx'],
219
+ '.mjs': ['.mts'],
220
+ '.cjs': ['.cts'],
221
+ };
222
+ function isFile(p) {
223
+ try {
224
+ return statSync(p).isFile();
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
230
+ /** Extension ladder: exact → +ext → TS .js→.ts mapping → /index.* */
231
+ function resolveWithLadder(base) {
232
+ if (isFile(base))
233
+ return base;
234
+ for (const ext of RESOLVE_EXTS) {
235
+ if (isFile(base + ext))
236
+ return base + ext;
237
+ }
238
+ for (const [jsExt, tsExts] of Object.entries(JS_TO_TS)) {
239
+ if (base.endsWith(jsExt)) {
240
+ const stem = base.slice(0, -jsExt.length);
241
+ for (const tsExt of tsExts) {
242
+ if (isFile(stem + tsExt))
243
+ return stem + tsExt;
244
+ }
245
+ }
246
+ }
247
+ for (const ext of RESOLVE_EXTS) {
248
+ const idx = join(base, 'index' + ext);
249
+ if (isFile(idx))
250
+ return idx;
251
+ }
252
+ return undefined;
253
+ }
254
+ const tsconfigCache = new Map();
255
+ function tsconfigPathsFor(ts, projectRoot) {
256
+ const cfgPath = join(projectRoot, 'tsconfig.json');
257
+ let mtimeMs;
258
+ try {
259
+ mtimeMs = statSync(cfgPath).mtimeMs;
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ const cached = tsconfigCache.get(cfgPath);
265
+ if (cached && cached.mtimeMs === mtimeMs)
266
+ return cached.paths;
267
+ let paths;
268
+ try {
269
+ // ts.readConfigFile parses leniently — tsconfig.json is JSONC.
270
+ const { config } = ts.readConfigFile(cfgPath, (p) => readFileSync(p, 'utf8'));
271
+ const options = config
272
+ ?.compilerOptions;
273
+ const rawPaths = options?.paths;
274
+ if (rawPaths && typeof rawPaths === 'object') {
275
+ const entries = [];
276
+ for (const [pattern, targetsRaw] of Object.entries(rawPaths)) {
277
+ if (!Array.isArray(targetsRaw))
278
+ continue;
279
+ const targets = targetsRaw.filter((t) => typeof t === 'string');
280
+ const stars = pattern.split('*').length - 1;
281
+ if (stars === 0) {
282
+ entries.push({ prefix: '', suffix: '', exact: pattern, targets });
283
+ }
284
+ else if (stars === 1) {
285
+ const [prefix = '', suffix = ''] = pattern.split('*');
286
+ entries.push({ prefix, suffix, targets });
287
+ }
288
+ // multi-star patterns are invalid tsconfig anyway — ignored
289
+ }
290
+ // Longest prefix wins, mirroring TS's matching order.
291
+ entries.sort((a, b) => (b.exact ?? b.prefix).length - (a.exact ?? a.prefix).length);
292
+ paths = { base: resolve(projectRoot, options?.baseUrl ?? '.'), entries };
293
+ }
294
+ }
295
+ catch {
296
+ paths = undefined;
297
+ }
298
+ tsconfigCache.set(cfgPath, { mtimeMs, paths });
299
+ return paths;
300
+ }
301
+ function resolveViaTsconfigPaths(ts, projectRoot, spec) {
302
+ const cfg = tsconfigPathsFor(ts, projectRoot);
303
+ if (!cfg)
304
+ return undefined;
305
+ for (const entry of cfg.entries) {
306
+ let substituted;
307
+ if (entry.exact !== undefined) {
308
+ if (spec === entry.exact)
309
+ substituted = entry.targets;
310
+ }
311
+ else if (spec.startsWith(entry.prefix) &&
312
+ spec.endsWith(entry.suffix) &&
313
+ spec.length >= entry.prefix.length + entry.suffix.length) {
314
+ const middle = spec.slice(entry.prefix.length, spec.length - entry.suffix.length);
315
+ substituted = entry.targets.map((t) => t.replace('*', middle));
316
+ }
317
+ if (!substituted)
318
+ continue;
319
+ for (const target of substituted) {
320
+ const found = resolveWithLadder(resolve(cfg.base, target));
321
+ if (found)
322
+ return found;
323
+ }
324
+ }
325
+ return undefined;
326
+ }
327
+ const NODE_BUILTINS = new Set(builtinModules);
328
+ /** Repo-relative POSIX path when `abs` sits inside the root (and outside node_modules). */
329
+ function projectRelative(projectRoot, abs) {
330
+ const rel = relative(projectRoot, abs);
331
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
332
+ return undefined;
333
+ if (rel.split('/').includes('node_modules'))
334
+ return undefined;
335
+ return rel;
336
+ }
337
+ /**
338
+ * Resolve a specifier from `importerAbs`. Project results carry the ABSOLUTE
339
+ * target path in `absPath` (for chain following); callers convert to
340
+ * repo-relative for the response.
341
+ */
342
+ function resolveSpecifier(ts, projectRoot, importerAbs, spec) {
343
+ if (spec.startsWith('./') || spec.startsWith('../')) {
344
+ const target = resolveWithLadder(resolve(dirname(importerAbs), spec));
345
+ if (!target)
346
+ return { kind: 'unresolved', reason: `cannot resolve relative import '${spec}'` };
347
+ const rel = projectRelative(projectRoot, target);
348
+ if (!rel)
349
+ return { kind: 'unresolved', reason: 'resolves outside the project root' };
350
+ return { kind: 'project', path: rel, absPath: target };
351
+ }
352
+ if (isAbsolute(spec))
353
+ return { kind: 'unresolved', reason: 'absolute-path specifier' };
354
+ if (spec.startsWith('node:'))
355
+ return { kind: 'builtin' };
356
+ const viaPaths = resolveViaTsconfigPaths(ts, projectRoot, spec);
357
+ if (viaPaths) {
358
+ const rel = projectRelative(projectRoot, viaPaths);
359
+ if (!rel)
360
+ return { kind: 'unresolved', reason: 'resolves outside the project root' };
361
+ return { kind: 'project', path: rel, absPath: viaPaths };
362
+ }
363
+ if (NODE_BUILTINS.has(spec))
364
+ return { kind: 'builtin' };
365
+ const parts = spec.split('/');
366
+ const pkg = spec.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
367
+ return { kind: 'external', package: pkg };
368
+ }
369
+ // ---------------------------------------------------------------------------
370
+ // Re-export chain following
371
+ const REEXPORT_DEPTH_CAP = 8;
372
+ /**
373
+ * Find the file+line where `exportName` is actually DECLARED, starting at
374
+ * `absFile` and following named/star re-exports. Depth-capped, cycle-safe.
375
+ */
376
+ function findExportedDeclaration(ts, projectRoot, absFile, exportName, depth = 0, seen = new Set()) {
377
+ if (depth > REEXPORT_DEPTH_CAP)
378
+ return undefined;
379
+ const key = `${absFile}::${exportName}`;
380
+ if (seen.has(key))
381
+ return undefined;
382
+ seen.add(key);
383
+ const parsed = getParsedSafe(ts, absFile);
384
+ if (!parsed)
385
+ return undefined;
386
+ const decl = parsed.declarations.find((d) => d.exported && d.name === exportName) ??
387
+ parsed.declarations.find((d) => d.name === exportName);
388
+ if (decl)
389
+ return { absFile, line: decl.line };
390
+ const named = parsed.reexports.find((r) => r.exported === exportName && r.source && r.source !== '*');
391
+ if (named) {
392
+ const res = resolveSpecifier(ts, projectRoot, absFile, named.from);
393
+ if (res.kind === 'project' && res.absPath) {
394
+ return findExportedDeclaration(ts, projectRoot, res.absPath, named.source, depth + 1, seen);
395
+ }
396
+ return undefined;
397
+ }
398
+ for (const star of parsed.reexports.filter((r) => r.exported === '*')) {
399
+ const res = resolveSpecifier(ts, projectRoot, absFile, star.from);
400
+ if (res.kind !== 'project' || !res.absPath)
401
+ continue;
402
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, exportName, depth + 1, seen);
403
+ if (found)
404
+ return found;
405
+ }
406
+ return undefined;
407
+ }
408
+ /** Resolution for an import/re-export entry, chased to the declaring file when possible. */
409
+ function resolveEntry(ts, projectRoot, importerAbs, spec, symbolName) {
410
+ const res = resolveSpecifier(ts, projectRoot, importerAbs, spec);
411
+ if (res.kind !== 'project' || !res.absPath) {
412
+ const { absPath: _drop, ...pub } = res;
413
+ return pub;
414
+ }
415
+ if (symbolName && symbolName !== '*' && symbolName !== 'default') {
416
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, symbolName);
417
+ if (found) {
418
+ const rel = projectRelative(projectRoot, found.absFile);
419
+ if (rel)
420
+ return { kind: 'project', path: rel, line: found.line };
421
+ }
422
+ }
423
+ return { kind: 'project', path: res.path };
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // Entry point
427
+ export async function getSourceFile(projectRoot, relPath, opts) {
428
+ const root = resolve(projectRoot);
429
+ // Generic 400: never echo the requested path back to the client.
430
+ if (typeof relPath !== 'string' || !isRequestPathAllowed(root, relPath)) {
431
+ return { ok: false, status: 400, error: 'Invalid or denied path' };
432
+ }
433
+ const abs = resolve(root, relPath);
434
+ let stat;
435
+ try {
436
+ stat = await fsp.stat(abs);
437
+ }
438
+ catch {
439
+ return { ok: false, status: 404, error: 'File not found' };
440
+ }
441
+ if (!stat.isFile())
442
+ return { ok: false, status: 404, error: 'File not found' };
443
+ const responsePath = relative(root, abs);
444
+ const ts = await loadTs(opts?.tsLoader);
445
+ if (!ts) {
446
+ const content = await fsp.readFile(abs, 'utf8');
447
+ return {
448
+ ok: true,
449
+ payload: {
450
+ path: responsePath,
451
+ content,
452
+ language: languageOf(abs),
453
+ resolver: 'unavailable',
454
+ symbols: { declarations: [], imports: [], reexports: [] },
455
+ },
456
+ };
457
+ }
458
+ let parsed;
459
+ try {
460
+ parsed = getParsed(ts, abs);
461
+ }
462
+ catch {
463
+ return { ok: false, status: 404, error: 'File not found' };
464
+ }
465
+ const imports = parsed.imports.map((raw) => ({
466
+ name: raw.name,
467
+ local: raw.local,
468
+ from: raw.from,
469
+ resolution: resolveEntry(ts, root, abs, raw.from, raw.name),
470
+ }));
471
+ for (const dyn of parsed.dynamicImports) {
472
+ if (dyn.from !== undefined) {
473
+ imports.push({
474
+ name: 'import()',
475
+ local: '',
476
+ from: dyn.from,
477
+ resolution: resolveEntry(ts, root, abs, dyn.from, undefined),
478
+ });
479
+ }
480
+ else {
481
+ imports.push({
482
+ name: 'import()',
483
+ local: '',
484
+ from: '(computed)',
485
+ resolution: { kind: 'unresolved', reason: 'dynamic import with a computed specifier' },
486
+ });
487
+ }
488
+ }
489
+ const reexports = parsed.reexports.map((raw) => ({
490
+ name: raw.exported,
491
+ from: raw.from,
492
+ resolution: resolveEntry(ts, root, abs, raw.from, raw.source && raw.source !== '*' ? raw.source : undefined),
493
+ }));
494
+ return {
495
+ ok: true,
496
+ payload: {
497
+ path: responsePath,
498
+ content: parsed.content,
499
+ language: parsed.language,
500
+ symbols: { declarations: parsed.declarations, imports, reexports },
501
+ },
502
+ };
503
+ }
@@ -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
@@ -12,6 +12,11 @@ export const SUBFLOW_DEPTH_CAP = 8;
12
12
  */
13
13
  export function makeSubflowRunner(opts, ancestry) {
14
14
  return async (workflowId, input, callerNodeId) => {
15
+ // A cancelled stepped run's unwinding parents (their runSubflow was
16
+ // rejected) may retry: never push a new child that nothing will step.
17
+ if (opts.drill?.cancelled) {
18
+ return { status: 'failed', error: 'run cancelled' };
19
+ }
15
20
  if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
16
21
  return { status: 'failed', error: `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded` };
17
22
  }
@@ -40,17 +45,39 @@ export function makeSubflowRunner(opts, ancestry) {
40
45
  mocks: childFlow.mocks ?? {},
41
46
  trace: opts.trace,
42
47
  subflowRunner: makeSubflowRunner(opts, [...ancestry, workflowId]),
43
- events: opts.onLog
48
+ events: opts.onLog || (opts.drill && opts.onNodeState)
44
49
  ? {
45
- onLog: (line) => opts.onLog({
46
- ...line,
47
- nodeLabel: `${childFlow.name} ${line.nodeLabel ?? ''}`,
48
- nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
49
- }),
50
+ ...(opts.onLog
51
+ ? {
52
+ onLog: (line) => opts.onLog({
53
+ ...line,
54
+ nodeLabel: `${childFlow.name} › ${line.nodeLabel ?? ''}`,
55
+ nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
56
+ }),
57
+ }
58
+ : {}),
59
+ // Stepped children stream their node states into the ROOT
60
+ // run's SSE stream, tagged with the child flow's id.
61
+ ...(opts.drill && opts.onNodeState
62
+ ? {
63
+ onNodeStateChange: (nodeId, state) => opts.onNodeState(childFlow.id, nodeId, state),
64
+ }
65
+ : {}),
50
66
  }
51
67
  : undefined,
52
68
  });
53
- const childRun = await childHandle.runToEnd();
69
+ // Stepped run: don't run the child here. Push it onto the shared drill
70
+ // stack and block this Subflow node until the registry's composite
71
+ // step() drives the child to completion and pops the entry — resolving
72
+ // with the completed child run, which flows into the exact same
73
+ // output-extraction / failure-mapping path runToEnd feeds today.
74
+ const childRun = opts.drill
75
+ ? await new Promise((resolve, reject) => {
76
+ const entry = { handle: childHandle, workflowId, viaNodeId: callerNodeId, resolve, reject };
77
+ opts.drill.stack.push(entry);
78
+ opts.drill.onChildStarted?.(entry);
79
+ })
80
+ : await childHandle.runToEnd();
54
81
  if (childRun.status !== 'succeeded') {
55
82
  // Surface the child's actual failure (e.g. the Mock-mode "would touch
56
83
  // real infrastructure" boundary message) through the Subflow node's
@@ -0,0 +1,68 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Update notifier + installer plumbing for consumer projects (they install
4
+ * @xdelivered/emberflow from npm and run `npx emberflow dev`).
5
+ *
6
+ * - checkForUpdate: asks the npm registry for the latest published version and
7
+ * compares it against the running one. Fail-SILENT by design: any network,
8
+ * HTTP, or parse failure returns null — an update nudge must never break or
9
+ * slow the studio. Results (including failures) are cached in-memory for an
10
+ * hour so /update-status stays cheap and the registry isn't hammered.
11
+ * - runNpmInstall: the actual one-click updater — `npm install <pkg>@latest`
12
+ * spawned (no shell) in the project root. Injectable spawn for tests.
13
+ */
14
+ export declare const EMBERFLOW_PACKAGE = "@xdelivered/emberflow";
15
+ export interface UpdateCheckResult {
16
+ current: string;
17
+ latest: string;
18
+ updateAvailable: boolean;
19
+ }
20
+ export interface CheckForUpdateOpts {
21
+ /** Registry base URL. Defaults to EMBERFLOW_REGISTRY env, then npmjs. */
22
+ registry?: string;
23
+ /** Cache TTL in ms (default 1h). */
24
+ ttlMs?: number;
25
+ /** Injectable clock for TTL tests. */
26
+ now?: () => number;
27
+ /** Injectable fetch for tests. */
28
+ fetchFn?: typeof fetch;
29
+ }
30
+ /** Test hook: clear the in-memory check cache. */
31
+ export declare function resetUpdateCheckCache(): void;
32
+ /**
33
+ * Latest-version check against the npm registry. Returns null when the check
34
+ * is disabled (EMBERFLOW_UPDATE_CHECK=0) or unavailable for ANY reason —
35
+ * never throws.
36
+ */
37
+ export declare function checkForUpdate(currentVersion: string, opts?: CheckForUpdateOpts): Promise<UpdateCheckResult | null>;
38
+ /**
39
+ * The running package's own version, read from its package.json. Two layouts,
40
+ * same probe as prompt.ts's EMBERFLOW_BIN: in the source repo this file is
41
+ * server/updateCheck.ts and the package root is one level up; in the shipped
42
+ * package it runs from dist/server/updateCheck.js and the root is two up.
43
+ */
44
+ export declare function ownVersion(): string | null;
45
+ /**
46
+ * True when the consumer's node_modules/@xdelivered/emberflow is a symlink —
47
+ * a `file:`/`npm link` dev setup that an npm install would clobber. POST
48
+ * /update refuses in that case.
49
+ */
50
+ export declare function isLinkedInstall(projectRoot: string): boolean;
51
+ export type InstallResult = {
52
+ ok: true;
53
+ } | {
54
+ ok: false;
55
+ error: string;
56
+ };
57
+ export interface RunNpmInstallOpts {
58
+ /** Injectable spawner so tests never actually npm install. */
59
+ spawnFn?: typeof spawn;
60
+ /** Kill-and-fail deadline (default 5min). */
61
+ timeoutMs?: number;
62
+ }
63
+ /**
64
+ * Runs `npm install @xdelivered/emberflow@latest` in the project root via
65
+ * spawn (no shell). Captures the last ~4KB of combined output; on failure
66
+ * that tail is the error. Never rejects.
67
+ */
68
+ export declare function runNpmInstall(projectRoot: string, opts?: RunNpmInstallOpts): Promise<InstallResult>;