@xdelivered/emberflow 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) 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 +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -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
  }