@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
package/server/subflowRunner.ts
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
FlowRun,
|
|
2
3
|
LogLine,
|
|
3
4
|
NodeRegistry,
|
|
5
|
+
NodeRunState,
|
|
4
6
|
StartRunOptions,
|
|
5
7
|
SubflowResult,
|
|
6
8
|
TraceSink,
|
|
7
9
|
WorkflowDefinition,
|
|
10
|
+
WorkflowRun,
|
|
8
11
|
} from '../src/engine';
|
|
9
12
|
import { runOutput, startRun } from '../src/engine';
|
|
10
13
|
|
|
11
14
|
/** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
|
|
12
15
|
export const SUBFLOW_DEPTH_CAP = 8;
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* One live drilled-into child on a stepped run. Pushed by the subflow runner
|
|
19
|
+
* when a Subflow node starts its child; popped by the registry's composite
|
|
20
|
+
* step() when the child's own stepping completes. `resolve`/`reject` settle
|
|
21
|
+
* the promise the parent's Subflow node execution is blocked on.
|
|
22
|
+
*/
|
|
23
|
+
export interface DrillEntry {
|
|
24
|
+
handle: FlowRun;
|
|
25
|
+
/** The child flow's id (what the caller drilled into). */
|
|
26
|
+
workflowId: string;
|
|
27
|
+
/** The parent Subflow node that triggered the child. */
|
|
28
|
+
viaNodeId: string;
|
|
29
|
+
/** Hands the completed child run back to the blocked parent Subflow node. */
|
|
30
|
+
resolve: (run: WorkflowRun) => void;
|
|
31
|
+
reject: (err: unknown) => void;
|
|
32
|
+
/**
|
|
33
|
+
* This level's own executor.step() promise, left pending while a DEEPER
|
|
34
|
+
* child runs (a Subflow node's execution blocks on its child). Stashed by
|
|
35
|
+
* the registry when it observes `entered`; folded back in on exit.
|
|
36
|
+
*/
|
|
37
|
+
pendingStep?: Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per-root-run drill state for stepped runs. The root's subflow runner and
|
|
42
|
+
* every nested child's runner share the SAME instance, so grandchildren push
|
|
43
|
+
* onto the same stack and nesting Just Works.
|
|
44
|
+
*/
|
|
45
|
+
export interface DrillState {
|
|
46
|
+
stack: DrillEntry[];
|
|
47
|
+
/** Armed by the registry's step() while it awaits a level's step, so a
|
|
48
|
+
* child starting can be raced against the (now blocked) step promise. */
|
|
49
|
+
onChildStarted?: (entry: DrillEntry) => void;
|
|
50
|
+
/** Set by RunRegistry.cancel(): unwinding parent executions must not start
|
|
51
|
+
* (or block on) new children — runSubflow fails fast instead. */
|
|
52
|
+
cancelled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
export interface SubflowRunnerOptions {
|
|
15
56
|
/** Resolves a child workflow by id (server hosts this via apiStore.load). */
|
|
16
57
|
loadFlow: (id: string) => WorkflowDefinition | undefined;
|
|
@@ -29,6 +70,17 @@ export interface SubflowRunnerOptions {
|
|
|
29
70
|
* prefixed with the child flow's name and the caller node's id, exactly as
|
|
30
71
|
* the live runner streams them over SSE. Absent for headless runs. */
|
|
31
72
|
onLog?: (line: LogLine) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Present only for stepped runs: children become step-drivable instead of
|
|
75
|
+
* running to completion. The runner pushes each started child onto
|
|
76
|
+
* `drill.stack` and blocks the Subflow node until the registry pops it.
|
|
77
|
+
*/
|
|
78
|
+
drill?: DrillState;
|
|
79
|
+
/** Child nodeState forwarder for stepped runs — receives the CHILD flow's
|
|
80
|
+
* id so the client can route states to the drilled-in view. Only wired
|
|
81
|
+
* when `drill` is present (non-stepped children keep today's behavior:
|
|
82
|
+
* their node states are discarded, only the output returns). */
|
|
83
|
+
onNodeState?: (workflowId: string, nodeId: string, state: NodeRunState) => void;
|
|
32
84
|
}
|
|
33
85
|
|
|
34
86
|
/**
|
|
@@ -45,6 +97,11 @@ export function makeSubflowRunner(
|
|
|
45
97
|
ancestry: string[],
|
|
46
98
|
): NonNullable<StartRunOptions['subflowRunner']> {
|
|
47
99
|
return async (workflowId, input, callerNodeId): Promise<SubflowResult> => {
|
|
100
|
+
// A cancelled stepped run's unwinding parents (their runSubflow was
|
|
101
|
+
// rejected) may retry: never push a new child that nothing will step.
|
|
102
|
+
if (opts.drill?.cancelled) {
|
|
103
|
+
return { status: 'failed', error: 'run cancelled' };
|
|
104
|
+
}
|
|
48
105
|
if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
|
|
49
106
|
return { status: 'failed', error: `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded` };
|
|
50
107
|
}
|
|
@@ -72,18 +129,42 @@ export function makeSubflowRunner(
|
|
|
72
129
|
mocks: (childFlow as { mocks?: Record<string, unknown> }).mocks ?? {},
|
|
73
130
|
trace: opts.trace,
|
|
74
131
|
subflowRunner: makeSubflowRunner(opts, [...ancestry, workflowId]),
|
|
75
|
-
events:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
opts.onLog
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
132
|
+
events:
|
|
133
|
+
opts.onLog || (opts.drill && opts.onNodeState)
|
|
134
|
+
? {
|
|
135
|
+
...(opts.onLog
|
|
136
|
+
? {
|
|
137
|
+
onLog: (line: LogLine) =>
|
|
138
|
+
opts.onLog!({
|
|
139
|
+
...line,
|
|
140
|
+
nodeLabel: `${childFlow.name} › ${line.nodeLabel ?? ''}`,
|
|
141
|
+
nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
// Stepped children stream their node states into the ROOT
|
|
146
|
+
// run's SSE stream, tagged with the child flow's id.
|
|
147
|
+
...(opts.drill && opts.onNodeState
|
|
148
|
+
? {
|
|
149
|
+
onNodeStateChange: (nodeId: string, state: NodeRunState) =>
|
|
150
|
+
opts.onNodeState!(childFlow.id, nodeId, state),
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
}
|
|
154
|
+
: undefined,
|
|
85
155
|
});
|
|
86
|
-
|
|
156
|
+
// Stepped run: don't run the child here. Push it onto the shared drill
|
|
157
|
+
// stack and block this Subflow node until the registry's composite
|
|
158
|
+
// step() drives the child to completion and pops the entry — resolving
|
|
159
|
+
// with the completed child run, which flows into the exact same
|
|
160
|
+
// output-extraction / failure-mapping path runToEnd feeds today.
|
|
161
|
+
const childRun = opts.drill
|
|
162
|
+
? await new Promise<WorkflowRun>((resolve, reject) => {
|
|
163
|
+
const entry: DrillEntry = { handle: childHandle, workflowId, viaNodeId: callerNodeId, resolve, reject };
|
|
164
|
+
opts.drill!.stack.push(entry);
|
|
165
|
+
opts.drill!.onChildStarted?.(entry);
|
|
166
|
+
})
|
|
167
|
+
: await childHandle.runToEnd();
|
|
87
168
|
if (childRun.status !== 'succeeded') {
|
|
88
169
|
// Surface the child's actual failure (e.g. the Mock-mode "would touch
|
|
89
170
|
// real infrastructure" boundary message) through the Subflow node's
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
checkForUpdate,
|
|
8
|
+
isLinkedInstall,
|
|
9
|
+
ownVersion,
|
|
10
|
+
resetUpdateCheckCache,
|
|
11
|
+
runNpmInstall,
|
|
12
|
+
type InstallResult,
|
|
13
|
+
} from './updateCheck';
|
|
14
|
+
|
|
15
|
+
/** A fetch stub returning a registry-shaped `{ version }` body. */
|
|
16
|
+
function fetchReturning(version: string, ok = true): typeof fetch {
|
|
17
|
+
return vi.fn(async () => ({ ok, json: async () => ({ version }) })) as unknown as typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('checkForUpdate', () => {
|
|
21
|
+
beforeEach(() => resetUpdateCheckCache());
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
delete process.env.EMBERFLOW_UPDATE_CHECK;
|
|
24
|
+
delete process.env.EMBERFLOW_REGISTRY;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports updateAvailable when the registry version is newer', async () => {
|
|
28
|
+
const result = await checkForUpdate('0.3.0', { fetchFn: fetchReturning('0.4.0') });
|
|
29
|
+
expect(result).toEqual({ current: '0.3.0', latest: '0.4.0', updateAvailable: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports no update when versions are equal', async () => {
|
|
33
|
+
const result = await checkForUpdate('0.3.0', { fetchFn: fetchReturning('0.3.0') });
|
|
34
|
+
expect(result).toEqual({ current: '0.3.0', latest: '0.3.0', updateAvailable: false });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('reports no update when the registry version is OLDER (local ahead of npm)', async () => {
|
|
38
|
+
const result = await checkForUpdate('0.10.0', { fetchFn: fetchReturning('0.9.9') });
|
|
39
|
+
expect(result?.updateAvailable).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('compares numerically, not lexically (0.10.0 beats 0.9.0)', async () => {
|
|
43
|
+
const result = await checkForUpdate('0.9.0', { fetchFn: fetchReturning('0.10.0') });
|
|
44
|
+
expect(result?.updateAvailable).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null on fetch rejection (fail-silent)', async () => {
|
|
48
|
+
const fetchFn = vi.fn(async () => {
|
|
49
|
+
throw new Error('ENETDOWN');
|
|
50
|
+
}) as unknown as typeof fetch;
|
|
51
|
+
await expect(checkForUpdate('0.3.0', { fetchFn })).resolves.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null on a non-ok response (404 pre-publish)', async () => {
|
|
55
|
+
await expect(checkForUpdate('0.3.0', { fetchFn: fetchReturning('irrelevant', false) })).resolves.toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null on malformed json / missing version', async () => {
|
|
59
|
+
const fetchFn = vi.fn(async () => ({
|
|
60
|
+
ok: true,
|
|
61
|
+
json: async () => ({ nope: true }),
|
|
62
|
+
})) as unknown as typeof fetch;
|
|
63
|
+
await expect(checkForUpdate('0.3.0', { fetchFn })).resolves.toBeNull();
|
|
64
|
+
resetUpdateCheckCache();
|
|
65
|
+
const throwsOnJson = vi.fn(async () => ({
|
|
66
|
+
ok: true,
|
|
67
|
+
json: async () => {
|
|
68
|
+
throw new Error('bad json');
|
|
69
|
+
},
|
|
70
|
+
})) as unknown as typeof fetch;
|
|
71
|
+
await expect(checkForUpdate('0.3.0', { fetchFn: throwsOnJson })).resolves.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('caches within the TTL and refetches after it expires', async () => {
|
|
75
|
+
let clock = 1_000;
|
|
76
|
+
const fetchFn = fetchReturning('0.4.0');
|
|
77
|
+
const opts = { fetchFn, ttlMs: 60_000, now: () => clock };
|
|
78
|
+
|
|
79
|
+
expect(await checkForUpdate('0.3.0', opts)).toMatchObject({ latest: '0.4.0' });
|
|
80
|
+
clock += 30_000; // inside TTL — served from cache
|
|
81
|
+
expect(await checkForUpdate('0.3.0', opts)).toMatchObject({ latest: '0.4.0' });
|
|
82
|
+
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
clock += 60_001; // past TTL — hits the registry again
|
|
85
|
+
await checkForUpdate('0.3.0', opts);
|
|
86
|
+
expect(fetchFn).toHaveBeenCalledTimes(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('caches failures too (no registry hammering while offline)', async () => {
|
|
90
|
+
const fetchFn = vi.fn(async () => {
|
|
91
|
+
throw new Error('offline');
|
|
92
|
+
}) as unknown as typeof fetch;
|
|
93
|
+
await checkForUpdate('0.3.0', { fetchFn });
|
|
94
|
+
await checkForUpdate('0.3.0', { fetchFn });
|
|
95
|
+
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is disabled entirely by EMBERFLOW_UPDATE_CHECK=0', async () => {
|
|
99
|
+
process.env.EMBERFLOW_UPDATE_CHECK = '0';
|
|
100
|
+
const fetchFn = fetchReturning('9.9.9');
|
|
101
|
+
await expect(checkForUpdate('0.3.0', { fetchFn })).resolves.toBeNull();
|
|
102
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('honors the EMBERFLOW_REGISTRY override', async () => {
|
|
106
|
+
process.env.EMBERFLOW_REGISTRY = 'http://127.0.0.1:9999';
|
|
107
|
+
const fetchFn = fetchReturning('0.4.0');
|
|
108
|
+
await checkForUpdate('0.3.0', { fetchFn });
|
|
109
|
+
expect(fetchFn).toHaveBeenCalledWith('http://127.0.0.1:9999/@xdelivered/emberflow/latest');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('ownVersion', () => {
|
|
114
|
+
it('resolves this package version from package.json (source layout)', () => {
|
|
115
|
+
// Running from the source repo: server/updateCheck.ts → root is one up.
|
|
116
|
+
expect(ownVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Minimal spawn-shaped fake: emits like a ChildProcess, never runs npm. */
|
|
121
|
+
function fakeChild() {
|
|
122
|
+
const child = new EventEmitter() as EventEmitter & {
|
|
123
|
+
stdout: EventEmitter;
|
|
124
|
+
stderr: EventEmitter;
|
|
125
|
+
kill: ReturnType<typeof vi.fn>;
|
|
126
|
+
};
|
|
127
|
+
child.stdout = new EventEmitter();
|
|
128
|
+
child.stderr = new EventEmitter();
|
|
129
|
+
child.kill = vi.fn();
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('runNpmInstall', () => {
|
|
134
|
+
it('spawns npm install <pkg>@latest in the project root, no shell', async () => {
|
|
135
|
+
const child = fakeChild();
|
|
136
|
+
const spawnFn = vi.fn(() => child);
|
|
137
|
+
const done = runNpmInstall('/proj', { spawnFn: spawnFn as never });
|
|
138
|
+
expect(spawnFn).toHaveBeenCalledWith('npm', ['install', '@xdelivered/emberflow@latest'], {
|
|
139
|
+
cwd: '/proj',
|
|
140
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
child.emit('close', 0);
|
|
143
|
+
await expect(done).resolves.toEqual({ ok: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('fails with the output tail on a nonzero exit', async () => {
|
|
147
|
+
const child = fakeChild();
|
|
148
|
+
const done = runNpmInstall('/proj', { spawnFn: (() => child) as never });
|
|
149
|
+
child.stderr.emit('data', 'npm ERR! code E403\n');
|
|
150
|
+
child.stderr.emit('data', 'npm ERR! forbidden\n');
|
|
151
|
+
child.emit('close', 1);
|
|
152
|
+
const result = (await done) as InstallResult & { ok: false };
|
|
153
|
+
expect(result.ok).toBe(false);
|
|
154
|
+
expect(result.error).toContain('E403');
|
|
155
|
+
expect(result.error).toContain('forbidden');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps only the last ~4KB of output in the error tail', async () => {
|
|
159
|
+
const child = fakeChild();
|
|
160
|
+
const done = runNpmInstall('/proj', { spawnFn: (() => child) as never });
|
|
161
|
+
child.stdout.emit('data', 'EARLY-MARKER ' + 'x'.repeat(8000));
|
|
162
|
+
child.stderr.emit('data', ' LATE-MARKER');
|
|
163
|
+
child.emit('close', 1);
|
|
164
|
+
const result = (await done) as InstallResult & { ok: false };
|
|
165
|
+
expect(result.error).toContain('LATE-MARKER');
|
|
166
|
+
expect(result.error).not.toContain('EARLY-MARKER');
|
|
167
|
+
expect(result.error.length).toBeLessThanOrEqual(4096);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('fails on a spawn error event (npm missing) without rejecting', async () => {
|
|
171
|
+
const child = fakeChild();
|
|
172
|
+
const done = runNpmInstall('/proj', { spawnFn: (() => child) as never });
|
|
173
|
+
child.emit('error', new Error('spawn npm ENOENT'));
|
|
174
|
+
await expect(done).resolves.toEqual({ ok: false, error: 'spawn npm ENOENT' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('kills and fails after the timeout', async () => {
|
|
178
|
+
const child = fakeChild();
|
|
179
|
+
const done = runNpmInstall('/proj', { spawnFn: (() => child) as never, timeoutMs: 10 });
|
|
180
|
+
const result = await done; // child never closes — the timer settles it
|
|
181
|
+
expect(result.ok).toBe(false);
|
|
182
|
+
if (!result.ok) expect(result.error).toContain('timed out');
|
|
183
|
+
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('isLinkedInstall', () => {
|
|
188
|
+
let root: string;
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
root = mkdtempSync(join(tmpdir(), 'ef-update-'));
|
|
191
|
+
});
|
|
192
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
193
|
+
|
|
194
|
+
it('is false when the package is not installed at all', () => {
|
|
195
|
+
expect(isLinkedInstall(root)).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('is false for a real directory install', () => {
|
|
199
|
+
mkdirSync(join(root, 'node_modules', '@xdelivered', 'emberflow'), { recursive: true });
|
|
200
|
+
expect(isLinkedInstall(root)).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('is true for a symlinked (file:/npm link) install', () => {
|
|
204
|
+
mkdirSync(join(root, 'node_modules', '@xdelivered'), { recursive: true });
|
|
205
|
+
mkdirSync(join(root, 'real-checkout'));
|
|
206
|
+
symlinkSync(join(root, 'real-checkout'), join(root, 'node_modules', '@xdelivered', 'emberflow'));
|
|
207
|
+
expect(isLinkedInstall(root)).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
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';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Update notifier + installer plumbing for consumer projects (they install
|
|
9
|
+
* @xdelivered/emberflow from npm and run `npx emberflow dev`).
|
|
10
|
+
*
|
|
11
|
+
* - checkForUpdate: asks the npm registry for the latest published version and
|
|
12
|
+
* compares it against the running one. Fail-SILENT by design: any network,
|
|
13
|
+
* HTTP, or parse failure returns null — an update nudge must never break or
|
|
14
|
+
* slow the studio. Results (including failures) are cached in-memory for an
|
|
15
|
+
* hour so /update-status stays cheap and the registry isn't hammered.
|
|
16
|
+
* - runNpmInstall: the actual one-click updater — `npm install <pkg>@latest`
|
|
17
|
+
* spawned (no shell) in the project root. Injectable spawn for tests.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const EMBERFLOW_PACKAGE = '@xdelivered/emberflow';
|
|
21
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
22
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
|
|
23
|
+
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; // 5min
|
|
24
|
+
const OUTPUT_TAIL_BYTES = 4096;
|
|
25
|
+
|
|
26
|
+
export interface UpdateCheckResult {
|
|
27
|
+
current: string;
|
|
28
|
+
latest: string;
|
|
29
|
+
updateAvailable: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CheckForUpdateOpts {
|
|
33
|
+
/** Registry base URL. Defaults to EMBERFLOW_REGISTRY env, then npmjs. */
|
|
34
|
+
registry?: string;
|
|
35
|
+
/** Cache TTL in ms (default 1h). */
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
/** Injectable clock for TTL tests. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
/** Injectable fetch for tests. */
|
|
40
|
+
fetchFn?: typeof fetch;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Single-entry cache: the runner only ever checks one package/version pair.
|
|
44
|
+
// Failures are cached too — a flaky network shouldn't retrigger a fetch on
|
|
45
|
+
// every /update-status poll.
|
|
46
|
+
let cache: { at: number; result: UpdateCheckResult | null } | null = null;
|
|
47
|
+
|
|
48
|
+
/** Test hook: clear the in-memory check cache. */
|
|
49
|
+
export function resetUpdateCheckCache(): void {
|
|
50
|
+
cache = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Latest-version check against the npm registry. Returns null when the check
|
|
55
|
+
* is disabled (EMBERFLOW_UPDATE_CHECK=0) or unavailable for ANY reason —
|
|
56
|
+
* never throws.
|
|
57
|
+
*/
|
|
58
|
+
export async function checkForUpdate(
|
|
59
|
+
currentVersion: string,
|
|
60
|
+
opts: CheckForUpdateOpts = {},
|
|
61
|
+
): Promise<UpdateCheckResult | null> {
|
|
62
|
+
if (process.env.EMBERFLOW_UPDATE_CHECK === '0') return null;
|
|
63
|
+
const now = opts.now ?? Date.now;
|
|
64
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
65
|
+
if (cache && now() - cache.at < ttlMs) return cache.result;
|
|
66
|
+
|
|
67
|
+
const registry = opts.registry ?? process.env.EMBERFLOW_REGISTRY ?? DEFAULT_REGISTRY;
|
|
68
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
69
|
+
let result: UpdateCheckResult | null = null;
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchFn(`${registry}/${EMBERFLOW_PACKAGE}/latest`);
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
const body = (await res.json()) as { version?: unknown };
|
|
74
|
+
if (typeof body.version === 'string' && body.version.length > 0) {
|
|
75
|
+
result = {
|
|
76
|
+
current: currentVersion,
|
|
77
|
+
latest: body.version,
|
|
78
|
+
updateAvailable: newer(body.version, currentVersion),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
result = null; // fail-silent: network error, bad JSON, anything
|
|
84
|
+
}
|
|
85
|
+
cache = { at: now(), result };
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The running package's own version, read from its package.json. Two layouts,
|
|
91
|
+
* same probe as prompt.ts's EMBERFLOW_BIN: in the source repo this file is
|
|
92
|
+
* server/updateCheck.ts and the package root is one level up; in the shipped
|
|
93
|
+
* package it runs from dist/server/updateCheck.js and the root is two up.
|
|
94
|
+
*/
|
|
95
|
+
export function ownVersion(): string | null {
|
|
96
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
97
|
+
for (const root of [resolve(here, '..'), resolve(here, '../..')]) {
|
|
98
|
+
const file = join(root, 'package.json');
|
|
99
|
+
if (!existsSync(file)) continue;
|
|
100
|
+
try {
|
|
101
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown };
|
|
102
|
+
if (typeof pkg.version === 'string') return pkg.version;
|
|
103
|
+
} catch {
|
|
104
|
+
// malformed — try the next candidate
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True when the consumer's node_modules/@xdelivered/emberflow is a symlink —
|
|
112
|
+
* a `file:`/`npm link` dev setup that an npm install would clobber. POST
|
|
113
|
+
* /update refuses in that case.
|
|
114
|
+
*/
|
|
115
|
+
export function isLinkedInstall(projectRoot: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
return lstatSync(join(projectRoot, 'node_modules', '@xdelivered', 'emberflow')).isSymbolicLink();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type InstallResult = { ok: true } | { ok: false; error: string };
|
|
124
|
+
|
|
125
|
+
export interface RunNpmInstallOpts {
|
|
126
|
+
/** Injectable spawner so tests never actually npm install. */
|
|
127
|
+
spawnFn?: typeof spawn;
|
|
128
|
+
/** Kill-and-fail deadline (default 5min). */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Runs `npm install @xdelivered/emberflow@latest` in the project root via
|
|
134
|
+
* spawn (no shell). Captures the last ~4KB of combined output; on failure
|
|
135
|
+
* that tail is the error. Never rejects.
|
|
136
|
+
*/
|
|
137
|
+
export function runNpmInstall(projectRoot: string, opts: RunNpmInstallOpts = {}): Promise<InstallResult> {
|
|
138
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
139
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
140
|
+
return new Promise((resolveInstall) => {
|
|
141
|
+
let tail = '';
|
|
142
|
+
const append = (chunk: unknown): void => {
|
|
143
|
+
tail = (tail + String(chunk)).slice(-OUTPUT_TAIL_BYTES);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let child: ReturnType<typeof spawn>;
|
|
147
|
+
try {
|
|
148
|
+
child = spawnFn('npm', ['install', `${EMBERFLOW_PACKAGE}@latest`], {
|
|
149
|
+
cwd: projectRoot,
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
resolveInstall({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
child.stdout?.on('data', append);
|
|
157
|
+
child.stderr?.on('data', append);
|
|
158
|
+
|
|
159
|
+
let settled = false;
|
|
160
|
+
const settle = (result: InstallResult): void => {
|
|
161
|
+
if (settled) return;
|
|
162
|
+
settled = true;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolveInstall(result);
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
child.kill('SIGKILL');
|
|
168
|
+
settle({ ok: false, error: `npm install timed out after ${Math.round(timeoutMs / 1000)}s\n${tail}`.trim() });
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
child.on('error', (err) => settle({ ok: false, error: err.message }));
|
|
171
|
+
child.on('close', (code) =>
|
|
172
|
+
settle(code === 0 ? { ok: true } : { ok: false, error: tail.trim() || `npm install exited with code ${code}` }),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
package/src/App.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import { PanelBottomOpenIcon, PanelLeftOpenIcon, PanelRightOpenIcon, XIcon } from 'lucide-react';
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { FlameIcon, PanelBottomOpenIcon, PanelLeftOpenIcon, PanelRightOpenIcon, XIcon } from 'lucide-react';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
4
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
|
5
5
|
import { AgentConsole } from './components/AgentConsole';
|
|
6
|
+
import { CreateModalHost } from './components/CreateModal';
|
|
6
7
|
import { Dock } from './components/Dock';
|
|
8
|
+
import { EMPTY_STATE_DISMISSED_KEY, EmptyState } from './components/EmptyState';
|
|
7
9
|
import { Inspector } from './components/Inspector';
|
|
8
10
|
import { RunConsole, useRunConsole } from './components/RunLogPanel';
|
|
9
11
|
import { RunbookView } from './components/RunbookView';
|
|
@@ -68,9 +70,84 @@ function EdgeReopen({
|
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Calm, honest offline state. The workspace (operations, environments, runs) all
|
|
75
|
+
* come from the runner — with it down and no workspace ever adopted, there is
|
|
76
|
+
* nothing to show and nothing to execute, so we say so plainly instead of
|
|
77
|
+
* pretending an empty canvas is a project.
|
|
78
|
+
*/
|
|
79
|
+
function RunnerOfflinePanel() {
|
|
80
|
+
const checkRunner = useBuilderStore((s) => s.checkRunner);
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex h-full min-h-0 items-center justify-center p-8">
|
|
83
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center">
|
|
84
|
+
<FlameIcon className="size-8 text-muted-foreground/50" />
|
|
85
|
+
<h2 className="text-[15px] font-semibold tracking-tight">Runner offline</h2>
|
|
86
|
+
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
87
|
+
The studio is a pure client — your operations, environments and runs all
|
|
88
|
+
live on the runner. Start it, then this workspace fills in.
|
|
89
|
+
</p>
|
|
90
|
+
<code className="rounded-md border border-border bg-tertiary px-2.5 py-1.5 font-mono text-[12.5px] text-foreground">
|
|
91
|
+
npx emberflow dev
|
|
92
|
+
</code>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => void checkRunner()}
|
|
96
|
+
className="mt-1 rounded-md px-2.5 py-1 text-[12px] text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
|
|
97
|
+
>
|
|
98
|
+
Re-check
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
/** The center panel's content: the runbook document is the sole flow surface. */
|
|
72
106
|
function CenterView() {
|
|
73
107
|
const viewRegister = useBuilderStore((s) => s.viewRegister);
|
|
108
|
+
const runnerOnline = useBuilderStore((s) => s.runnerOnline);
|
|
109
|
+
const workspaceSource = useBuilderStore((s) => s.workspaceSource);
|
|
110
|
+
const setupStatus = useBuilderStore((s) => s.setupStatus);
|
|
111
|
+
const welcomeOpen = useBuilderStore((s) => s.welcomeOpen);
|
|
112
|
+
const setCreateModal = useBuilderStore((s) => s.setCreateModal);
|
|
113
|
+
const switchWorkflow = useBuilderStore((s) => s.switchWorkflow);
|
|
114
|
+
// Explicit dismissal of the post-onboarding empty state ("explore the
|
|
115
|
+
// example" path). Building a second op dismisses it implicitly — onlyHello
|
|
116
|
+
// stops matching once setupStatus refreshes (WelcomeDialog/StatusBar refetch
|
|
117
|
+
// it on agent-run finish), so no flag is written on that path.
|
|
118
|
+
const [emptyDismissed, setEmptyDismissed] = useState(
|
|
119
|
+
() => typeof localStorage !== 'undefined' && localStorage.getItem(EMPTY_STATE_DISMISSED_KEY) === '1',
|
|
120
|
+
);
|
|
121
|
+
// Offline AND no runner workspace ever adopted → the calm offline panel. Once
|
|
122
|
+
// a workspace has been adopted (workspaceSource === 'server'), a mid-session
|
|
123
|
+
// runner blip keeps showing the flow; the StatusBar carries the offline signal.
|
|
124
|
+
if (runnerOnline === false && workspaceSource !== 'server') {
|
|
125
|
+
return (
|
|
126
|
+
<div className="relative h-full min-h-0">
|
|
127
|
+
<RunnerOfflinePanel />
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// Post-onboarding: the bare hello-example project gets a clear starting point
|
|
132
|
+
// instead of someone else's op — hidden while the Welcome dialog still runs.
|
|
133
|
+
if (!welcomeOpen && !emptyDismissed && setupStatus?.ops.onlyHello) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="relative h-full min-h-0">
|
|
136
|
+
<EmptyState
|
|
137
|
+
status={setupStatus}
|
|
138
|
+
dismissed={emptyDismissed}
|
|
139
|
+
onCreate={() => setCreateModal({ mode: 'api' })}
|
|
140
|
+
onExplore={() => {
|
|
141
|
+
if (typeof localStorage !== 'undefined') {
|
|
142
|
+
localStorage.setItem(EMPTY_STATE_DISMISSED_KEY, '1');
|
|
143
|
+
}
|
|
144
|
+
setEmptyDismissed(true);
|
|
145
|
+
switchWorkflow('default/hello');
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
74
151
|
return (
|
|
75
152
|
<div className="relative h-full min-h-0">
|
|
76
153
|
<RunbookView register={viewRegister} />
|
|
@@ -228,6 +305,9 @@ export default function App() {
|
|
|
228
305
|
)}
|
|
229
306
|
</div>
|
|
230
307
|
<StatusBar />
|
|
308
|
+
{/* The one New API / New operation modal — hosted here (not in the
|
|
309
|
+
Sidebar) so the canvas empty state can open it with the sidebar closed. */}
|
|
310
|
+
<CreateModalHost />
|
|
231
311
|
</div>
|
|
232
312
|
);
|
|
233
313
|
}
|