@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,142 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { newer } from './agents/detect.js';
|
|
6
|
+
/**
|
|
7
|
+
* Update notifier + installer plumbing for consumer projects (they install
|
|
8
|
+
* @xdelivered/emberflow from npm and run `npx emberflow dev`).
|
|
9
|
+
*
|
|
10
|
+
* - checkForUpdate: asks the npm registry for the latest published version and
|
|
11
|
+
* compares it against the running one. Fail-SILENT by design: any network,
|
|
12
|
+
* HTTP, or parse failure returns null — an update nudge must never break or
|
|
13
|
+
* slow the studio. Results (including failures) are cached in-memory for an
|
|
14
|
+
* hour so /update-status stays cheap and the registry isn't hammered.
|
|
15
|
+
* - runNpmInstall: the actual one-click updater — `npm install <pkg>@latest`
|
|
16
|
+
* spawned (no shell) in the project root. Injectable spawn for tests.
|
|
17
|
+
*/
|
|
18
|
+
export const EMBERFLOW_PACKAGE = '@xdelivered/emberflow';
|
|
19
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
|
|
21
|
+
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; // 5min
|
|
22
|
+
const OUTPUT_TAIL_BYTES = 4096;
|
|
23
|
+
// Single-entry cache: the runner only ever checks one package/version pair.
|
|
24
|
+
// Failures are cached too — a flaky network shouldn't retrigger a fetch on
|
|
25
|
+
// every /update-status poll.
|
|
26
|
+
let cache = null;
|
|
27
|
+
/** Test hook: clear the in-memory check cache. */
|
|
28
|
+
export function resetUpdateCheckCache() {
|
|
29
|
+
cache = null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Latest-version check against the npm registry. Returns null when the check
|
|
33
|
+
* is disabled (EMBERFLOW_UPDATE_CHECK=0) or unavailable for ANY reason —
|
|
34
|
+
* never throws.
|
|
35
|
+
*/
|
|
36
|
+
export async function checkForUpdate(currentVersion, opts = {}) {
|
|
37
|
+
if (process.env.EMBERFLOW_UPDATE_CHECK === '0')
|
|
38
|
+
return null;
|
|
39
|
+
const now = opts.now ?? Date.now;
|
|
40
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
41
|
+
if (cache && now() - cache.at < ttlMs)
|
|
42
|
+
return cache.result;
|
|
43
|
+
const registry = opts.registry ?? process.env.EMBERFLOW_REGISTRY ?? DEFAULT_REGISTRY;
|
|
44
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
45
|
+
let result = null;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetchFn(`${registry}/${EMBERFLOW_PACKAGE}/latest`);
|
|
48
|
+
if (res.ok) {
|
|
49
|
+
const body = (await res.json());
|
|
50
|
+
if (typeof body.version === 'string' && body.version.length > 0) {
|
|
51
|
+
result = {
|
|
52
|
+
current: currentVersion,
|
|
53
|
+
latest: body.version,
|
|
54
|
+
updateAvailable: newer(body.version, currentVersion),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
result = null; // fail-silent: network error, bad JSON, anything
|
|
61
|
+
}
|
|
62
|
+
cache = { at: now(), result };
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The running package's own version, read from its package.json. Two layouts,
|
|
67
|
+
* same probe as prompt.ts's EMBERFLOW_BIN: in the source repo this file is
|
|
68
|
+
* server/updateCheck.ts and the package root is one level up; in the shipped
|
|
69
|
+
* package it runs from dist/server/updateCheck.js and the root is two up.
|
|
70
|
+
*/
|
|
71
|
+
export function ownVersion() {
|
|
72
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
for (const root of [resolve(here, '..'), resolve(here, '../..')]) {
|
|
74
|
+
const file = join(root, 'package.json');
|
|
75
|
+
if (!existsSync(file))
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
79
|
+
if (typeof pkg.version === 'string')
|
|
80
|
+
return pkg.version;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// malformed — try the next candidate
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* True when the consumer's node_modules/@xdelivered/emberflow is a symlink —
|
|
90
|
+
* a `file:`/`npm link` dev setup that an npm install would clobber. POST
|
|
91
|
+
* /update refuses in that case.
|
|
92
|
+
*/
|
|
93
|
+
export function isLinkedInstall(projectRoot) {
|
|
94
|
+
try {
|
|
95
|
+
return lstatSync(join(projectRoot, 'node_modules', '@xdelivered', 'emberflow')).isSymbolicLink();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Runs `npm install @xdelivered/emberflow@latest` in the project root via
|
|
103
|
+
* spawn (no shell). Captures the last ~4KB of combined output; on failure
|
|
104
|
+
* that tail is the error. Never rejects.
|
|
105
|
+
*/
|
|
106
|
+
export function runNpmInstall(projectRoot, opts = {}) {
|
|
107
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
108
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
109
|
+
return new Promise((resolveInstall) => {
|
|
110
|
+
let tail = '';
|
|
111
|
+
const append = (chunk) => {
|
|
112
|
+
tail = (tail + String(chunk)).slice(-OUTPUT_TAIL_BYTES);
|
|
113
|
+
};
|
|
114
|
+
let child;
|
|
115
|
+
try {
|
|
116
|
+
child = spawnFn('npm', ['install', `${EMBERFLOW_PACKAGE}@latest`], {
|
|
117
|
+
cwd: projectRoot,
|
|
118
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
resolveInstall({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
child.stdout?.on('data', append);
|
|
126
|
+
child.stderr?.on('data', append);
|
|
127
|
+
let settled = false;
|
|
128
|
+
const settle = (result) => {
|
|
129
|
+
if (settled)
|
|
130
|
+
return;
|
|
131
|
+
settled = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolveInstall(result);
|
|
134
|
+
};
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
child.kill('SIGKILL');
|
|
137
|
+
settle({ ok: false, error: `npm install timed out after ${Math.round(timeoutMs / 1000)}s\n${tail}`.trim() });
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
child.on('error', (err) => settle({ ok: false, error: err.message }));
|
|
140
|
+
child.on('close', (code) => settle(code === 0 ? { ok: true } : { ok: false, error: tail.trim() || `npm install exited with code ${code}` }));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -42,10 +42,11 @@ function envRefName(value) {
|
|
|
42
42
|
function resolveEnvRef(name, vars) {
|
|
43
43
|
if (!(name in vars)) {
|
|
44
44
|
// The most common cause is not a missing key but a run with NO environment
|
|
45
|
-
// at all:
|
|
46
|
-
// need
|
|
45
|
+
// at all: an environment-less run (e.g. a scenario test) has no vars, so
|
|
46
|
+
// flows seeded with $env refs need a real environment. Say so, instead of a
|
|
47
|
+
// bare "missing".
|
|
47
48
|
const hint = Object.keys(vars).length === 0
|
|
48
|
-
? ' — this run has no environment variables
|
|
49
|
+
? ' — this run has no environment variables; run it against an environment that defines them'
|
|
49
50
|
: '';
|
|
50
51
|
throw new Error(`Missing environment variable: ${name}${hint}`);
|
|
51
52
|
}
|
|
@@ -3,10 +3,30 @@ export interface RegisteredNode {
|
|
|
3
3
|
definition: NodeDefinition;
|
|
4
4
|
implementation: NodeImplementation;
|
|
5
5
|
}
|
|
6
|
+
/** Where a node was registered from: absolute file path + 1-based line of the register() call. */
|
|
7
|
+
export interface SourceRef {
|
|
8
|
+
file: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface RegisterOptions {
|
|
12
|
+
/** Explicit provenance escape hatch — wins over automatic capture. */
|
|
13
|
+
sourceRef?: SourceRef;
|
|
14
|
+
}
|
|
6
15
|
export declare class NodeRegistry {
|
|
7
16
|
nodes: Map<string, RegisteredNode>;
|
|
8
17
|
sources: Map<string, string>;
|
|
9
|
-
|
|
18
|
+
/** Registration provenance per node type. Populated only when capture is on or opts.sourceRef given. */
|
|
19
|
+
sourceRefs: Map<string, SourceRef>;
|
|
20
|
+
/** Types the RUNNER flagged builtin (registered from the package, not the project). */
|
|
21
|
+
builtinTypes: Set<string>;
|
|
22
|
+
/** Automatic caller capture is opt-in (server registries only) so browser registries never pay/leak. */
|
|
23
|
+
private captureSourceRefs;
|
|
24
|
+
constructor(opts?: {
|
|
25
|
+
captureSourceRefs?: boolean;
|
|
26
|
+
});
|
|
27
|
+
register(definition: NodeDefinition, implementation: NodeImplementation, opts?: RegisterOptions): void;
|
|
28
|
+
/** Where `type` was registered from, when known. */
|
|
29
|
+
getSourceRef(type: string): SourceRef | undefined;
|
|
10
30
|
get(type: string): RegisteredNode;
|
|
11
31
|
has(type: string): boolean;
|
|
12
32
|
/**
|
|
@@ -17,13 +37,18 @@ export declare class NodeRegistry {
|
|
|
17
37
|
* process restart (which would kill in-flight agent runs).
|
|
18
38
|
*/
|
|
19
39
|
adopt(other: NodeRegistry): void;
|
|
40
|
+
/** Whether the runner flagged `type` as a package built-in (no servable source file). */
|
|
41
|
+
isBuiltin(type: string): boolean;
|
|
20
42
|
list(): NodeDefinition[];
|
|
21
43
|
/**
|
|
22
44
|
* Register a definition-only node (metadata fetched from the runner). Its
|
|
23
45
|
* implementation is a stub that fails loudly if browser-executed — such
|
|
24
46
|
* nodes run on the server. Never overwrites a real registration.
|
|
25
47
|
*/
|
|
26
|
-
registerDefinition(definition: NodeDefinition, source?: string
|
|
48
|
+
registerDefinition(definition: NodeDefinition, source?: string, opts?: {
|
|
49
|
+
sourceRef?: SourceRef;
|
|
50
|
+
builtin?: boolean;
|
|
51
|
+
}): void;
|
|
27
52
|
/** Source text for display: stored source for definition-only nodes, else the impl's toString(). */
|
|
28
53
|
getSource(type: string): string | undefined;
|
|
29
54
|
/**
|
|
@@ -1,11 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the CALLER's file:line via a one-frame V8 stack capture.
|
|
3
|
+
* `Error.prepareStackTrace` is swapped for the duration of a single `new
|
|
4
|
+
* Error()` and restored in `finally`, so source-map hooks installed by
|
|
5
|
+
* tsx/vitest are back in place immediately. Frames arrive either as
|
|
6
|
+
* `file:///abs/path.ts` URLs (ESM) or plain absolute paths (CJS/transpilers);
|
|
7
|
+
* both normalize to a plain path. Returns undefined on any non-V8 runtime or
|
|
8
|
+
* anonymous/eval frames — capture is best-effort by design.
|
|
9
|
+
*/
|
|
10
|
+
function captureCallerSourceRef(skip) {
|
|
11
|
+
if (typeof Error.captureStackTrace !== 'function')
|
|
12
|
+
return undefined;
|
|
13
|
+
const original = Error.prepareStackTrace;
|
|
14
|
+
try {
|
|
15
|
+
Error.prepareStackTrace = (_err, frames) => frames;
|
|
16
|
+
const holder = {};
|
|
17
|
+
Error.captureStackTrace(holder, skip);
|
|
18
|
+
const frames = holder.stack;
|
|
19
|
+
if (!Array.isArray(frames) || frames.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const frame = frames[0];
|
|
22
|
+
const raw = typeof frame.getFileName === 'function' ? frame.getFileName() : undefined;
|
|
23
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const lineRaw = typeof frame.getLineNumber === 'function' ? frame.getLineNumber() : undefined;
|
|
26
|
+
const line = typeof lineRaw === 'number' && lineRaw > 0 ? lineRaw : undefined;
|
|
27
|
+
let file = raw;
|
|
28
|
+
if (file.startsWith('file://')) {
|
|
29
|
+
// No node:url here (browser-safe module): URL parsing covers the
|
|
30
|
+
// file:///abs/path form these frames take on POSIX platforms.
|
|
31
|
+
try {
|
|
32
|
+
file = decodeURIComponent(new URL(file).pathname);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Loaders (vite-node hot imports) may suffix ?t=... cache-busters.
|
|
39
|
+
const q = file.indexOf('?');
|
|
40
|
+
if (q !== -1)
|
|
41
|
+
file = file.slice(0, q);
|
|
42
|
+
return line !== undefined ? { file, line } : { file };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
Error.prepareStackTrace = original;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
1
51
|
export class NodeRegistry {
|
|
2
52
|
nodes = new Map();
|
|
3
53
|
sources = new Map();
|
|
4
|
-
|
|
54
|
+
/** Registration provenance per node type. Populated only when capture is on or opts.sourceRef given. */
|
|
55
|
+
sourceRefs = new Map();
|
|
56
|
+
/** Types the RUNNER flagged builtin (registered from the package, not the project). */
|
|
57
|
+
builtinTypes = new Set();
|
|
58
|
+
/** Automatic caller capture is opt-in (server registries only) so browser registries never pay/leak. */
|
|
59
|
+
captureSourceRefs;
|
|
60
|
+
constructor(opts) {
|
|
61
|
+
this.captureSourceRefs = opts?.captureSourceRefs ?? false;
|
|
62
|
+
}
|
|
63
|
+
register(definition, implementation, opts) {
|
|
5
64
|
if (this.nodes.has(definition.type)) {
|
|
6
65
|
throw new Error(`Node type already registered: ${definition.type}`);
|
|
7
66
|
}
|
|
8
67
|
this.nodes.set(definition.type, { definition, implementation });
|
|
68
|
+
const ref = opts?.sourceRef ??
|
|
69
|
+
(this.captureSourceRefs ? captureCallerSourceRef(this.register) : undefined);
|
|
70
|
+
if (ref)
|
|
71
|
+
this.sourceRefs.set(definition.type, ref);
|
|
72
|
+
}
|
|
73
|
+
/** Where `type` was registered from, when known. */
|
|
74
|
+
getSourceRef(type) {
|
|
75
|
+
return this.sourceRefs.get(type);
|
|
9
76
|
}
|
|
10
77
|
get(type) {
|
|
11
78
|
const node = this.nodes.get(type);
|
|
@@ -26,6 +93,12 @@ export class NodeRegistry {
|
|
|
26
93
|
adopt(other) {
|
|
27
94
|
this.nodes = other.nodes;
|
|
28
95
|
this.sources = other.sources;
|
|
96
|
+
this.sourceRefs = other.sourceRefs;
|
|
97
|
+
this.builtinTypes = other.builtinTypes;
|
|
98
|
+
}
|
|
99
|
+
/** Whether the runner flagged `type` as a package built-in (no servable source file). */
|
|
100
|
+
isBuiltin(type) {
|
|
101
|
+
return this.builtinTypes.has(type);
|
|
29
102
|
}
|
|
30
103
|
list() {
|
|
31
104
|
return [...this.nodes.values()].map((n) => n.definition);
|
|
@@ -35,7 +108,14 @@ export class NodeRegistry {
|
|
|
35
108
|
* implementation is a stub that fails loudly if browser-executed — such
|
|
36
109
|
* nodes run on the server. Never overwrites a real registration.
|
|
37
110
|
*/
|
|
38
|
-
registerDefinition(definition, source) {
|
|
111
|
+
registerDefinition(definition, source, opts) {
|
|
112
|
+
// Provenance is recorded even for already-registered types: the studio's
|
|
113
|
+
// bundled built-ins are real registrations, and the runner's builtin flag
|
|
114
|
+
// (or a project sourceRef shadowing one) must still land on them.
|
|
115
|
+
if (opts?.sourceRef)
|
|
116
|
+
this.sourceRefs.set(definition.type, opts.sourceRef);
|
|
117
|
+
if (opts?.builtin)
|
|
118
|
+
this.builtinTypes.add(definition.type);
|
|
39
119
|
if (this.nodes.has(definition.type))
|
|
40
120
|
return;
|
|
41
121
|
this.nodes.set(definition.type, {
|
|
@@ -62,6 +142,8 @@ export class NodeRegistry {
|
|
|
62
142
|
const next = new NodeRegistry();
|
|
63
143
|
next.nodes = this.nodes;
|
|
64
144
|
next.sources = this.sources;
|
|
145
|
+
next.sourceRefs = this.sourceRefs;
|
|
146
|
+
next.builtinTypes = this.builtinTypes;
|
|
65
147
|
return next;
|
|
66
148
|
}
|
|
67
149
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { NodeRegistry } from '../engine/index.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth.
|
|
4
|
+
* `opts.captureSourceRefs` is threaded from SERVER callers only (buildRegistries)
|
|
5
|
+
* — this function is shared with the browser bundle, where capture stays off.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createDefaultRegistry(delayMs?: number, opts?: {
|
|
8
|
+
captureSourceRefs?: boolean;
|
|
9
|
+
}): NodeRegistry;
|
package/dist/src/nodes/index.js
CHANGED
|
@@ -6,9 +6,13 @@ import { registerLoopNodes } from './loops.js';
|
|
|
6
6
|
import { registerPradarNodesModule } from './pradar/index.js';
|
|
7
7
|
import { registerResponseNodes } from './response.js';
|
|
8
8
|
import { registerRequireAuthNode } from './requireAuth.js';
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth.
|
|
11
|
+
* `opts.captureSourceRefs` is threaded from SERVER callers only (buildRegistries)
|
|
12
|
+
* — this function is shared with the browser bundle, where capture stays off.
|
|
13
|
+
*/
|
|
14
|
+
export function createDefaultRegistry(delayMs, opts) {
|
|
15
|
+
const registry = createLoginRegistry(delayMs, opts);
|
|
12
16
|
registerWeatherNodes(registry);
|
|
13
17
|
registerAnomalyNodes(registry);
|
|
14
18
|
registerFlowControlNodes(registry);
|
|
@@ -4,4 +4,6 @@ import { NodeRegistry } from '../engine/index.js';
|
|
|
4
4
|
* `delayMs` is injected into every implementation via `await sleep(delayMs)`
|
|
5
5
|
* so tests can run with no delay by passing `0`.
|
|
6
6
|
*/
|
|
7
|
-
export declare function createLoginRegistry(delayMs?: number
|
|
7
|
+
export declare function createLoginRegistry(delayMs?: number, opts?: {
|
|
8
|
+
captureSourceRefs?: boolean;
|
|
9
|
+
}): NodeRegistry;
|
package/dist/src/nodes/login.js
CHANGED
|
@@ -9,8 +9,8 @@ function usernamePart(userId) {
|
|
|
9
9
|
* `delayMs` is injected into every implementation via `await sleep(delayMs)`
|
|
10
10
|
* so tests can run with no delay by passing `0`.
|
|
11
11
|
*/
|
|
12
|
-
export function createLoginRegistry(delayMs = 300) {
|
|
13
|
-
const registry = new NodeRegistry();
|
|
12
|
+
export function createLoginRegistry(delayMs = 300, opts) {
|
|
13
|
+
const registry = new NodeRegistry(opts);
|
|
14
14
|
registry.register({
|
|
15
15
|
type: 'ValidateCredentials',
|
|
16
16
|
label: 'Validate Credentials',
|
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xdelivered/emberflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Visual builder and runner for API operations modelled as node graphs, with scenarios, mocks and agent skills.",
|
|
6
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"api",
|
|
8
|
+
"workflow",
|
|
9
|
+
"node-graph",
|
|
10
|
+
"low-code",
|
|
11
|
+
"visual-builder",
|
|
12
|
+
"agent-skills"
|
|
13
|
+
],
|
|
7
14
|
"license": "MIT",
|
|
8
|
-
"publishConfig": {
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
9
18
|
"repository": {
|
|
10
19
|
"type": "git",
|
|
11
20
|
"url": "https://github.com/pmccurley87/emberflow.git"
|
|
12
21
|
},
|
|
13
|
-
"bin": {
|
|
22
|
+
"bin": {
|
|
23
|
+
"emberflow": "bin/emberflow.mjs"
|
|
24
|
+
},
|
|
14
25
|
"exports": {
|
|
15
26
|
".": {
|
|
16
27
|
"types": "./dist/src/config.d.ts",
|
|
@@ -25,7 +36,15 @@
|
|
|
25
36
|
"default": "./dist/server/environments.js"
|
|
26
37
|
}
|
|
27
38
|
},
|
|
28
|
-
"files": [
|
|
39
|
+
"files": [
|
|
40
|
+
"bin",
|
|
41
|
+
"server",
|
|
42
|
+
"src",
|
|
43
|
+
"dist",
|
|
44
|
+
"studio-dist",
|
|
45
|
+
"templates",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
29
48
|
"scripts": {
|
|
30
49
|
"dev": "vite",
|
|
31
50
|
"build": "tsc -b && vite build",
|
|
@@ -67,13 +86,16 @@
|
|
|
67
86
|
"tailwind-merge": "^3.6.0",
|
|
68
87
|
"tailwindcss": "^4.3.2",
|
|
69
88
|
"tw-animate-css": "^1.4.0",
|
|
89
|
+
"typescript": "~6.0.2",
|
|
70
90
|
"zustand": "^5.0.14"
|
|
71
91
|
},
|
|
72
92
|
"peerDependencies": {
|
|
73
93
|
"tsx": "^4.22.5"
|
|
74
94
|
},
|
|
75
95
|
"peerDependenciesMeta": {
|
|
76
|
-
"tsx": {
|
|
96
|
+
"tsx": {
|
|
97
|
+
"optional": true
|
|
98
|
+
}
|
|
77
99
|
},
|
|
78
100
|
"devDependencies": {
|
|
79
101
|
"@types/express": "^5.0.6",
|
|
@@ -85,7 +107,6 @@
|
|
|
85
107
|
"@vitejs/plugin-react": "^6.0.3",
|
|
86
108
|
"oxlint": "^1.71.0",
|
|
87
109
|
"tsx": "^4.22.5",
|
|
88
|
-
"typescript": "~6.0.2",
|
|
89
110
|
"vite": "^8.1.1",
|
|
90
111
|
"vitest": "^4.1.9"
|
|
91
112
|
}
|
|
@@ -124,6 +124,26 @@ describe('POST /agent — setup-environments route validation', () => {
|
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
describe('POST /agent — guided-setup route validation', () => {
|
|
128
|
+
it('accepts a guided-setup intent WITHOUT flowId or environment (instruction-only, like setup-environments)', async () => {
|
|
129
|
+
const res = await postAgentWhenFree({ intent: { action: 'guided-setup', instruction: 'dev and prod please' } });
|
|
130
|
+
expect(res.status).toBe(201);
|
|
131
|
+
const body = await res.json();
|
|
132
|
+
expect(typeof body.agentRunId).toBe('string');
|
|
133
|
+
expect(body.agentRunId.length).toBeGreaterThan(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accepts a guided-setup intent with an empty instruction (the no-notes start)', async () => {
|
|
137
|
+
const res = await postAgentWhenFree({ intent: { action: 'guided-setup', instruction: '' } });
|
|
138
|
+
expect(res.status).toBe(201);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('400s a guided-setup intent with no instruction field', async () => {
|
|
142
|
+
const res = await postAgent({ intent: { action: 'guided-setup' } });
|
|
143
|
+
expect(res.status).toBe(400);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
127
147
|
/** The run manager is single-flight per project (409 while a previous stub run
|
|
128
148
|
* winds down) — retry briefly so route-validation asserts don't race it. */
|
|
129
149
|
async function postAgentWhenFree(body: unknown): Promise<Response> {
|
|
@@ -55,4 +55,41 @@ describe('parseCodexLine', () => {
|
|
|
55
55
|
expect(parseCodexLine('')).toBeNull();
|
|
56
56
|
expect(parseCodexLine('not json')).toBeNull();
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
// Captured live from codex 0.142.5 rejecting the default model: the failure
|
|
60
|
+
// arrives as a top-level `error` line + a terminal `turn.failed`, both with
|
|
61
|
+
// the human-readable cause double-encoded as JSON-in-a-string. Unhandled,
|
|
62
|
+
// the adapter could only report a generic "codex exited with code 1".
|
|
63
|
+
const REJECTION =
|
|
64
|
+
'{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.6-sol\' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again."}}';
|
|
65
|
+
|
|
66
|
+
it('maps turn.failed to a terminal error with the inner message unwrapped and the stale-CLI hint', () => {
|
|
67
|
+
const line = JSON.stringify({ type: 'turn.failed', error: { message: REJECTION } });
|
|
68
|
+
const event = parseCodexLine(line);
|
|
69
|
+
expect(event?.type).toBe('error');
|
|
70
|
+
const text = (event as { text: string }).text;
|
|
71
|
+
expect(text).toContain("The 'gpt-5.6-sol' model requires a newer version of Codex");
|
|
72
|
+
expect(text).toContain('hint: your codex CLI may be too old');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('maps a top-level error line to a non-terminal ⚠ message with the inner message unwrapped', () => {
|
|
76
|
+
const line = JSON.stringify({ type: 'error', message: REJECTION });
|
|
77
|
+
const event = parseCodexLine(line);
|
|
78
|
+
expect(event?.type).toBe('message');
|
|
79
|
+
expect((event as { text: string }).text).toContain(
|
|
80
|
+
"⚠ The 'gpt-5.6-sol' model requires a newer version of Codex",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('turn.failed with a plain (non-JSON) message passes it through verbatim', () => {
|
|
85
|
+
const line = JSON.stringify({ type: 'turn.failed', error: { message: 'boom' } });
|
|
86
|
+
expect(parseCodexLine(line)).toEqual({ type: 'error', text: 'boom' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('turn.failed with no error payload still terminates with a generic message', () => {
|
|
90
|
+
expect(parseCodexLine('{"type":"turn.failed"}')).toEqual({
|
|
91
|
+
type: 'error',
|
|
92
|
+
text: 'codex turn failed',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
58
95
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentEvent } from './types';
|
|
2
|
+
import { modelRejectionHint } from './modelRejectionHint';
|
|
2
3
|
|
|
3
4
|
interface CodexCommandExecutionItem {
|
|
4
5
|
type: 'command_execution';
|
|
@@ -55,6 +56,24 @@ interface CodexLine {
|
|
|
55
56
|
type: string;
|
|
56
57
|
item?: CodexItem;
|
|
57
58
|
usage?: Record<string, number>;
|
|
59
|
+
message?: string;
|
|
60
|
+
error?: { message?: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Codex wraps API failures as JSON-in-a-string: `turn.failed`'s error.message
|
|
65
|
+
* (and top-level `error`'s message) is often itself a serialized
|
|
66
|
+
* `{"type":"error","status":400,"error":{"message":"…"}}` payload. Dig out the
|
|
67
|
+
* innermost human-readable message, falling back to the raw string.
|
|
68
|
+
*/
|
|
69
|
+
function extractCodexErrorMessage(raw: string | undefined): string {
|
|
70
|
+
if (!raw) return 'codex turn failed';
|
|
71
|
+
try {
|
|
72
|
+
const inner = JSON.parse(raw) as { error?: { message?: string }; message?: string };
|
|
73
|
+
return inner.error?.message ?? inner.message ?? raw;
|
|
74
|
+
} catch {
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
export function parseCodexLine(line: string): AgentEvent | null {
|
|
@@ -120,6 +139,21 @@ export function parseCodexLine(line: string): AgentEvent | null {
|
|
|
120
139
|
case 'turn.completed':
|
|
121
140
|
return { type: 'done', usage: parsed.usage };
|
|
122
141
|
|
|
142
|
+
// The turn itself failed (e.g. the API rejected the request) — this IS the
|
|
143
|
+
// run failing, unlike an `error` ITEM above. Codex exits 1 after emitting
|
|
144
|
+
// it; without handling it here the adapter can only synthesize a generic
|
|
145
|
+
// "codex exited with code 1" and the real cause stays buried.
|
|
146
|
+
case 'turn.failed': {
|
|
147
|
+
const text = extractCodexErrorMessage(parsed.error?.message);
|
|
148
|
+
const hint = modelRejectionHint('codex', text);
|
|
149
|
+
return { type: 'error', text: hint ? `${text} (${hint})` : text };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Top-level `error` lines precede turn.failed with the same payload —
|
|
153
|
+
// surface as a visible diagnostic, let turn.failed be the terminal event.
|
|
154
|
+
case 'error':
|
|
155
|
+
return { type: 'message', text: `⚠ ${extractCodexErrorMessage(parsed.message)}` };
|
|
156
|
+
|
|
123
157
|
default:
|
|
124
158
|
return null;
|
|
125
159
|
}
|
|
@@ -7,19 +7,19 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
const fixture = (name: string) => path.join(__dirname, '__fixtures__', name);
|
|
8
8
|
|
|
9
9
|
describe('detectAgents', () => {
|
|
10
|
-
it('returns only the CLIs the injected probe reports as present, with parsed versions', () => {
|
|
10
|
+
it('returns only the CLIs the injected probe reports as present, with parsed versions and the bin', () => {
|
|
11
11
|
expect(detectAgents((bin) => (bin === 'codex' ? { version: '0.142.5' } : undefined))).toEqual([
|
|
12
|
-
{ kind: 'codex', version: '0.142.5' },
|
|
12
|
+
{ kind: 'codex', version: '0.142.5', bin: 'codex' },
|
|
13
13
|
]);
|
|
14
14
|
expect(detectAgents((bin) => (bin === 'claude' ? { version: '1.2.3' } : undefined))).toEqual([
|
|
15
|
-
{ kind: 'claude', version: '1.2.3' },
|
|
15
|
+
{ kind: 'claude', version: '1.2.3', bin: 'claude' },
|
|
16
16
|
]);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
it('returns both when both are present', () => {
|
|
20
|
-
expect(detectAgents(() => ({ version: '1.0.0' }))).toEqual([
|
|
21
|
-
{ kind: 'codex', version: '1.0.0' },
|
|
22
|
-
{ kind: 'claude', version: '1.0.0' },
|
|
20
|
+
expect(detectAgents((bin) => (bin === 'codex' || bin === 'claude' ? { version: '1.0.0' } : undefined))).toEqual([
|
|
21
|
+
{ kind: 'codex', version: '1.0.0', bin: 'codex' },
|
|
22
|
+
{ kind: 'claude', version: '1.0.0', bin: 'claude' },
|
|
23
23
|
]);
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -29,10 +29,29 @@ describe('detectAgents', () => {
|
|
|
29
29
|
|
|
30
30
|
it('reports null version when present but the output has no parseable semver token', () => {
|
|
31
31
|
expect(detectAgents((bin) => (bin === 'codex' ? { version: null } : undefined))).toEqual([
|
|
32
|
-
{ kind: 'codex', version: null },
|
|
32
|
+
{ kind: 'codex', version: null, bin: 'codex' },
|
|
33
33
|
]);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('picks the NEWEST version across candidate locations, not the PATH shim', () => {
|
|
37
|
+
// PATH `codex` is pinned old; the ChatGPT.app bundle is newer — the bundle wins.
|
|
38
|
+
const versions: Record<string, string> = {
|
|
39
|
+
codex: '0.142.5',
|
|
40
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex': '0.156.0',
|
|
41
|
+
};
|
|
42
|
+
const found = detectAgents((bin) => (versions[bin] ? { version: versions[bin] } : undefined));
|
|
43
|
+
expect(found).toEqual([
|
|
44
|
+
{ kind: 'codex', version: '0.156.0', bin: '/Applications/ChatGPT.app/Contents/Resources/codex' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('a parseable version beats a null one regardless of probe order', () => {
|
|
49
|
+
const found = detectAgents((bin) =>
|
|
50
|
+
bin === 'codex' ? { version: null } : bin === '/opt/homebrew/bin/codex' ? { version: '0.1.0' } : undefined,
|
|
51
|
+
);
|
|
52
|
+
expect(found).toEqual([{ kind: 'codex', version: '0.1.0', bin: '/opt/homebrew/bin/codex' }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
36
55
|
it('defaults to a real PATH probe that returns an array', () => {
|
|
37
56
|
expect(Array.isArray(detectAgents())).toBe(true);
|
|
38
57
|
});
|