@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
package/server/runRegistry.ts
CHANGED
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
import { InMemoryTraceSink, startRun, type FlowRun, type NodeRegistry } from '../src/engine';
|
|
6
6
|
import { createDefaultRegistry } from '../src/nodes';
|
|
7
7
|
import { redactSecrets } from './redact';
|
|
8
|
-
import { makeSubflowRunner } from './subflowRunner';
|
|
8
|
+
import { makeSubflowRunner, type DrillEntry, type DrillState } from './subflowRunner';
|
|
9
9
|
|
|
10
10
|
/** SSE-shaped events, mirroring ExecutorEvents 1:1. */
|
|
11
11
|
export type RunEvent =
|
|
12
|
-
|
|
12
|
+
/** `workflowId` is the flow the node belongs to: the root flow for the
|
|
13
|
+
* run's own nodes, the CHILD flow's id for a stepped subflow's nodes. */
|
|
14
|
+
| { type: 'nodeState'; workflowId: string; nodeId: string; state: NodeRunState }
|
|
13
15
|
| { type: 'log'; line: LogLine }
|
|
14
16
|
| {
|
|
15
17
|
type: 'finished';
|
|
@@ -27,6 +29,25 @@ interface LiveRun {
|
|
|
27
29
|
listeners: Set<Listener>;
|
|
28
30
|
finished: boolean;
|
|
29
31
|
cleanupTimer?: ReturnType<typeof setTimeout>;
|
|
32
|
+
/** Serializes step() calls per run: a second concurrent step() would
|
|
33
|
+
* re-arm `state.onChildStarted` (last writer wins) and double-drive the
|
|
34
|
+
* executor, hanging the first caller. */
|
|
35
|
+
stepChain?: Promise<unknown>;
|
|
36
|
+
/** Present only for stepped runs: subflow drill-in state (see step()). */
|
|
37
|
+
drill?: {
|
|
38
|
+
state: DrillState;
|
|
39
|
+
/** The ROOT executor's step() promise left pending while a child runs. */
|
|
40
|
+
rootPendingStep?: Promise<boolean>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Result of one composite step on a stepped run (see RunRegistry.step). */
|
|
45
|
+
export interface StepResult {
|
|
46
|
+
done: boolean;
|
|
47
|
+
/** Set when this step drove execution INTO a subflow child. */
|
|
48
|
+
entered?: { workflowId: string; nodeId: string };
|
|
49
|
+
/** Set when this step completed the deepest child and popped back out. */
|
|
50
|
+
exited?: true;
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
const RETAIN_FINISHED_MS = 10 * 60 * 1000; // 10 minutes
|
|
@@ -93,6 +114,10 @@ export class RunRegistry {
|
|
|
93
114
|
* error-handler run — stamped onto this run's `finished` event as
|
|
94
115
|
* `errorHandler: { firedBy }`. Only meaningful alongside isErrorHandler. */
|
|
95
116
|
errorHandlerFiredBy?: string;
|
|
117
|
+
/** When true (a step-mode run), Subflow children become step-drivable:
|
|
118
|
+
* entering one pauses inside it instead of running it to completion in
|
|
119
|
+
* a single opaque step. Drive the run via `step(runId)`. */
|
|
120
|
+
stepped?: boolean;
|
|
96
121
|
},
|
|
97
122
|
): { runId: string; handle: FlowRun } {
|
|
98
123
|
this.evictIfNeeded();
|
|
@@ -116,6 +141,11 @@ export class RunRegistry {
|
|
|
116
141
|
// the child via loadFlow, runs it to completion on the same registry
|
|
117
142
|
// sharing the parent's environment, and forwards its (prefixed) logs into
|
|
118
143
|
// this run's SSE stream via `onLog`.
|
|
144
|
+
// Stepped runs share ONE drill state across the root's runner and every
|
|
145
|
+
// nested child's runner (makeSubflowRunner threads `opts` through), so a
|
|
146
|
+
// grandchild pushes onto the same stack and nesting works.
|
|
147
|
+
const drillState: DrillState | undefined = opts.stepped ? { stack: [] } : undefined;
|
|
148
|
+
|
|
119
149
|
const subflowRunner = makeSubflowRunner(
|
|
120
150
|
{
|
|
121
151
|
loadFlow: (id) => this.loadFlow?.(id),
|
|
@@ -127,6 +157,18 @@ export class RunRegistry {
|
|
|
127
157
|
mockRun: opts.mockRun,
|
|
128
158
|
trace: this.sink,
|
|
129
159
|
onLog: (line) => emit({ type: 'log', line }),
|
|
160
|
+
drill: drillState,
|
|
161
|
+
// Stepped children stream node states into this run's SSE stream,
|
|
162
|
+
// tagged with the CHILD flow's id so the client can route them.
|
|
163
|
+
// After a drill-aware cancel the rejected parent executions unwind in
|
|
164
|
+
// the background (recording their Subflow nodes as failed) — child
|
|
165
|
+
// states from that unwinding must not reach subscribers post-finish.
|
|
166
|
+
onNodeState: drillState
|
|
167
|
+
? (workflowId, nodeId, state) => {
|
|
168
|
+
if (drillState.cancelled) return;
|
|
169
|
+
emit({ type: 'nodeState', workflowId, nodeId, state });
|
|
170
|
+
}
|
|
171
|
+
: undefined,
|
|
130
172
|
},
|
|
131
173
|
[flow.id],
|
|
132
174
|
);
|
|
@@ -145,9 +187,14 @@ export class RunRegistry {
|
|
|
145
187
|
trace: this.sink,
|
|
146
188
|
subflowRunner,
|
|
147
189
|
events: {
|
|
148
|
-
onNodeStateChange: (nodeId, state) =>
|
|
190
|
+
onNodeStateChange: (nodeId, state) =>
|
|
191
|
+
emit({ type: 'nodeState', workflowId: flow.id, nodeId, state }),
|
|
149
192
|
onLog: (line) => emit({ type: 'log', line }),
|
|
150
193
|
onRunFinished: (run) => {
|
|
194
|
+
// Idempotent: after a drill-aware cancel the root executor's pending
|
|
195
|
+
// Subflow execution can fail in the background and re-finish the run
|
|
196
|
+
// — subscribers already saw the cancelled `finished` event.
|
|
197
|
+
if (entry.finished) return;
|
|
151
198
|
emit({
|
|
152
199
|
type: 'finished',
|
|
153
200
|
run,
|
|
@@ -163,10 +210,160 @@ export class RunRegistry {
|
|
|
163
210
|
});
|
|
164
211
|
|
|
165
212
|
entry.handle = handle;
|
|
213
|
+
if (drillState) entry.drill = { state: drillState };
|
|
166
214
|
this.runs.set(handle.run.id, entry);
|
|
167
215
|
return { runId: handle.run.id, handle };
|
|
168
216
|
}
|
|
169
217
|
|
|
218
|
+
/**
|
|
219
|
+
* One composite step of a run, drill-aware. For stepped runs a Subflow node
|
|
220
|
+
* is no longer one opaque step: entering it pauses inside the child
|
|
221
|
+
* (`entered`), subsequent steps drive the child's nodes, and completing the
|
|
222
|
+
* child pops back out (`exited`). Non-stepped runs (no drill state) fall
|
|
223
|
+
* back to a plain executor step. Unknown run → undefined.
|
|
224
|
+
*/
|
|
225
|
+
async step(runId: string): Promise<StepResult | undefined> {
|
|
226
|
+
const entry = this.runs.get(runId);
|
|
227
|
+
if (!entry) return undefined;
|
|
228
|
+
// Serialize per run: concurrent step() calls would each arm
|
|
229
|
+
// `state.onChildStarted` (last writer wins) and double-drive the
|
|
230
|
+
// executor, so the second caller waits for the first to settle.
|
|
231
|
+
const result = (entry.stepChain ?? Promise.resolve()).then(() => this.stepLevels(entry));
|
|
232
|
+
entry.stepChain = result.catch(() => undefined);
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One composite step, drill-aware. Structured as a loop over levels: each
|
|
238
|
+
* pass drives the current deepest level (a pending stashed step, or a fresh
|
|
239
|
+
* executor step) while racing "a new child started". Popping a finished
|
|
240
|
+
* child continues the loop on its parent's stashed step WITH the race still
|
|
241
|
+
* armed — the parent's still-pending Subflow node execution may call
|
|
242
|
+
* runSubflow again (retry, or a custom node making sequential calls), and
|
|
243
|
+
* that re-entry must surface as `entered`, not deadlock the fold.
|
|
244
|
+
*/
|
|
245
|
+
private async stepLevels(entry: LiveRun): Promise<StepResult> {
|
|
246
|
+
const drill = entry.drill;
|
|
247
|
+
if (!drill) {
|
|
248
|
+
const more = await entry.handle.step();
|
|
249
|
+
return { done: !more };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { state } = drill;
|
|
253
|
+
// Cancelled mid-drill: the stack was already unwound by cancel(); report
|
|
254
|
+
// done without driving anything.
|
|
255
|
+
if (state.cancelled) return { done: true };
|
|
256
|
+
|
|
257
|
+
// Set once this step pops a child; carried onto whatever this step
|
|
258
|
+
// ultimately reports (including an `entered` for a re-run child).
|
|
259
|
+
let exited = false;
|
|
260
|
+
const withExit = (r: StepResult): StepResult => (exited ? { ...r, exited: true } : r);
|
|
261
|
+
|
|
262
|
+
for (;;) {
|
|
263
|
+
// The level being stepped: the deepest drilled-in child, or the root run.
|
|
264
|
+
const level = state.stack.length > 0 ? state.stack[state.stack.length - 1] : undefined;
|
|
265
|
+
const handle = level ? level.handle : entry.handle;
|
|
266
|
+
const pendingStep = level ? level.pendingStep : drill.rootPendingStep;
|
|
267
|
+
const setPending = (p: Promise<boolean> | undefined): void => {
|
|
268
|
+
if (level) level.pendingStep = p;
|
|
269
|
+
else drill.rootPendingStep = p;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// A Subflow node's execution blocks inside runSubflow until its child
|
|
273
|
+
// completes, so the step promise alone can never report "entered a
|
|
274
|
+
// child" — arm the child-started signal BEFORE awaiting and race them.
|
|
275
|
+
const childStarted = new Promise<DrillEntry>((res) => {
|
|
276
|
+
state.onChildStarted = res;
|
|
277
|
+
});
|
|
278
|
+
const stepPromise = pendingStep ?? handle.step();
|
|
279
|
+
setPending(undefined);
|
|
280
|
+
|
|
281
|
+
type Raced = { kind: 'stepped'; more: boolean } | { kind: 'entered'; child: DrillEntry };
|
|
282
|
+
let raced: Raced;
|
|
283
|
+
try {
|
|
284
|
+
raced = await Promise.race([
|
|
285
|
+
stepPromise.then((more): Raced => ({ kind: 'stepped', more })),
|
|
286
|
+
childStarted.then((child): Raced => ({ kind: 'entered', child })),
|
|
287
|
+
]);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
state.onChildStarted = undefined;
|
|
290
|
+
if (!level) throw err;
|
|
291
|
+
// A child level's step threw outright (not a recorded node failure —
|
|
292
|
+
// the executor catches those). Reject the parent's blocked runSubflow,
|
|
293
|
+
// which surfaces it as the Subflow node's error, then fold the parent
|
|
294
|
+
// (next loop pass, race re-armed).
|
|
295
|
+
state.stack.pop();
|
|
296
|
+
level.reject(err);
|
|
297
|
+
exited = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
state.onChildStarted = undefined;
|
|
301
|
+
|
|
302
|
+
if (raced.kind === 'entered') {
|
|
303
|
+
// The level's step stays pending for the whole child run — stash it;
|
|
304
|
+
// the step that later pops this child folds its boolean back in.
|
|
305
|
+
setPending(stepPromise);
|
|
306
|
+
return withExit({
|
|
307
|
+
done: false,
|
|
308
|
+
entered: { workflowId: raced.child.workflowId, nodeId: raced.child.viaNodeId },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (raced.more) return withExit({ done: false });
|
|
313
|
+
|
|
314
|
+
// This level has no more nodes: its run is complete.
|
|
315
|
+
if (!level) return withExit({ done: true });
|
|
316
|
+
|
|
317
|
+
// Pop the finished child and hand its completed run (succeeded OR failed)
|
|
318
|
+
// to the parent's blocked runSubflow — the success/failure semantics live
|
|
319
|
+
// in subflowRunner, byte-identical to the non-stepped path. The parent's
|
|
320
|
+
// stashed step is folded in by the next loop pass, with the child-started
|
|
321
|
+
// race re-armed so a retrying Subflow node re-entering its child yields
|
|
322
|
+
// `exited` + `entered` instead of hanging.
|
|
323
|
+
state.stack.pop();
|
|
324
|
+
level.resolve(level.handle.run);
|
|
325
|
+
exited = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Cancel a run, drill-aware. For a stepped run drilled into subflows this
|
|
331
|
+
* cancels every stacked child deepest-first and rejects the promise each
|
|
332
|
+
* parent's Subflow node execution is blocked on — those pending executions
|
|
333
|
+
* then unwind in the background exactly like an in-flight node after a
|
|
334
|
+
* plain root cancel today (recorded, but the run is already finished).
|
|
335
|
+
* step() after cancel reports { done: true } without executing anything.
|
|
336
|
+
* Returns false for an unknown run.
|
|
337
|
+
*/
|
|
338
|
+
/** Cancels every live (non-finished) run — the server's shutdown path, so
|
|
339
|
+
* in-flight executions (and their drilled subflow children) don't keep
|
|
340
|
+
* running headless after the process is asked to die. */
|
|
341
|
+
shutdown(): void {
|
|
342
|
+
for (const [id, run] of this.runs) {
|
|
343
|
+
if (!run.finished) this.cancel(id);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
cancel(runId: string): boolean {
|
|
348
|
+
const entry = this.runs.get(runId);
|
|
349
|
+
if (!entry) return false;
|
|
350
|
+
const drill = entry.drill;
|
|
351
|
+
if (drill) {
|
|
352
|
+
const { state } = drill;
|
|
353
|
+
state.cancelled = true;
|
|
354
|
+
state.onChildStarted = undefined;
|
|
355
|
+
while (state.stack.length > 0) {
|
|
356
|
+
const child = state.stack.pop()!;
|
|
357
|
+
child.pendingStep = undefined;
|
|
358
|
+
child.handle.cancel();
|
|
359
|
+
child.reject(new Error('run cancelled'));
|
|
360
|
+
}
|
|
361
|
+
drill.rootPendingStep = undefined;
|
|
362
|
+
}
|
|
363
|
+
entry.handle.cancel();
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
170
367
|
/**
|
|
171
368
|
* Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
|
|
172
369
|
* callback Subflow nodes use) as a new run when `failedRun` finished with
|
|
@@ -78,6 +78,9 @@ describe('GET /setup-status', () => {
|
|
|
78
78
|
const body = await res.json();
|
|
79
79
|
|
|
80
80
|
expect(Array.isArray(body.agents)).toBe(true);
|
|
81
|
+
// The temp project dir isn't inside a git repo — reported as such so the
|
|
82
|
+
// checklist can gate the agent features on it.
|
|
83
|
+
expect(body.git).toEqual({ repo: false });
|
|
81
84
|
// A fresh project has no environments file → the synthesized fallback is
|
|
82
85
|
// reported as unconfigured with zero real environments.
|
|
83
86
|
expect(body.environments).toMatchObject({
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
6
|
+
import { getSourceFile, resetSourceNavCaches, type SourceFilePayload } from './sourceNav';
|
|
7
|
+
import { buildRegistries } from './projectMode';
|
|
8
|
+
import { nodesPayload } from './nodesPayload';
|
|
9
|
+
import type { NodeRegistry } from '../src/engine';
|
|
10
|
+
|
|
11
|
+
let root: string;
|
|
12
|
+
|
|
13
|
+
function write(rel: string, lines: string[]): void {
|
|
14
|
+
const p = join(root, rel);
|
|
15
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
16
|
+
writeFileSync(p, lines.join('\n') + '\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function payloadOf(rel: string): Promise<SourceFilePayload> {
|
|
20
|
+
const result = await getSourceFile(root, rel);
|
|
21
|
+
if (!result.ok) throw new Error(`expected ok for ${rel}, got ${result.status}: ${result.error}`);
|
|
22
|
+
return result.payload;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenav-')));
|
|
27
|
+
resetSourceNavCaches();
|
|
28
|
+
|
|
29
|
+
write('nodes.mjs', [
|
|
30
|
+
"import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
|
|
31
|
+
"import { createHash } from 'node:crypto';", // 2
|
|
32
|
+
"import express from 'express';", // 3
|
|
33
|
+
"import { helper } from '@scope/pkg/utils';", // 4
|
|
34
|
+
'', // 5
|
|
35
|
+
'const TAX_RATE = 0.2;', // 6
|
|
36
|
+
'', // 7
|
|
37
|
+
'export function handler(input) {', // 8
|
|
38
|
+
" void createHash('sha256');", // 9
|
|
39
|
+
' void express;', // 10
|
|
40
|
+
' void helper;', // 11
|
|
41
|
+
' return deriveTrackingActual(input) * TAX_RATE;', // 12
|
|
42
|
+
'}', // 13
|
|
43
|
+
'', // 14
|
|
44
|
+
'export async function loadPlugin(name) {', // 15
|
|
45
|
+
' return await import(name);', // 16
|
|
46
|
+
'}', // 17
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
write('shipment-logic.mjs', [
|
|
50
|
+
'function nestedHelper(shipment) {', // 1
|
|
51
|
+
' return shipment.weight * 2;', // 2
|
|
52
|
+
'}', // 3
|
|
53
|
+
'', // 4
|
|
54
|
+
'export function deriveTrackingActual(shipment) {', // 5
|
|
55
|
+
' return nestedHelper(shipment);', // 6
|
|
56
|
+
'}', // 7
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
write('index.mjs', [
|
|
60
|
+
"export { deriveTrackingActual } from './shipment-logic.mjs';", // 1
|
|
61
|
+
"export * from './extras.mjs';", // 2
|
|
62
|
+
]);
|
|
63
|
+
write('extras.mjs', ['export const EXTRA = 1;']);
|
|
64
|
+
|
|
65
|
+
write('via-index.mjs', [
|
|
66
|
+
"import { deriveTrackingActual } from './index.mjs';", // 1
|
|
67
|
+
'', // 2
|
|
68
|
+
'export function viaIndex(x) {', // 3
|
|
69
|
+
' return deriveTrackingActual(x);', // 4
|
|
70
|
+
'}', // 5
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Mutual value-import cycle.
|
|
74
|
+
write('circ-a.mjs', [
|
|
75
|
+
"import { b } from './circ-b.mjs';",
|
|
76
|
+
'export function a() {',
|
|
77
|
+
' return b();',
|
|
78
|
+
'}',
|
|
79
|
+
]);
|
|
80
|
+
write('circ-b.mjs', [
|
|
81
|
+
"import { a } from './circ-a.mjs';",
|
|
82
|
+
'export function b() {',
|
|
83
|
+
' return a();',
|
|
84
|
+
'}',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Re-export cycle: chasing the declaration must terminate.
|
|
88
|
+
write('reexp-a.mjs', ["export { spin } from './reexp-b.mjs';"]);
|
|
89
|
+
write('reexp-b.mjs', ["export { spin } from './reexp-a.mjs';"]);
|
|
90
|
+
write('spin-consumer.mjs', [
|
|
91
|
+
"import { spin } from './reexp-a.mjs';",
|
|
92
|
+
'export const useSpin = spin;',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
// TS variant with type annotations + TS-style .js specifier for a .ts file.
|
|
96
|
+
write('ts/handler.ts', [
|
|
97
|
+
"import { deriveTs } from './logic.js';", // 1
|
|
98
|
+
'', // 2
|
|
99
|
+
'export function tsHandler(n: number): number {', // 3
|
|
100
|
+
' return deriveTs(n);', // 4
|
|
101
|
+
'}', // 5
|
|
102
|
+
]);
|
|
103
|
+
write('ts/logic.ts', [
|
|
104
|
+
'export function deriveTs(n: number): number {', // 1
|
|
105
|
+
' return n * 2;', // 2
|
|
106
|
+
'}', // 3
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// tsconfig paths alias (JSONC — comments must parse leniently).
|
|
110
|
+
write('tsconfig.json', [
|
|
111
|
+
'{',
|
|
112
|
+
' // single-star paths alias, exercised by aliased.ts',
|
|
113
|
+
' "compilerOptions": {',
|
|
114
|
+
' "paths": { "@lib/*": ["lib/*"] }',
|
|
115
|
+
' }',
|
|
116
|
+
'}',
|
|
117
|
+
]);
|
|
118
|
+
write('lib/util.ts', ['export function util(): number {', ' return 1;', '}']);
|
|
119
|
+
write('aliased.ts', ["import { util } from '@lib/util';", '', 'export const z = util();']);
|
|
120
|
+
|
|
121
|
+
// Secrets / denied files that must never be served.
|
|
122
|
+
write('.env', ['SECRET=shh']);
|
|
123
|
+
mkdirSync(join(root, 'sub'), { recursive: true });
|
|
124
|
+
write('sub/.env.local', ['SECRET=shh']);
|
|
125
|
+
write('server.key', ['---KEY---']);
|
|
126
|
+
write('cert.pem', ['---PEM---']);
|
|
127
|
+
write('emberflow.secrets.json', ['{}']);
|
|
128
|
+
mkdirSync(join(root, 'node_modules', 'somepkg'), { recursive: true });
|
|
129
|
+
write('node_modules/somepkg/index.js', ['module.exports = 1;']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterAll(() => {
|
|
133
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('getSourceFile: parse + declarations', () => {
|
|
137
|
+
it('serves the whole file with language and repo-relative path', async () => {
|
|
138
|
+
const p = await payloadOf('nodes.mjs');
|
|
139
|
+
expect(p.path).toBe('nodes.mjs');
|
|
140
|
+
expect(p.language).toBe('js');
|
|
141
|
+
expect(p.content).toContain('TAX_RATE');
|
|
142
|
+
expect(p.resolver).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('extracts top-level declarations with kind, lines, and exported flag', async () => {
|
|
146
|
+
const p = await payloadOf('shipment-logic.mjs');
|
|
147
|
+
const decls = p.symbols.declarations;
|
|
148
|
+
const nested = decls.find((d) => d.name === 'nestedHelper');
|
|
149
|
+
expect(nested).toMatchObject({ kind: 'fn', line: 1, endLine: 3, exported: false });
|
|
150
|
+
const derive = decls.find((d) => d.name === 'deriveTrackingActual');
|
|
151
|
+
expect(derive).toMatchObject({ kind: 'fn', line: 5, endLine: 7, exported: true });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('extracts a local const with an initializer', async () => {
|
|
155
|
+
const p = await payloadOf('nodes.mjs');
|
|
156
|
+
const tax = p.symbols.declarations.find((d) => d.name === 'TAX_RATE');
|
|
157
|
+
expect(tax).toMatchObject({ kind: 'const', line: 6, exported: false });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('getSourceFile: import resolution', () => {
|
|
162
|
+
it('resolves a relative sibling import to a project file with the declaration line', async () => {
|
|
163
|
+
const p = await payloadOf('nodes.mjs');
|
|
164
|
+
const imp = p.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
|
|
165
|
+
expect(imp).toBeDefined();
|
|
166
|
+
expect(imp!.from).toBe('./shipment-logic.mjs');
|
|
167
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('classifies node:crypto as builtin', async () => {
|
|
171
|
+
const p = await payloadOf('nodes.mjs');
|
|
172
|
+
const imp = p.symbols.imports.find((i) => i.local === 'createHash');
|
|
173
|
+
expect(imp!.resolution).toEqual({ kind: 'builtin' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('classifies a bare specifier as external with the package name', async () => {
|
|
177
|
+
const p = await payloadOf('nodes.mjs');
|
|
178
|
+
const imp = p.symbols.imports.find((i) => i.local === 'express');
|
|
179
|
+
expect(imp!.name).toBe('default');
|
|
180
|
+
expect(imp!.resolution).toEqual({ kind: 'external', package: 'express' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('extracts the scoped package name from a deep specifier', async () => {
|
|
184
|
+
const p = await payloadOf('nodes.mjs');
|
|
185
|
+
const imp = p.symbols.imports.find((i) => i.local === 'helper');
|
|
186
|
+
expect(imp!.resolution).toEqual({ kind: 'external', package: '@scope/pkg' });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reports a computed dynamic import as unresolved with a reason', async () => {
|
|
190
|
+
const p = await payloadOf('nodes.mjs');
|
|
191
|
+
const dyn = p.symbols.imports.find((i) => i.resolution.kind === 'unresolved');
|
|
192
|
+
expect(dyn).toBeDefined();
|
|
193
|
+
expect((dyn!.resolution as { reason: string }).reason).toMatch(/dynamic/i);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('follows a re-export chain through an index module to the declaring file', async () => {
|
|
197
|
+
const p = await payloadOf('via-index.mjs');
|
|
198
|
+
const imp = p.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
|
|
199
|
+
expect(imp!.from).toBe('./index.mjs');
|
|
200
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('handles a circular import pair without looping', async () => {
|
|
204
|
+
const p = await payloadOf('circ-a.mjs');
|
|
205
|
+
const imp = p.symbols.imports.find((i) => i.local === 'b');
|
|
206
|
+
expect(imp!.resolution).toMatchObject({ kind: 'project', path: 'circ-b.mjs' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('terminates on a re-export cycle (depth cap + cycle set)', async () => {
|
|
210
|
+
const p = await payloadOf('spin-consumer.mjs');
|
|
211
|
+
const imp = p.symbols.imports.find((i) => i.local === 'spin');
|
|
212
|
+
expect(imp!.resolution.kind).toBe('project');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('maps a TS-style .js specifier to the .ts source (extension ladder)', async () => {
|
|
216
|
+
const p = await payloadOf('ts/handler.ts');
|
|
217
|
+
expect(p.language).toBe('ts');
|
|
218
|
+
const imp = p.symbols.imports.find((i) => i.local === 'deriveTs');
|
|
219
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'ts/logic.ts', line: 1 });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('honors single-star tsconfig paths (parsed leniently as JSONC)', async () => {
|
|
223
|
+
const p = await payloadOf('aliased.ts');
|
|
224
|
+
const imp = p.symbols.imports.find((i) => i.local === 'util');
|
|
225
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'lib/util.ts', line: 1 });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('getSourceFile: re-exports of the served file', () => {
|
|
230
|
+
it('lists named and star re-exports with resolutions', async () => {
|
|
231
|
+
const p = await payloadOf('index.mjs');
|
|
232
|
+
const named = p.symbols.reexports.find((r) => r.name === 'deriveTrackingActual');
|
|
233
|
+
expect(named!.from).toBe('./shipment-logic.mjs');
|
|
234
|
+
expect(named!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
|
|
235
|
+
const star = p.symbols.reexports.find((r) => r.name === '*');
|
|
236
|
+
expect(star!.from).toBe('./extras.mjs');
|
|
237
|
+
expect(star!.resolution).toMatchObject({ kind: 'project', path: 'extras.mjs' });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('getSourceFile: path guards', () => {
|
|
242
|
+
const denied = [
|
|
243
|
+
'../../etc/passwd',
|
|
244
|
+
'/etc/passwd',
|
|
245
|
+
'node_modules/somepkg/index.js',
|
|
246
|
+
'.env',
|
|
247
|
+
'sub/.env.local',
|
|
248
|
+
'server.key',
|
|
249
|
+
'cert.pem',
|
|
250
|
+
'emberflow.secrets.json',
|
|
251
|
+
];
|
|
252
|
+
for (const rel of denied) {
|
|
253
|
+
it(`denies ${rel} with a generic 400`, async () => {
|
|
254
|
+
const result = await getSourceFile(root, rel);
|
|
255
|
+
expect(result.ok).toBe(false);
|
|
256
|
+
if (result.ok) return;
|
|
257
|
+
expect(result.status).toBe(400);
|
|
258
|
+
// Generic message: never echo the requested path back.
|
|
259
|
+
expect(result.error).not.toContain(rel);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it('404s for a missing file inside the root', async () => {
|
|
264
|
+
const result = await getSourceFile(root, 'does-not-exist.mjs');
|
|
265
|
+
expect(result.ok).toBe(false);
|
|
266
|
+
if (result.ok) return;
|
|
267
|
+
expect(result.status).toBe(404);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('getSourceFile: caching', () => {
|
|
272
|
+
it('invalidates the parse cache when the file mtime changes', async () => {
|
|
273
|
+
write('mut.mjs', ['export const FIRST = 1;']);
|
|
274
|
+
utimesSync(join(root, 'mut.mjs'), 1_000, 1_000);
|
|
275
|
+
const before = await payloadOf('mut.mjs');
|
|
276
|
+
expect(before.content).toContain('FIRST');
|
|
277
|
+
expect(before.symbols.declarations.map((d) => d.name)).toContain('FIRST');
|
|
278
|
+
|
|
279
|
+
write('mut.mjs', ['export const SECOND = 2;']);
|
|
280
|
+
utimesSync(join(root, 'mut.mjs'), 2_000, 2_000);
|
|
281
|
+
const after = await payloadOf('mut.mjs');
|
|
282
|
+
expect(after.content).toContain('SECOND');
|
|
283
|
+
expect(after.symbols.declarations.map((d) => d.name)).toContain('SECOND');
|
|
284
|
+
expect(after.content).not.toContain('FIRST');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('getSourceFile: typescript unavailable', () => {
|
|
289
|
+
it('returns resolver: unavailable with content but empty symbols when the ts import fails', async () => {
|
|
290
|
+
const result = await getSourceFile(root, 'nodes.mjs', {
|
|
291
|
+
tsLoader: async () => {
|
|
292
|
+
throw new Error('typescript not installed');
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
expect(result.ok).toBe(true);
|
|
296
|
+
if (!result.ok) return;
|
|
297
|
+
expect(result.payload.resolver).toBe('unavailable');
|
|
298
|
+
expect(result.payload.content).toContain('TAX_RATE');
|
|
299
|
+
expect(result.payload.symbols.declarations).toEqual([]);
|
|
300
|
+
expect(result.payload.symbols.imports).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('regression: DeriveShipmentActuals → deriveTrackingActual (thin-adapter consumer shape)', () => {
|
|
305
|
+
let regRoot: string;
|
|
306
|
+
|
|
307
|
+
beforeAll(async () => {
|
|
308
|
+
regRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenav-regression-')));
|
|
309
|
+
const w = (rel: string, lines: string[]): void =>
|
|
310
|
+
writeFileSync(join(regRoot, rel), lines.join('\n') + '\n');
|
|
311
|
+
w('shipment-logic.mjs', [
|
|
312
|
+
'function nestedHelper(shipment) {', // 1
|
|
313
|
+
' return (shipment.weight ?? 0) * 2;', // 2
|
|
314
|
+
'}', // 3
|
|
315
|
+
'', // 4
|
|
316
|
+
'export function deriveTrackingActual(shipment) {', // 5
|
|
317
|
+
' return nestedHelper(shipment);', // 6
|
|
318
|
+
'}', // 7
|
|
319
|
+
]);
|
|
320
|
+
w('nodes.mjs', [
|
|
321
|
+
"import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
|
|
322
|
+
'', // 2
|
|
323
|
+
'export function registerNodes(registry) {', // 3
|
|
324
|
+
' registry.register(', // 4
|
|
325
|
+
" { type: 'DeriveShipmentActuals', label: 'Derive Shipment Actuals' },", // 5
|
|
326
|
+
' async (ctx) => ({ actual: deriveTrackingActual(ctx.input) }),', // 6
|
|
327
|
+
' );', // 7
|
|
328
|
+
'}', // 8
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
afterAll(() => {
|
|
333
|
+
if (regRoot) rmSync(regRoot, { recursive: true, force: true });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('nodesPayload carries the node sourceRef, and /source-file navigates to the helper', async () => {
|
|
337
|
+
const mod = (await import(pathToFileURL(join(regRoot, 'nodes.mjs')).href)) as {
|
|
338
|
+
registerNodes: (r: NodeRegistry) => void;
|
|
339
|
+
};
|
|
340
|
+
const { validation } = buildRegistries({
|
|
341
|
+
root: regRoot,
|
|
342
|
+
flowsDir: join(regRoot, 'emberflow', 'flows'),
|
|
343
|
+
language: 'javascript',
|
|
344
|
+
registerNodes: mod.registerNodes,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// 1. The registration was captured and lands in the payload repo-relative.
|
|
348
|
+
// (Exact line accuracy under the production loader is asserted by
|
|
349
|
+
// registry.sourceRef.test.ts and sourceNavRoute.test.ts — vitest's
|
|
350
|
+
// vite-node transform shifts lines of in-process dynamic imports.)
|
|
351
|
+
const payload = nodesPayload(validation, regRoot);
|
|
352
|
+
const node = payload.nodes.find((n) => n.type === 'DeriveShipmentActuals');
|
|
353
|
+
expect(node).toBeDefined();
|
|
354
|
+
expect(node!.sourceRef!.file).toBe('nodes.mjs');
|
|
355
|
+
expect(typeof node!.sourceRef!.line).toBe('number');
|
|
356
|
+
expect(node!.builtin).toBeUndefined();
|
|
357
|
+
|
|
358
|
+
// Built-ins registered from the Emberflow package are outside regRoot.
|
|
359
|
+
const builtinNode = payload.nodes.find((n) => n.type === 'ValidateCredentials');
|
|
360
|
+
expect(builtinNode).toBeDefined();
|
|
361
|
+
expect(builtinNode!.builtin).toBe(true);
|
|
362
|
+
expect(builtinNode!.sourceRef).toBeUndefined();
|
|
363
|
+
|
|
364
|
+
// 2. /source-file on the handler module resolves the imported helper.
|
|
365
|
+
const handlerFile = await getSourceFile(regRoot, 'nodes.mjs');
|
|
366
|
+
expect(handlerFile.ok).toBe(true);
|
|
367
|
+
if (!handlerFile.ok) return;
|
|
368
|
+
const imp = handlerFile.payload.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
|
|
369
|
+
expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
|
|
370
|
+
|
|
371
|
+
// 3. /source-file on the helper module exposes its declaration lines.
|
|
372
|
+
const logicFile = await getSourceFile(regRoot, 'shipment-logic.mjs');
|
|
373
|
+
expect(logicFile.ok).toBe(true);
|
|
374
|
+
if (!logicFile.ok) return;
|
|
375
|
+
const derive = logicFile.payload.symbols.declarations.find(
|
|
376
|
+
(d) => d.name === 'deriveTrackingActual',
|
|
377
|
+
);
|
|
378
|
+
expect(derive).toMatchObject({ kind: 'fn', line: 5, endLine: 7, exported: true });
|
|
379
|
+
const nested = logicFile.payload.symbols.declarations.find((d) => d.name === 'nestedHelper');
|
|
380
|
+
expect(nested).toMatchObject({ kind: 'fn', line: 1, exported: false });
|
|
381
|
+
});
|
|
382
|
+
});
|