@os-eco/overstory-cli 0.9.3 → 0.10.3

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/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Action wrappers for the coordinator console REST endpoints.
3
+ *
4
+ * Thin functions consumed by `src/commands/serve/rest.ts` to drive the headless
5
+ * coordinator without going through tmux. Each function opens its own short-lived
6
+ * SQLite handles when no override is provided so the actions remain DI-friendly
7
+ * and never share long-lived stores with the request lifetime.
8
+ *
9
+ * The CLI command `ov coordinator start` is intentionally untouched — the new
10
+ * headless start path is gated behind `headless: true` in CoordinatorSessionOptions
11
+ * (see src/commands/coordinator.ts).
12
+ */
13
+
14
+ import { join } from "node:path";
15
+ import { AgentError, OverstoryError } from "../../errors.ts";
16
+ import { createMailClient } from "../../mail/client.ts";
17
+ import type { MailStore } from "../../mail/store.ts";
18
+ import { createMailStore } from "../../mail/store.ts";
19
+ import { getConnection } from "../../runtimes/connections.ts";
20
+ import type { SessionStore } from "../../sessions/store.ts";
21
+ import { createSessionStore } from "../../sessions/store.ts";
22
+ import type { MailMessage } from "../../types.ts";
23
+ import {
24
+ type CheckCompleteResult,
25
+ COORDINATOR_NAME,
26
+ checkComplete,
27
+ startCoordinatorSession,
28
+ stopCoordinatorSession,
29
+ } from "../coordinator.ts";
30
+
31
+ /**
32
+ * Raised when the coordinator is running in tmux mode and cannot be controlled
33
+ * from the headless web-UI surface. The REST layer maps this to HTTP 409.
34
+ *
35
+ * Defined inline (not in src/errors.ts) because adding error types is out of
36
+ * scope for the ui-coord-console-server slice (overstory-82b4). When/if a future
37
+ * task promotes ConflictError to a shared error type, this local class can be
38
+ * deleted in favor of an import.
39
+ */
40
+ export class ConflictError extends OverstoryError {
41
+ constructor(message: string, options?: ErrorOptions) {
42
+ super(message, "CONFLICT_ERROR", options);
43
+ this.name = "ConflictError";
44
+ }
45
+ }
46
+
47
+ // ─── Types ────────────────────────────────────────────────────────────────────
48
+
49
+ export interface CoordinatorState {
50
+ running: boolean;
51
+ agentName: string;
52
+ pid: number | null;
53
+ /** Empty string for headless coordinators is normalized to null. */
54
+ tmuxSession: string | null;
55
+ runId: string | null;
56
+ startedAt: string | null;
57
+ lastActivityAt: string | null;
58
+ /** True when a HeadlessClaudeConnection is registered for this agent. */
59
+ headless: boolean;
60
+ }
61
+
62
+ export interface CoordinatorActionDeps {
63
+ projectRoot: string;
64
+ /**
65
+ * Inject a SessionStore (closing is the caller's responsibility). When omitted,
66
+ * each action opens a short-lived store from `<projectRoot>/.overstory/sessions.db`.
67
+ */
68
+ _sessionStore?: SessionStore;
69
+ /**
70
+ * Inject a MailStore (closing is the caller's responsibility). When omitted,
71
+ * each action opens a short-lived store from `<projectRoot>/.overstory/mail.db`.
72
+ */
73
+ _mailStore?: MailStore;
74
+ /** Override the ask poll interval (default 500ms). Used by tests. */
75
+ _askPollIntervalMs?: number;
76
+ /**
77
+ * Override startCoordinatorSession (used by tests to avoid spawning a real
78
+ * coordinator process). Falls back to the live import when omitted.
79
+ */
80
+ _startCoordinatorSession?: typeof startCoordinatorSession;
81
+ /** Override stopCoordinatorSession (used by tests). Falls back to the live import. */
82
+ _stopCoordinatorSession?: typeof stopCoordinatorSession;
83
+ }
84
+
85
+ interface OpenedStores {
86
+ session: SessionStore;
87
+ mail: MailStore;
88
+ close: () => void;
89
+ }
90
+
91
+ const DEFAULT_ASK_POLL_INTERVAL_MS = 500;
92
+
93
+ function openStores(deps: CoordinatorActionDeps): OpenedStores {
94
+ if (deps._sessionStore !== undefined && deps._mailStore !== undefined) {
95
+ return {
96
+ session: deps._sessionStore,
97
+ mail: deps._mailStore,
98
+ close: () => {},
99
+ };
100
+ }
101
+ const ovDir = join(deps.projectRoot, ".overstory");
102
+ const session = deps._sessionStore ?? createSessionStore(join(ovDir, "sessions.db"));
103
+ const mail = deps._mailStore ?? createMailStore(join(ovDir, "mail.db"));
104
+ return {
105
+ session,
106
+ mail,
107
+ close: () => {
108
+ if (deps._sessionStore === undefined) session.close();
109
+ if (deps._mailStore === undefined) mail.close();
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Active means: a session row exists with a non-terminal state. Returns null
116
+ * for completed/zombie/missing sessions so callers can short-circuit.
117
+ */
118
+ function getActiveCoordinatorSession(
119
+ store: SessionStore,
120
+ ): import("../../types.ts").AgentSession | null {
121
+ const session = store.getByName(COORDINATOR_NAME);
122
+ if (session === null) return null;
123
+ if (session.state === "completed" || session.state === "zombie") return null;
124
+ return session;
125
+ }
126
+
127
+ // ─── State ────────────────────────────────────────────────────────────────────
128
+
129
+ /** Snapshot of the coordinator's live state. Cheap — single SQLite read. */
130
+ export function getCoordinatorState(deps: CoordinatorActionDeps): CoordinatorState {
131
+ const stores = openStores(deps);
132
+ try {
133
+ const session = getActiveCoordinatorSession(stores.session);
134
+ if (session === null) {
135
+ return {
136
+ running: false,
137
+ agentName: COORDINATOR_NAME,
138
+ pid: null,
139
+ tmuxSession: null,
140
+ runId: null,
141
+ startedAt: null,
142
+ lastActivityAt: null,
143
+ headless: false,
144
+ };
145
+ }
146
+ return {
147
+ running: true,
148
+ agentName: session.agentName,
149
+ pid: session.pid,
150
+ tmuxSession: session.tmuxSession === "" ? null : session.tmuxSession,
151
+ runId: session.runId,
152
+ startedAt: session.startedAt,
153
+ lastActivityAt: session.lastActivity,
154
+ headless: getConnection(COORDINATOR_NAME) !== undefined,
155
+ };
156
+ } finally {
157
+ stores.close();
158
+ }
159
+ }
160
+
161
+ // ─── Send ─────────────────────────────────────────────────────────────────────
162
+
163
+ interface SendOpts {
164
+ subject: string;
165
+ from?: string;
166
+ }
167
+
168
+ /**
169
+ * Send mail to the coordinator and immediately deliver via the headless
170
+ * connection's followUp() when one is registered.
171
+ *
172
+ * Throws AgentError when no active coordinator session exists.
173
+ * Throws ConflictError when the active session is tmux-only (tmuxSession !== "")
174
+ * and no headless connection is registered — the operator must fall back to
175
+ * `ov coordinator send` from the shell.
176
+ */
177
+ export async function sendToCoordinator(
178
+ deps: CoordinatorActionDeps,
179
+ body: string,
180
+ opts: SendOpts,
181
+ ): Promise<{ messageId: string }> {
182
+ const stores = openStores(deps);
183
+ try {
184
+ const session = getActiveCoordinatorSession(stores.session);
185
+ if (session === null) {
186
+ throw new AgentError("Coordinator is not running", { agentName: COORDINATOR_NAME });
187
+ }
188
+
189
+ const conn = getConnection(COORDINATOR_NAME);
190
+ if (conn === undefined && session.tmuxSession !== "") {
191
+ throw new ConflictError(
192
+ "Coordinator is tmux-only — use 'ov coordinator send' from the shell",
193
+ );
194
+ }
195
+
196
+ const from = opts.from ?? "operator";
197
+ const mailClient = createMailClient(stores.mail);
198
+ const messageId = mailClient.send({
199
+ from,
200
+ to: COORDINATOR_NAME,
201
+ subject: opts.subject,
202
+ body,
203
+ type: "dispatch",
204
+ priority: "normal",
205
+ });
206
+
207
+ // When a headless connection is registered the mail-injection loop
208
+ // (installMailInjectors() in serve.ts) picks the row up automatically;
209
+ // no explicit followUp() is required. We intentionally do not duplicate
210
+ // the followUp here to keep delivery semantics single-sourced.
211
+ return { messageId };
212
+ } finally {
213
+ stores.close();
214
+ }
215
+ }
216
+
217
+ // ─── Ask ──────────────────────────────────────────────────────────────────────
218
+
219
+ interface AskOpts {
220
+ subject: string;
221
+ from?: string;
222
+ timeoutSec: number;
223
+ }
224
+
225
+ interface AskResult {
226
+ messageId: string;
227
+ reply: { id: string; body: string; subject: string } | null;
228
+ timedOut: boolean;
229
+ }
230
+
231
+ /**
232
+ * Send a synchronous request to the coordinator and poll the mail thread for
233
+ * a correlated reply.
234
+ *
235
+ * Returns `{ reply: null, timedOut: true }` when the deadline expires. Same
236
+ * tmux/headless rejection rules as sendToCoordinator.
237
+ */
238
+ export async function askCoordinatorAction(
239
+ deps: CoordinatorActionDeps,
240
+ body: string,
241
+ opts: AskOpts,
242
+ ): Promise<AskResult> {
243
+ const stores = openStores(deps);
244
+ try {
245
+ const session = getActiveCoordinatorSession(stores.session);
246
+ if (session === null) {
247
+ throw new AgentError("Coordinator is not running", { agentName: COORDINATOR_NAME });
248
+ }
249
+
250
+ const conn = getConnection(COORDINATOR_NAME);
251
+ if (conn === undefined && session.tmuxSession !== "") {
252
+ throw new ConflictError("Coordinator is tmux-only — use 'ov coordinator ask' from the shell");
253
+ }
254
+
255
+ const from = opts.from ?? "operator";
256
+ const correlationId = crypto.randomUUID();
257
+ const mailClient = createMailClient(stores.mail);
258
+ const messageId = mailClient.send({
259
+ from,
260
+ to: COORDINATOR_NAME,
261
+ subject: opts.subject,
262
+ body,
263
+ type: "dispatch",
264
+ priority: "normal",
265
+ payload: JSON.stringify({ correlationId }),
266
+ });
267
+
268
+ const pollIntervalMs = deps._askPollIntervalMs ?? DEFAULT_ASK_POLL_INTERVAL_MS;
269
+ const deadline = Date.now() + opts.timeoutSec * 1000;
270
+ while (Date.now() < deadline) {
271
+ await Bun.sleep(pollIntervalMs);
272
+ const replies: MailMessage[] = stores.mail.getByThread(messageId);
273
+ const reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === from);
274
+ if (reply !== undefined) {
275
+ return {
276
+ messageId,
277
+ reply: { id: reply.id, body: reply.body, subject: reply.subject },
278
+ timedOut: false,
279
+ };
280
+ }
281
+ }
282
+
283
+ return { messageId, reply: null, timedOut: true };
284
+ } finally {
285
+ stores.close();
286
+ }
287
+ }
288
+
289
+ // ─── Check complete ───────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Wraps the existing checkComplete() function. Returns the structured result
293
+ * without printing anything to stdout — suitable for HTTP responses.
294
+ */
295
+ export async function checkCoordinatorComplete(
296
+ _deps: CoordinatorActionDeps,
297
+ ): Promise<CheckCompleteResult> {
298
+ return await checkComplete({ json: true });
299
+ }
300
+
301
+ // ─── Start headless ───────────────────────────────────────────────────────────
302
+
303
+ interface StartHeadlessResult {
304
+ started: boolean;
305
+ alreadyRunning: boolean;
306
+ pid: number | null;
307
+ runId: string | null;
308
+ }
309
+
310
+ /**
311
+ * Start the coordinator in headless mode (no tmux). Idempotent: when a
312
+ * coordinator session is already active (either tmux or headless), returns
313
+ * `{ started: false, alreadyRunning: true }` instead of throwing.
314
+ */
315
+ export async function startCoordinatorHeadless(
316
+ deps: CoordinatorActionDeps,
317
+ ): Promise<StartHeadlessResult> {
318
+ // If a session is already active, short-circuit before spawning.
319
+ const stores = openStores(deps);
320
+ let preExisting: import("../../types.ts").AgentSession | null;
321
+ try {
322
+ preExisting = getActiveCoordinatorSession(stores.session);
323
+ } finally {
324
+ stores.close();
325
+ }
326
+ if (preExisting !== null) {
327
+ return {
328
+ started: false,
329
+ alreadyRunning: true,
330
+ pid: preExisting.pid,
331
+ runId: preExisting.runId,
332
+ };
333
+ }
334
+
335
+ const start = deps._startCoordinatorSession ?? startCoordinatorSession;
336
+ try {
337
+ await start({
338
+ json: true,
339
+ attach: false,
340
+ watchdog: false,
341
+ monitor: false,
342
+ headless: true,
343
+ });
344
+ } catch (err) {
345
+ // Race: another caller spawned the coordinator between our preflight check
346
+ // and start(). Translate to an idempotent response rather than a 500.
347
+ if (err instanceof AgentError && /already running/i.test(err.message)) {
348
+ const stores2 = openStores(deps);
349
+ try {
350
+ const existing = getActiveCoordinatorSession(stores2.session);
351
+ return {
352
+ started: false,
353
+ alreadyRunning: true,
354
+ pid: existing?.pid ?? null,
355
+ runId: existing?.runId ?? null,
356
+ };
357
+ } finally {
358
+ stores2.close();
359
+ }
360
+ }
361
+ throw err;
362
+ }
363
+
364
+ // Read back the freshly-created session so the caller can correlate.
365
+ const stores2 = openStores(deps);
366
+ try {
367
+ const created = getActiveCoordinatorSession(stores2.session);
368
+ return {
369
+ started: true,
370
+ alreadyRunning: false,
371
+ pid: created?.pid ?? null,
372
+ runId: created?.runId ?? null,
373
+ };
374
+ } finally {
375
+ stores2.close();
376
+ }
377
+ }
378
+
379
+ // ─── Stop ─────────────────────────────────────────────────────────────────────
380
+
381
+ /**
382
+ * Stop the coordinator (works for both tmux and headless sessions).
383
+ * Returns `{ stopped: false }` when no active coordinator was found.
384
+ */
385
+ export async function stopCoordinator(deps: CoordinatorActionDeps): Promise<{ stopped: boolean }> {
386
+ const stores = openStores(deps);
387
+ let session: import("../../types.ts").AgentSession | null;
388
+ try {
389
+ session = getActiveCoordinatorSession(stores.session);
390
+ } finally {
391
+ stores.close();
392
+ }
393
+ if (session === null) {
394
+ return { stopped: false };
395
+ }
396
+
397
+ const stop = deps._stopCoordinatorSession ?? stopCoordinatorSession;
398
+ try {
399
+ await stop({ json: true });
400
+ } catch (err) {
401
+ if (err instanceof AgentError) {
402
+ // Race: someone else stopped the session between our check and the call.
403
+ return { stopped: false };
404
+ }
405
+ throw err;
406
+ }
407
+ return { stopped: true };
408
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Tests for startDevServer.
3
+ *
4
+ * Bun.spawn is fully stubbed — no real subprocess is launched. We capture the
5
+ * argv, cwd, and env passed to spawn, and verify stop() drives the lifecycle
6
+ * (SIGTERM → exited; SIGKILL fallback after timeout).
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { startDevServer } from "./dev.ts";
11
+
12
+ interface FakeHandle {
13
+ killCalls: string[];
14
+ exited: Promise<number>;
15
+ kill: (sig?: string) => void;
16
+ stdout: ReadableStream<Uint8Array>;
17
+ stderr: ReadableStream<Uint8Array>;
18
+ resolveExit: (code: number) => void;
19
+ }
20
+
21
+ function makeFakeHandle(): FakeHandle {
22
+ const killCalls: string[] = [];
23
+ let resolveExit!: (code: number) => void;
24
+ const exited = new Promise<number>((r) => {
25
+ resolveExit = r;
26
+ });
27
+ // Empty streams that close immediately.
28
+ const empty = (): ReadableStream<Uint8Array> =>
29
+ new ReadableStream<Uint8Array>({
30
+ start(controller) {
31
+ controller.close();
32
+ },
33
+ });
34
+ return {
35
+ killCalls,
36
+ exited,
37
+ kill: (sig?: string) => {
38
+ killCalls.push(sig ?? "SIGTERM");
39
+ },
40
+ stdout: empty(),
41
+ stderr: empty(),
42
+ resolveExit,
43
+ };
44
+ }
45
+
46
+ interface SpawnCall {
47
+ cmd: string[];
48
+ cwd: string | undefined;
49
+ env: Record<string, string> | undefined;
50
+ }
51
+
52
+ function makeFakeSpawn(handle: FakeHandle): {
53
+ spawn: typeof Bun.spawn;
54
+ calls: SpawnCall[];
55
+ } {
56
+ const calls: SpawnCall[] = [];
57
+ const fake = ((cmd: string[], options?: { cwd?: string; env?: Record<string, string> }) => {
58
+ calls.push({ cmd, cwd: options?.cwd, env: options?.env });
59
+ return handle;
60
+ }) as unknown as typeof Bun.spawn;
61
+ return { spawn: fake, calls };
62
+ }
63
+
64
+ describe("startDevServer", () => {
65
+ test("spawns ['bun', '--hot', './dev-server.ts'] in uiDir with OVERSTORY_* env", async () => {
66
+ const handle = makeFakeHandle();
67
+ const { spawn, calls } = makeFakeSpawn(handle);
68
+
69
+ const dev = await startDevServer({
70
+ uiDir: "/tmp/ui",
71
+ port: 3500,
72
+ apiPort: 9090,
73
+ apiHost: "0.0.0.0",
74
+ _spawn: spawn,
75
+ log: () => {},
76
+ });
77
+
78
+ expect(calls.length).toBe(1);
79
+ expect(calls[0]?.cmd).toEqual(["bun", "--hot", "./dev-server.ts"]);
80
+ expect(calls[0]?.cwd).toBe("/tmp/ui");
81
+ expect(calls[0]?.env?.OVERSTORY_DEV_PORT).toBe("3500");
82
+ expect(calls[0]?.env?.OVERSTORY_API_PORT).toBe("9090");
83
+ expect(calls[0]?.env?.OVERSTORY_API_HOST).toBe("0.0.0.0");
84
+ // Inherits process.env (PATH should usually be present).
85
+ expect(typeof calls[0]?.env?.PATH === "string" || calls[0]?.env?.PATH === undefined).toBe(true);
86
+
87
+ expect(dev.port).toBe(3500);
88
+
89
+ // Clean up: let stop drive resolveExit so the dangling promise settles.
90
+ handle.resolveExit(0);
91
+ await dev.stop();
92
+ });
93
+
94
+ test("defaults apiPort to the ov serve default and apiHost to 127.0.0.1", async () => {
95
+ const handle = makeFakeHandle();
96
+ const { spawn, calls } = makeFakeSpawn(handle);
97
+
98
+ const dev = await startDevServer({
99
+ uiDir: "/tmp/ui",
100
+ port: 3000,
101
+ _spawn: spawn,
102
+ log: () => {},
103
+ });
104
+
105
+ expect(calls[0]?.env?.OVERSTORY_API_PORT).toBe("7321");
106
+ expect(calls[0]?.env?.OVERSTORY_API_HOST).toBe("127.0.0.1");
107
+
108
+ handle.resolveExit(0);
109
+ await dev.stop();
110
+ });
111
+
112
+ test("stop() sends SIGTERM and resolves once the process exits", async () => {
113
+ const handle = makeFakeHandle();
114
+ const { spawn } = makeFakeSpawn(handle);
115
+
116
+ const dev = await startDevServer({
117
+ uiDir: "/tmp/ui",
118
+ port: 3000,
119
+ _spawn: spawn,
120
+ log: () => {},
121
+ });
122
+
123
+ // Resolve exited soon after stop() is called.
124
+ setTimeout(() => handle.resolveExit(0), 10);
125
+ await dev.stop();
126
+
127
+ expect(handle.killCalls.length).toBeGreaterThanOrEqual(1);
128
+ expect(handle.killCalls[0]).toBe("SIGTERM");
129
+ // No SIGKILL because the process exited within the 5s timeout.
130
+ expect(handle.killCalls.includes("SIGKILL")).toBe(false);
131
+ });
132
+
133
+ test("stop() escalates to SIGKILL when the process ignores SIGTERM", async () => {
134
+ const handle = makeFakeHandle();
135
+ const { spawn } = makeFakeSpawn(handle);
136
+
137
+ // Patch the timeout so the test doesn't actually wait 5s.
138
+ const realSetTimeout = globalThis.setTimeout;
139
+ globalThis.setTimeout = ((fn: () => void, _ms?: number) =>
140
+ realSetTimeout(fn, 0)) as unknown as typeof setTimeout;
141
+
142
+ try {
143
+ const dev = await startDevServer({
144
+ uiDir: "/tmp/ui",
145
+ port: 3000,
146
+ _spawn: spawn,
147
+ log: () => {},
148
+ });
149
+
150
+ // Resolve exited only after SIGKILL has been issued.
151
+ let killed = false;
152
+ const origKill = handle.kill;
153
+ handle.kill = (sig?: string) => {
154
+ origKill(sig);
155
+ if (sig === "SIGKILL") {
156
+ killed = true;
157
+ handle.resolveExit(137);
158
+ }
159
+ };
160
+
161
+ await dev.stop();
162
+ expect(killed).toBe(true);
163
+ expect(handle.killCalls).toEqual(["SIGTERM", "SIGKILL"]);
164
+ } finally {
165
+ globalThis.setTimeout = realSetTimeout;
166
+ }
167
+ });
168
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Dev-server launcher for `ov serve --dev`.
3
+ *
4
+ * Spawns ui/dev-server.ts (a Bun.serve script with HMR + API/WS proxy) in the
5
+ * ui/ workspace and exposes a stop() handle for graceful shutdown. The dev
6
+ * server itself proxies /api/* and /ws back to the main `ov serve` process.
7
+ */
8
+
9
+ import { DEFAULT_SERVE_PORT } from "../serve.ts";
10
+
11
+ export interface DevServerHandle {
12
+ port: number;
13
+ stop: () => Promise<void>;
14
+ }
15
+
16
+ export interface StartDevServerOptions {
17
+ uiDir: string;
18
+ port: number;
19
+ apiPort?: number;
20
+ apiHost?: string;
21
+ log?: (msg: string) => void;
22
+ _spawn?: typeof Bun.spawn;
23
+ }
24
+
25
+ /** Default sink: write each line to process.stderr with a "[ui-dev] " prefix. */
26
+ function defaultLog(line: string): void {
27
+ process.stderr.write(`[ui-dev] ${line}\n`);
28
+ }
29
+
30
+ /**
31
+ * Read a Bun subprocess stream line-by-line and forward each line to sink.
32
+ * The trailing partial line (no newline) is flushed when the stream closes.
33
+ */
34
+ async function pipeLines(
35
+ stream: ReadableStream<Uint8Array> | null,
36
+ sink: (line: string) => void,
37
+ ): Promise<void> {
38
+ if (stream === null) return;
39
+ const decoder = new TextDecoder();
40
+ let buf = "";
41
+ const reader = stream.getReader();
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ buf += decoder.decode(value, { stream: true });
47
+ let idx = buf.indexOf("\n");
48
+ while (idx !== -1) {
49
+ sink(buf.slice(0, idx));
50
+ buf = buf.slice(idx + 1);
51
+ idx = buf.indexOf("\n");
52
+ }
53
+ }
54
+ if (buf.length > 0) sink(buf);
55
+ } finally {
56
+ reader.releaseLock();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Build a string-only environment from process.env plus OVERSTORY_* vars,
62
+ * dropping entries whose value is undefined (process.env's declared type).
63
+ */
64
+ function buildEnv(extra: Record<string, string>): Record<string, string> {
65
+ const env: Record<string, string> = {};
66
+ for (const [k, v] of Object.entries(process.env)) {
67
+ if (typeof v === "string") env[k] = v;
68
+ }
69
+ for (const [k, v] of Object.entries(extra)) env[k] = v;
70
+ return env;
71
+ }
72
+
73
+ export async function startDevServer(opts: StartDevServerOptions): Promise<DevServerHandle> {
74
+ const spawn = opts._spawn ?? Bun.spawn;
75
+ const log = opts.log ?? defaultLog;
76
+
77
+ const env = buildEnv({
78
+ OVERSTORY_DEV_PORT: String(opts.port),
79
+ OVERSTORY_API_PORT: String(opts.apiPort ?? DEFAULT_SERVE_PORT),
80
+ OVERSTORY_API_HOST: opts.apiHost ?? "127.0.0.1",
81
+ });
82
+
83
+ const child = spawn(["bun", "--hot", "./dev-server.ts"], {
84
+ cwd: opts.uiDir,
85
+ env,
86
+ stdout: "pipe",
87
+ stderr: "pipe",
88
+ });
89
+
90
+ // Pipe child output asynchronously; we don't await these — they resolve
91
+ // when the streams close (i.e., on subprocess exit).
92
+ void pipeLines(child.stdout as ReadableStream<Uint8Array> | null, log);
93
+ void pipeLines(child.stderr as ReadableStream<Uint8Array> | null, log);
94
+
95
+ const stop = async (): Promise<void> => {
96
+ try {
97
+ child.kill("SIGTERM");
98
+ } catch {
99
+ // Already exited.
100
+ }
101
+ const timeoutMs = 5000;
102
+ const timeout = new Promise<"timeout">((resolve) =>
103
+ setTimeout(() => resolve("timeout"), timeoutMs),
104
+ );
105
+ const result = await Promise.race([child.exited.then(() => "exited" as const), timeout]);
106
+ if (result === "timeout") {
107
+ try {
108
+ child.kill("SIGKILL");
109
+ } catch {
110
+ // Already exited.
111
+ }
112
+ await child.exited;
113
+ }
114
+ };
115
+
116
+ return { port: opts.port, stop };
117
+ }