@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.
Files changed (88) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -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: opts.onLog
76
- ? {
77
- onLog: (line) =>
78
- opts.onLog!({
79
- ...line,
80
- nodeLabel: `${childFlow.name} › ${line.nodeLabel ?? ''}`,
81
- nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
82
- }),
83
- }
84
- : undefined,
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
- const childRun = await childHandle.runToEnd();
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
+ }
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  // the harness in environmentsRoute.test.ts / runsAuth.test.ts.
13
13
 
14
14
  let proc: ChildProcess;
15
- const PORT = 8137;
15
+ const PORT = 8156;
16
16
  const base = `http://127.0.0.1:${PORT}`;
17
17
  let projectDir: string;
18
18
 
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
  }