@os-eco/overstory-cli 0.9.4 → 0.11.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/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createMailClient } from "../mail/client.ts";
|
|
6
|
+
import { createMailStore } from "../mail/store.ts";
|
|
7
|
+
import type { DevServerHandle } from "./serve/dev.ts";
|
|
8
|
+
import {
|
|
9
|
+
_resetHandlers,
|
|
10
|
+
createServeServer,
|
|
11
|
+
installMailInjectors,
|
|
12
|
+
registerApiHandler,
|
|
13
|
+
registerWsHandler,
|
|
14
|
+
resolveUiDistPath,
|
|
15
|
+
runServe,
|
|
16
|
+
} from "./serve.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tests use createServeServer() directly to avoid binding to process SIGINT/SIGTERM.
|
|
20
|
+
* Each test binds to a random free port (port: 0) to avoid conflicts.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
describe("createServeServer", () => {
|
|
24
|
+
let tempDir: string;
|
|
25
|
+
let servers: ReturnType<typeof Bun.serve>[] = [];
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-serve-test-"));
|
|
29
|
+
_resetHandlers();
|
|
30
|
+
|
|
31
|
+
// Create minimal .overstory/config.yaml so loadConfig doesn't fail
|
|
32
|
+
mkdirSync(join(tempDir, ".overstory"), { recursive: true });
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
35
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
for (const srv of servers) {
|
|
41
|
+
srv.stop(true);
|
|
42
|
+
}
|
|
43
|
+
servers = [];
|
|
44
|
+
_resetHandlers();
|
|
45
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function startServer(
|
|
49
|
+
opts: {
|
|
50
|
+
port?: number;
|
|
51
|
+
host?: string;
|
|
52
|
+
resolveUiDistPath?: ((projectRoot: string) => string) | "default";
|
|
53
|
+
} = {},
|
|
54
|
+
): Promise<ReturnType<typeof Bun.serve>> {
|
|
55
|
+
const origCwd = process.cwd;
|
|
56
|
+
// Swap cwd so loadConfig resolves to tempDir
|
|
57
|
+
process.cwd = () => tempDir;
|
|
58
|
+
// Default to project-relative ui/dist so the package-bundled fallback
|
|
59
|
+
// (which exists in this dev repo) doesn't leak into tests that assert
|
|
60
|
+
// "no UI" semantics. Pass "default" to opt into the production resolver.
|
|
61
|
+
const resolveUiDist =
|
|
62
|
+
opts.resolveUiDistPath === "default"
|
|
63
|
+
? undefined
|
|
64
|
+
: (opts.resolveUiDistPath ?? ((root: string): string => join(root, "ui", "dist")));
|
|
65
|
+
const server = await createServeServer(
|
|
66
|
+
{ port: opts.port ?? 0, host: opts.host ?? "127.0.0.1" },
|
|
67
|
+
{
|
|
68
|
+
_restDeps: false,
|
|
69
|
+
...(resolveUiDist ? { _resolveUiDistPath: resolveUiDist } : {}),
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
process.cwd = origCwd;
|
|
73
|
+
servers.push(server);
|
|
74
|
+
return server;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("/healthz returns success JSON", async () => {
|
|
78
|
+
const server = await startServer();
|
|
79
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/healthz`);
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const body = (await res.json()) as { success: boolean; data?: { status: string } };
|
|
82
|
+
expect(body.success).toBe(true);
|
|
83
|
+
expect(body.data?.status).toBe("ok");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("/healthz Content-Type is application/json", async () => {
|
|
87
|
+
const server = await startServer();
|
|
88
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/healthz`);
|
|
89
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("/api/* with no handlers returns 404 JSON", async () => {
|
|
93
|
+
const server = await startServer();
|
|
94
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/api/foo`);
|
|
95
|
+
expect(res.status).toBe(404);
|
|
96
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
97
|
+
expect(body.success).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("registerApiHandler intercepts /api/* requests", async () => {
|
|
101
|
+
registerApiHandler((req) => {
|
|
102
|
+
const url = new URL(req.url);
|
|
103
|
+
if (url.pathname === "/api/ping") {
|
|
104
|
+
return new Response(JSON.stringify({ pong: true }), {
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const server = await startServer();
|
|
112
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/api/ping`);
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
115
|
+
expect(body.pong).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("multiple API handlers: first match wins", async () => {
|
|
119
|
+
registerApiHandler(() => null); // pass-through
|
|
120
|
+
registerApiHandler((req) => {
|
|
121
|
+
const url = new URL(req.url);
|
|
122
|
+
if (url.pathname === "/api/second") {
|
|
123
|
+
return new Response("second", { status: 200 });
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const server = await startServer();
|
|
129
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/api/second`);
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
const text = await res.text();
|
|
132
|
+
expect(text).toBe("second");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("static files: 503 when ui/dist missing", async () => {
|
|
136
|
+
const server = await startServer();
|
|
137
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/`);
|
|
138
|
+
expect(res.status).toBe(503);
|
|
139
|
+
// Now returns JSON envelope instead of plain text
|
|
140
|
+
const ct = res.headers.get("content-type");
|
|
141
|
+
expect(ct).toContain("application/json");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("static files: serves index.html when present", async () => {
|
|
145
|
+
mkdirSync(join(tempDir, "ui", "dist"), { recursive: true });
|
|
146
|
+
writeFileSync(join(tempDir, "ui", "dist", "index.html"), "<html>app</html>");
|
|
147
|
+
|
|
148
|
+
const server = await startServer();
|
|
149
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/`);
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
const body = await res.text();
|
|
152
|
+
expect(body).toContain("app");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("static files: SPA fallback returns index.html for unknown paths", async () => {
|
|
156
|
+
mkdirSync(join(tempDir, "ui", "dist"), { recursive: true });
|
|
157
|
+
writeFileSync(join(tempDir, "ui", "dist", "index.html"), "<html>spa</html>");
|
|
158
|
+
|
|
159
|
+
const server = await startServer();
|
|
160
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/some/deep/route`);
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const body = await res.text();
|
|
163
|
+
expect(body).toContain("spa");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("static files: serves named asset files", async () => {
|
|
167
|
+
mkdirSync(join(tempDir, "ui", "dist", "assets"), { recursive: true });
|
|
168
|
+
writeFileSync(join(tempDir, "ui", "dist", "assets", "main.js"), 'console.log("hi")');
|
|
169
|
+
writeFileSync(join(tempDir, "ui", "dist", "index.html"), "<html></html>");
|
|
170
|
+
|
|
171
|
+
const server = await startServer();
|
|
172
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/assets/main.js`);
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("/ws without handler returns 404", async () => {
|
|
177
|
+
const server = await startServer();
|
|
178
|
+
// Non-upgrade request to /ws should return 404
|
|
179
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/ws`);
|
|
180
|
+
expect(res.status).toBe(404);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("resolveUiDistPath: prefers project ui/dist when present", () => {
|
|
184
|
+
const projectDist = join(tempDir, "ui", "dist");
|
|
185
|
+
mkdirSync(projectDist, { recursive: true });
|
|
186
|
+
expect(resolveUiDistPath(tempDir)).toBe(projectDist);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("resolveUiDistPath: falls back to package-bundled ui/dist when project has no ui/", () => {
|
|
190
|
+
// tempDir has no ui/ — simulates fresh `ov init` (overstory-916d).
|
|
191
|
+
const resolved = resolveUiDistPath(tempDir);
|
|
192
|
+
expect(resolved).not.toBe(join(tempDir, "ui", "dist"));
|
|
193
|
+
// Resolves to the dev repo's own ui/dist (or wherever the package lives).
|
|
194
|
+
expect(resolved.endsWith("/ui/dist")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("static files: falls back to package-bundled ui/dist when project has no ui/", async () => {
|
|
198
|
+
// No project ui/dist — use the production resolver so the package fallback is exercised.
|
|
199
|
+
const server = await startServer({ resolveUiDistPath: "default" });
|
|
200
|
+
// The dev repo's ui/dist exists, so we get a real index.html (200), not 503.
|
|
201
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/`);
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("registerWsHandler replaces previous handler", () => {
|
|
206
|
+
const handler1 = { open: () => {} };
|
|
207
|
+
const handler2 = { open: () => {} };
|
|
208
|
+
registerWsHandler(handler1);
|
|
209
|
+
registerWsHandler(handler2);
|
|
210
|
+
// No assertion needed — just validates it doesn't throw
|
|
211
|
+
// The ws handler is exercised via integration if ws tests are added
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("installMailInjectors", () => {
|
|
216
|
+
let tempDir: string;
|
|
217
|
+
let overstoryDir: string;
|
|
218
|
+
let mailDbPath: string;
|
|
219
|
+
const stoppers: Array<() => void> = [];
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-mailinject-test-"));
|
|
223
|
+
overstoryDir = join(tempDir, ".overstory");
|
|
224
|
+
mkdirSync(overstoryDir, { recursive: true });
|
|
225
|
+
mailDbPath = join(overstoryDir, "mail.db");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(async () => {
|
|
229
|
+
for (const stop of stoppers.splice(0)) stop();
|
|
230
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("dispatches task-scoped agents to runTurn (SessionStore-driven discovery)", async () => {
|
|
234
|
+
const { createSessionStore } = await import("../sessions/store.ts");
|
|
235
|
+
const sessionsDbPath = join(overstoryDir, "sessions.db");
|
|
236
|
+
const sessionStore = createSessionStore(sessionsDbPath);
|
|
237
|
+
sessionStore.upsert({
|
|
238
|
+
id: "session-build-1",
|
|
239
|
+
agentName: "build-agent",
|
|
240
|
+
capability: "builder",
|
|
241
|
+
worktreePath: "/tmp/wt",
|
|
242
|
+
branchName: "overstory/build-agent/task-1",
|
|
243
|
+
taskId: "task-1",
|
|
244
|
+
tmuxSession: "",
|
|
245
|
+
state: "working",
|
|
246
|
+
pid: null,
|
|
247
|
+
parentAgent: "lead-1",
|
|
248
|
+
depth: 1,
|
|
249
|
+
runId: null,
|
|
250
|
+
startedAt: new Date().toISOString(),
|
|
251
|
+
lastActivity: new Date().toISOString(),
|
|
252
|
+
escalationLevel: 0,
|
|
253
|
+
stalledSince: null,
|
|
254
|
+
transcriptPath: null,
|
|
255
|
+
});
|
|
256
|
+
sessionStore.close();
|
|
257
|
+
|
|
258
|
+
const store = createMailStore(mailDbPath);
|
|
259
|
+
const client = createMailClient(store);
|
|
260
|
+
client.send({
|
|
261
|
+
from: "lead",
|
|
262
|
+
to: "build-agent",
|
|
263
|
+
subject: "Dispatch",
|
|
264
|
+
body: "Begin work.",
|
|
265
|
+
type: "dispatch",
|
|
266
|
+
priority: "normal",
|
|
267
|
+
});
|
|
268
|
+
store.close();
|
|
269
|
+
|
|
270
|
+
let runTurnCalled = false;
|
|
271
|
+
let observedNdjson: string | undefined;
|
|
272
|
+
|
|
273
|
+
const dispatch = {
|
|
274
|
+
config: {
|
|
275
|
+
project: { name: "x", root: tempDir, canonicalBranch: "main" },
|
|
276
|
+
agents: {
|
|
277
|
+
baseDir: "agents",
|
|
278
|
+
manifestPath: ".overstory/agent-manifest.json",
|
|
279
|
+
maxConcurrent: 5,
|
|
280
|
+
maxSessionsPerRun: 0,
|
|
281
|
+
maxAgentsPerLead: 5,
|
|
282
|
+
maxDepth: 2,
|
|
283
|
+
staggerDelayMs: 0,
|
|
284
|
+
autoNudgeOnMail: false,
|
|
285
|
+
},
|
|
286
|
+
worktrees: { baseDir: ".overstory/worktrees" },
|
|
287
|
+
merge: { mode: "manual" },
|
|
288
|
+
mulch: { enabled: false, domains: {} },
|
|
289
|
+
canopy: { enabled: false },
|
|
290
|
+
taskTracker: { backend: "seeds", enabled: true },
|
|
291
|
+
watchdog: {
|
|
292
|
+
tier0Enabled: false,
|
|
293
|
+
tier0IntervalMs: 30_000,
|
|
294
|
+
tier1Enabled: false,
|
|
295
|
+
maxEscalationLevel: 3,
|
|
296
|
+
},
|
|
297
|
+
models: {},
|
|
298
|
+
logging: { verbose: false, redactSecrets: true },
|
|
299
|
+
runtime: { default: "claude" },
|
|
300
|
+
providers: {},
|
|
301
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal config shape for the test path
|
|
302
|
+
} as any,
|
|
303
|
+
manifest: {
|
|
304
|
+
version: "1",
|
|
305
|
+
agents: {
|
|
306
|
+
builder: {
|
|
307
|
+
file: "builder.md",
|
|
308
|
+
model: "claude-sonnet",
|
|
309
|
+
tools: [],
|
|
310
|
+
capabilities: ["build"],
|
|
311
|
+
canSpawn: false,
|
|
312
|
+
constraints: [],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
capabilityIndex: { build: ["builder"] },
|
|
316
|
+
},
|
|
317
|
+
_runTurnFn: async (opts: import("../agents/turn-runner.ts").RunTurnOpts) => {
|
|
318
|
+
runTurnCalled = true;
|
|
319
|
+
observedNdjson = opts.userTurnNdjson;
|
|
320
|
+
return {
|
|
321
|
+
exitCode: 0,
|
|
322
|
+
cleanResult: true,
|
|
323
|
+
newSessionId: null,
|
|
324
|
+
resumeMismatch: false,
|
|
325
|
+
terminalMailObserved: false,
|
|
326
|
+
durationMs: 1,
|
|
327
|
+
initialState: "booting" as const,
|
|
328
|
+
finalState: "working" as const,
|
|
329
|
+
stallAborted: false,
|
|
330
|
+
terminalMailMissing: false,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const stop = installMailInjectors(mailDbPath, overstoryDir, dispatch);
|
|
336
|
+
stoppers.push(stop);
|
|
337
|
+
|
|
338
|
+
// Allow several poll ticks so the dispatcher batches the unread mail
|
|
339
|
+
// and routes it through runTurn instead of the FIFO writer.
|
|
340
|
+
await new Promise((r) => setTimeout(r, 2400));
|
|
341
|
+
|
|
342
|
+
expect(runTurnCalled).toBe(true);
|
|
343
|
+
expect(observedNdjson).toBeDefined();
|
|
344
|
+
const parsed = JSON.parse(observedNdjson?.trimEnd() ?? "");
|
|
345
|
+
expect(parsed.type).toBe("user");
|
|
346
|
+
expect(parsed.message.content[0].text).toContain("Begin work.");
|
|
347
|
+
}, 8000);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("runServe auto-build + dev wiring", () => {
|
|
351
|
+
let tempDir: string;
|
|
352
|
+
let origCwd: typeof process.cwd;
|
|
353
|
+
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-runserve-test-"));
|
|
356
|
+
_resetHandlers();
|
|
357
|
+
mkdirSync(join(tempDir, ".overstory"), { recursive: true });
|
|
358
|
+
writeFileSync(
|
|
359
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
360
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
361
|
+
);
|
|
362
|
+
origCwd = process.cwd;
|
|
363
|
+
process.cwd = () => tempDir;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
afterEach(() => {
|
|
367
|
+
process.cwd = origCwd;
|
|
368
|
+
_resetHandlers();
|
|
369
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("opts.dev=false invokes _ensureUiBuild and skips _startDevServer", async () => {
|
|
373
|
+
const ensureCalls: Array<{ uiDir: string }> = [];
|
|
374
|
+
const ensureStub = async (o: { uiDir: string }): Promise<void> => {
|
|
375
|
+
ensureCalls.push({ uiDir: o.uiDir });
|
|
376
|
+
throw new Error("__halt__");
|
|
377
|
+
};
|
|
378
|
+
const devCalls: unknown[] = [];
|
|
379
|
+
const devStub = async (): Promise<DevServerHandle> => {
|
|
380
|
+
devCalls.push(true);
|
|
381
|
+
return { port: 0, stop: async () => {} };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
await expect(
|
|
385
|
+
runServe(
|
|
386
|
+
{ port: 0, host: "127.0.0.1", dev: false },
|
|
387
|
+
{ _ensureUiBuild: ensureStub, _startDevServer: devStub, _restDeps: false },
|
|
388
|
+
),
|
|
389
|
+
).rejects.toThrow("__halt__");
|
|
390
|
+
|
|
391
|
+
expect(ensureCalls.length).toBe(1);
|
|
392
|
+
expect(ensureCalls[0]?.uiDir).toBe(join(tempDir, "ui"));
|
|
393
|
+
expect(devCalls.length).toBe(0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("opts.dev=true does NOT call _ensureUiBuild", async () => {
|
|
397
|
+
const ensureStub = async (): Promise<void> => {
|
|
398
|
+
throw new Error("ensureUiBuild should not be called in dev mode");
|
|
399
|
+
};
|
|
400
|
+
const devStub = async (): Promise<DevServerHandle> => {
|
|
401
|
+
throw new Error("__halt__");
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
await expect(
|
|
405
|
+
runServe(
|
|
406
|
+
{ port: 0, host: "127.0.0.1", dev: true, devPort: 4567 },
|
|
407
|
+
{ _ensureUiBuild: ensureStub, _startDevServer: devStub, _restDeps: false },
|
|
408
|
+
),
|
|
409
|
+
).rejects.toThrow("__halt__");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("opts.devPort is forwarded to _startDevServer", async () => {
|
|
413
|
+
const devCalls: Array<{ uiDir: string; port: number; apiPort?: number }> = [];
|
|
414
|
+
const devStub = async (o: {
|
|
415
|
+
uiDir: string;
|
|
416
|
+
port: number;
|
|
417
|
+
apiPort?: number;
|
|
418
|
+
}): Promise<DevServerHandle> => {
|
|
419
|
+
devCalls.push({ uiDir: o.uiDir, port: o.port, apiPort: o.apiPort });
|
|
420
|
+
throw new Error("__halt__");
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
await expect(
|
|
424
|
+
runServe(
|
|
425
|
+
{ port: 0, host: "127.0.0.1", dev: true, devPort: 4567 },
|
|
426
|
+
{ _startDevServer: devStub, _skipAutoBuild: true, _restDeps: false },
|
|
427
|
+
),
|
|
428
|
+
).rejects.toThrow("__halt__");
|
|
429
|
+
|
|
430
|
+
expect(devCalls.length).toBe(1);
|
|
431
|
+
expect(devCalls[0]?.port).toBe(4567);
|
|
432
|
+
expect(devCalls[0]?.uiDir).toBe(join(tempDir, "ui"));
|
|
433
|
+
// apiPort is the actual bound server port (port 0 => OS-assigned non-zero).
|
|
434
|
+
expect(typeof devCalls[0]?.apiPort).toBe("number");
|
|
435
|
+
expect(devCalls[0]?.apiPort).toBeGreaterThan(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("_skipAutoBuild bypasses _ensureUiBuild even when dev is false", async () => {
|
|
439
|
+
const ensureStub = async (): Promise<void> => {
|
|
440
|
+
throw new Error("should not be called when _skipAutoBuild is true");
|
|
441
|
+
};
|
|
442
|
+
const devStub = async (): Promise<DevServerHandle> => {
|
|
443
|
+
throw new Error("__halt__");
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// dev=true to ensure runServe halts via devStub regardless.
|
|
447
|
+
await expect(
|
|
448
|
+
runServe(
|
|
449
|
+
{ port: 0, host: "127.0.0.1", dev: true, devPort: 3000 },
|
|
450
|
+
{
|
|
451
|
+
_ensureUiBuild: ensureStub,
|
|
452
|
+
_startDevServer: devStub,
|
|
453
|
+
_skipAutoBuild: true,
|
|
454
|
+
_restDeps: false,
|
|
455
|
+
},
|
|
456
|
+
),
|
|
457
|
+
).rejects.toThrow("__halt__");
|
|
458
|
+
});
|
|
459
|
+
});
|