@xdelivered/emberflow 0.2.0 → 0.4.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 +185 -14
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- 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/package.json +27 -6
- 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 +189 -13
- package/server/nodeRun.test.ts +189 -0
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -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/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- 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/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/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 +383 -312
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-CAIDjNhv.css +1 -0
- package/studio-dist/assets/index-CGwEx82J.js +65 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-model-process/SKILL.md +82 -9
- package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
|
@@ -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>;
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xdelivered/emberflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -73,7 +92,9 @@
|
|
|
73
92
|
"tsx": "^4.22.5"
|
|
74
93
|
},
|
|
75
94
|
"peerDependenciesMeta": {
|
|
76
|
-
"tsx": {
|
|
95
|
+
"tsx": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
77
98
|
},
|
|
78
99
|
"devDependencies": {
|
|
79
100
|
"@types/express": "^5.0.6",
|
|
@@ -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
|
});
|
package/server/agents/detect.ts
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import type { AgentKind } from './types';
|
|
3
5
|
|
|
4
|
-
const CANDIDATES: AgentKind[] = ['codex', 'claude'];
|
|
5
|
-
|
|
6
6
|
const VERSION_RE = /\d+\.\d+\.\d+/;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Where each agent CLI may live, beyond whatever PATH resolves. PATH shims can
|
|
10
|
+
* pin an OLD version (e.g. a superset-managed ~/.superset/bin/codex frozen at
|
|
11
|
+
* a release the API no longer accepts) while a newer binary sits elsewhere —
|
|
12
|
+
* so we probe every known location and pick the NEWEST, never just the first.
|
|
13
|
+
*/
|
|
14
|
+
const CANDIDATE_BINS: Record<AgentKind, string[]> = {
|
|
15
|
+
codex: [
|
|
16
|
+
'codex', // PATH resolution (may be a pinned shim)
|
|
17
|
+
join(homedir(), '.local', 'bin', 'codex'),
|
|
18
|
+
'/opt/homebrew/bin/codex',
|
|
19
|
+
'/usr/local/bin/codex',
|
|
20
|
+
// The ChatGPT desktop app bundles its own codex, updated with the app.
|
|
21
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
22
|
+
],
|
|
23
|
+
claude: ['claude', join(homedir(), '.local', 'bin', 'claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude'],
|
|
24
|
+
};
|
|
25
|
+
|
|
8
26
|
export interface DetectedAgent {
|
|
9
27
|
kind: AgentKind;
|
|
10
28
|
version: string | null;
|
|
29
|
+
/** The concrete binary the newest version was found at — what spawns should use. */
|
|
30
|
+
bin: string;
|
|
11
31
|
}
|
|
12
32
|
|
|
13
33
|
/**
|
|
14
|
-
* Real
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
34
|
+
* Real probe: runs `<bin> --version` and parses the first semver-ish token out
|
|
35
|
+
* of stdout. Returns `undefined` when the binary isn't present/runnable (spawn
|
|
36
|
+
* error, timeout, or nonzero exit); `null` version when it ran but produced no
|
|
37
|
+
* parseable version string.
|
|
18
38
|
*/
|
|
19
39
|
export function probe(bin: string): { version: string | null } | undefined {
|
|
20
40
|
const result = spawnSync(bin, ['--version'], {
|
|
@@ -27,17 +47,65 @@ export function probe(bin: string): { version: string | null } | undefined {
|
|
|
27
47
|
return { version: match ? match[0] : null };
|
|
28
48
|
}
|
|
29
49
|
|
|
50
|
+
/** Numeric segment compare; a parseable version always beats null.
|
|
51
|
+
* Shared: agent-CLI detection here, and the package update check
|
|
52
|
+
* (server/updateCheck.ts) both rank semver-ish strings with it. */
|
|
53
|
+
export function newer(a: string | null, b: string | null): boolean {
|
|
54
|
+
if (a === null) return false;
|
|
55
|
+
if (b === null) return true;
|
|
56
|
+
const as = a.split('.').map(Number);
|
|
57
|
+
const bs = b.split('.').map(Number);
|
|
58
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
59
|
+
const d = (as[i] ?? 0) - (bs[i] ?? 0);
|
|
60
|
+
if (d !== 0) return d > 0;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
/**
|
|
31
|
-
* Detects which coding-agent CLIs are available
|
|
32
|
-
* each
|
|
33
|
-
*
|
|
34
|
-
*
|
|
66
|
+
* Detects which coding-agent CLIs are available — probing PATH plus the known
|
|
67
|
+
* install locations for each kind — and keeps the NEWEST version found per
|
|
68
|
+
* kind, with the binary path it came from. `probeBin` is injectable so tests
|
|
69
|
+
* can stub presence/version per location without shelling out.
|
|
35
70
|
*/
|
|
36
|
-
export function detectAgents(
|
|
71
|
+
export function detectAgents(
|
|
72
|
+
probeBin: (bin: string) => { version: string | null } | undefined = probe,
|
|
73
|
+
): DetectedAgent[] {
|
|
37
74
|
const found: DetectedAgent[] = [];
|
|
38
|
-
for (const kind of
|
|
39
|
-
|
|
40
|
-
|
|
75
|
+
for (const kind of Object.keys(CANDIDATE_BINS) as AgentKind[]) {
|
|
76
|
+
let best: DetectedAgent | undefined;
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const bin of CANDIDATE_BINS[kind]) {
|
|
79
|
+
if (seen.has(bin)) continue;
|
|
80
|
+
seen.add(bin);
|
|
81
|
+
const result = probeBin(bin);
|
|
82
|
+
if (!result) continue;
|
|
83
|
+
if (!best || newer(result.version, best.version)) {
|
|
84
|
+
best = { kind, version: result.version, bin };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (best) found.push(best);
|
|
41
88
|
}
|
|
42
89
|
return found;
|
|
43
90
|
}
|
|
91
|
+
|
|
92
|
+
// Probing every candidate spawns several subprocesses; /setup-status and
|
|
93
|
+
// /agent/available are polled by the studio, so cache briefly.
|
|
94
|
+
const RESOLVE_TTL_MS = 30_000;
|
|
95
|
+
let cachedAt = 0;
|
|
96
|
+
let cached: DetectedAgent[] | null = null;
|
|
97
|
+
|
|
98
|
+
/** Cached detection — the newest binary per kind, refreshed every 30s. */
|
|
99
|
+
export function detectAgentsCached(): DetectedAgent[] {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (!cached || now - cachedAt > RESOLVE_TTL_MS) {
|
|
102
|
+
cached = detectAgents();
|
|
103
|
+
cachedAt = now;
|
|
104
|
+
}
|
|
105
|
+
return cached;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The concrete binary path spawns should use for `kind` — the newest found. */
|
|
109
|
+
export function resolveAgentBin(kind: AgentKind): string | undefined {
|
|
110
|
+
return detectAgentsCached().find((a) => a.kind === kind)?.bin;
|
|
111
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentKind } from './types';
|
|
2
2
|
|
|
3
|
-
const MODEL_REJECTION_RE =
|
|
3
|
+
const MODEL_REJECTION_RE =
|
|
4
|
+
/unknown model|unsupported model|invalid model|model.*not.*(found|supported)|model requires a newer version/i;
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* When an agent CLI exits nonzero without ever emitting a terminal `done`,
|