@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.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +208 -16
- package/dist/server/nodesPayload.d.ts +14 -1
- package/dist/server/nodesPayload.js +15 -2
- package/dist/server/projectMode.js +5 -2
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/sourceNav.d.ts +77 -0
- package/dist/server/sourceNav.js +503 -0
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/dist/src/engine/registry.d.ts +27 -2
- package/dist/src/engine/registry.js +84 -2
- package/dist/src/nodes/index.d.ts +8 -2
- package/dist/src/nodes/index.js +7 -3
- package/dist/src/nodes/login.d.ts +3 -1
- package/dist/src/nodes/login.js +2 -2
- package/package.json +28 -7
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +213 -15
- package/server/nodeRun.test.ts +189 -0
- package/server/nodesPayload.test.ts +38 -0
- package/server/nodesPayload.ts +28 -3
- package/server/projectMode.ts +5 -2
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/sourceNav.test.ts +382 -0
- package/server/sourceNav.ts +644 -0
- package/server/sourceNavRoute.test.ts +163 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/Inspector.tsx +9 -26
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/SourceNavigator.test.tsx +226 -0
- package/src/components/SourceNavigator.tsx +520 -0
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/engine/registry.sourceRef.test.ts +112 -0
- package/src/engine/registry.ts +103 -2
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/nodes/index.ts +10 -3
- package/src/nodes/login.ts +5 -2
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +385 -314
- package/src/store/nodeMeta.ts +10 -2
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/sourceNavClient.ts +48 -0
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-BbzppDpt.js +65 -0
- package/studio-dist/assets/index-CLf6blcA.css +1 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-basics/SKILL.md +29 -1
- package/templates/skills/emberflow-model-process/SKILL.md +92 -9
- package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
- package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- 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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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>;
|