@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.
- package/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- 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 +211 -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/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- 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 +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- 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 +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- 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 +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- 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 +53 -6
- package/src/json.ts +29 -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/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 +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- 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 +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-file serving with path-traversal guard for `ov serve`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from serve.ts so the path-traversal check can be tested in isolation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { existsSync } from "node:fs";
|
|
8
|
+
import { resolve, sep } from "node:path";
|
|
9
|
+
import { apiError } from "../../json.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Serve a static file from uiDistPath, falling back to index.html for SPA routes.
|
|
13
|
+
* Rejects requests that escape uiDistPath with a 403 JSON envelope.
|
|
14
|
+
* Returns a 503 JSON envelope when ui/dist is missing.
|
|
15
|
+
*/
|
|
16
|
+
export async function serveStatic(
|
|
17
|
+
path: string,
|
|
18
|
+
uiDistPath: string,
|
|
19
|
+
_exists: typeof existsSync,
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
if (!_exists(uiDistPath)) {
|
|
22
|
+
return apiError("UI not built — run the UI build first", 503);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Normalise path: strip leading slash, default to index.html
|
|
26
|
+
const stripped = path.replace(/^\//, "") || "index.html";
|
|
27
|
+
|
|
28
|
+
const uiRoot = resolve(uiDistPath);
|
|
29
|
+
const filePath = resolve(uiRoot, stripped);
|
|
30
|
+
|
|
31
|
+
// Path-traversal guard: resolved path must stay inside uiRoot
|
|
32
|
+
if (filePath !== uiRoot && !filePath.startsWith(uiRoot + sep)) {
|
|
33
|
+
return apiError("Forbidden", 403);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const file = Bun.file(filePath);
|
|
37
|
+
if (await file.exists()) {
|
|
38
|
+
return new Response(file);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// SPA fallback: any unknown path → index.html
|
|
42
|
+
const indexPath = resolve(uiRoot, "index.html");
|
|
43
|
+
const indexFile = Bun.file(indexPath);
|
|
44
|
+
if (await indexFile.exists()) {
|
|
45
|
+
return new Response(indexFile, {
|
|
46
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new Response("Not found", { status: 404 });
|
|
51
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { _resetHandlers, createServeServer } from "../serve.ts";
|
|
7
|
+
import { _getRoomCount, _resetRooms, installBroadcaster } from "./ws.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tests use createServeServer() directly (no process signals).
|
|
11
|
+
* Each test binds to port: 0 for conflict-free execution.
|
|
12
|
+
* installBroadcaster() is called explicitly in tests that need it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const POLL_MS = 50; // fast polls for tests
|
|
16
|
+
|
|
17
|
+
describe("WebSocket broadcaster", () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
let eventsDbPath: string;
|
|
20
|
+
let mailDbPath: string;
|
|
21
|
+
let eventsDb: Database;
|
|
22
|
+
let mailDb: Database;
|
|
23
|
+
let servers: ReturnType<typeof Bun.serve>[] = [];
|
|
24
|
+
let stopBroadcasters: (() => void)[] = [];
|
|
25
|
+
let wsConnections: WebSocket[] = [];
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-ws-test-"));
|
|
29
|
+
eventsDbPath = join(tempDir, "events.db");
|
|
30
|
+
mailDbPath = join(tempDir, "mail.db");
|
|
31
|
+
|
|
32
|
+
// Create .overstory/config.yaml so loadConfig resolves
|
|
33
|
+
mkdirSync(join(tempDir, ".overstory"), { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
36
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Pre-create DBs with required schemas
|
|
40
|
+
eventsDb = new Database(eventsDbPath);
|
|
41
|
+
eventsDb.exec("PRAGMA journal_mode=WAL");
|
|
42
|
+
eventsDb.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
run_id TEXT,
|
|
46
|
+
agent_name TEXT NOT NULL,
|
|
47
|
+
session_id TEXT,
|
|
48
|
+
event_type TEXT NOT NULL,
|
|
49
|
+
tool_name TEXT,
|
|
50
|
+
tool_args TEXT,
|
|
51
|
+
tool_duration_ms INTEGER,
|
|
52
|
+
level TEXT NOT NULL DEFAULT 'info',
|
|
53
|
+
data TEXT,
|
|
54
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
|
|
55
|
+
)
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
mailDb = new Database(mailDbPath);
|
|
59
|
+
mailDb.exec("PRAGMA journal_mode=WAL");
|
|
60
|
+
mailDb.exec(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
from_agent TEXT NOT NULL,
|
|
64
|
+
to_agent TEXT NOT NULL,
|
|
65
|
+
subject TEXT NOT NULL,
|
|
66
|
+
body TEXT NOT NULL,
|
|
67
|
+
type TEXT NOT NULL DEFAULT 'status',
|
|
68
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
69
|
+
thread_id TEXT,
|
|
70
|
+
payload TEXT,
|
|
71
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
72
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
|
|
73
|
+
)
|
|
74
|
+
`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
// Close WS connections
|
|
79
|
+
for (const ws of wsConnections) {
|
|
80
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
81
|
+
ws.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
wsConnections = [];
|
|
85
|
+
// Stop broadcasters
|
|
86
|
+
for (const stop of stopBroadcasters) {
|
|
87
|
+
stop();
|
|
88
|
+
}
|
|
89
|
+
stopBroadcasters = [];
|
|
90
|
+
// Stop servers
|
|
91
|
+
for (const srv of servers) {
|
|
92
|
+
srv.stop(true);
|
|
93
|
+
}
|
|
94
|
+
servers = [];
|
|
95
|
+
// Reset handler and room state
|
|
96
|
+
_resetHandlers();
|
|
97
|
+
_resetRooms();
|
|
98
|
+
// Close test DBs
|
|
99
|
+
eventsDb.close();
|
|
100
|
+
mailDb.close();
|
|
101
|
+
// Remove temp dir
|
|
102
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
async function startWithBroadcaster(): Promise<ReturnType<typeof Bun.serve>> {
|
|
106
|
+
const stop = installBroadcaster({ eventsDbPath, mailDbPath, pollIntervalMs: POLL_MS });
|
|
107
|
+
stopBroadcasters.push(stop);
|
|
108
|
+
const origCwd = process.cwd;
|
|
109
|
+
process.cwd = () => tempDir;
|
|
110
|
+
const server = await createServeServer({ port: 0, host: "127.0.0.1" });
|
|
111
|
+
process.cwd = origCwd;
|
|
112
|
+
servers.push(server);
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
117
|
+
return new Promise<void>((resolve, reject) => {
|
|
118
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
119
|
+
resolve();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
ws.addEventListener("open", () => resolve());
|
|
123
|
+
ws.addEventListener("error", () => reject(new Error("WebSocket error")));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectMessages(ws: WebSocket, count: number, timeoutMs = 2000): Promise<unknown[]> {
|
|
128
|
+
return new Promise<unknown[]>((resolve) => {
|
|
129
|
+
const messages: unknown[] = [];
|
|
130
|
+
const timer = setTimeout(() => resolve(messages), timeoutMs);
|
|
131
|
+
ws.addEventListener("message", (e: MessageEvent) => {
|
|
132
|
+
messages.push(JSON.parse(e.data as string));
|
|
133
|
+
if (messages.length >= count) {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
resolve(messages);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function insertEvent(
|
|
142
|
+
db: Database,
|
|
143
|
+
agentName: string,
|
|
144
|
+
runId: string | null,
|
|
145
|
+
eventType: string,
|
|
146
|
+
sessionId?: string | null,
|
|
147
|
+
data?: string | null,
|
|
148
|
+
): void {
|
|
149
|
+
db.prepare(
|
|
150
|
+
"INSERT INTO events (agent_name, run_id, session_id, event_type, data, level) VALUES (?, ?, ?, ?, ?, 'info')",
|
|
151
|
+
).run(agentName, runId, sessionId ?? null, eventType, data ?? null);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function insertMail(db: Database, id: string, fromAgent: string, toAgent: string): void {
|
|
155
|
+
const ts = new Date().toISOString();
|
|
156
|
+
db.prepare(
|
|
157
|
+
"INSERT INTO messages (id, from_agent, to_agent, subject, body, type, priority, read, created_at) VALUES (?, ?, ?, 'Subject', 'Body', 'status', 'normal', 0, ?)",
|
|
158
|
+
).run(id, fromAgent, toAgent, ts);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Test 1: ?run=<id> upgrades; event for matching run id arrives
|
|
162
|
+
test("?run=<id> upgrades and event for matching run arrives", async () => {
|
|
163
|
+
const server = await startWithBroadcaster();
|
|
164
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-123`);
|
|
165
|
+
wsConnections.push(ws);
|
|
166
|
+
await waitForOpen(ws);
|
|
167
|
+
|
|
168
|
+
const pending = collectMessages(ws, 1, 1000);
|
|
169
|
+
insertEvent(eventsDb, "agent-x", "run-123", "tool_start");
|
|
170
|
+
|
|
171
|
+
const msgs = await pending;
|
|
172
|
+
expect(msgs.length).toBe(1);
|
|
173
|
+
const frame = msgs[0] as Record<string, unknown>;
|
|
174
|
+
expect(frame.type).toBe("event");
|
|
175
|
+
const payload = frame.payload as Record<string, unknown>;
|
|
176
|
+
expect(payload.runId).toBe("run-123");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Test 2: ?agent=<name> upgrades; event for that agent arrives
|
|
180
|
+
test("?agent=<name> upgrades and event for matching agent arrives", async () => {
|
|
181
|
+
const server = await startWithBroadcaster();
|
|
182
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=agent-x`);
|
|
183
|
+
wsConnections.push(ws);
|
|
184
|
+
await waitForOpen(ws);
|
|
185
|
+
|
|
186
|
+
const pending = collectMessages(ws, 1, 1000);
|
|
187
|
+
insertEvent(eventsDb, "agent-x", null, "tool_start");
|
|
188
|
+
|
|
189
|
+
const msgs = await pending;
|
|
190
|
+
expect(msgs.length).toBe(1);
|
|
191
|
+
const frame = msgs[0] as Record<string, unknown>;
|
|
192
|
+
expect(frame.type).toBe("event");
|
|
193
|
+
const payload = frame.payload as Record<string, unknown>;
|
|
194
|
+
expect(payload.agentName).toBe("agent-x");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Test 3: Event rooms are independent
|
|
198
|
+
test("event for run-A is not delivered to run-B subscriber", async () => {
|
|
199
|
+
const server = await startWithBroadcaster();
|
|
200
|
+
|
|
201
|
+
const wsA = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-A`);
|
|
202
|
+
const wsB = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-B`);
|
|
203
|
+
wsConnections.push(wsA, wsB);
|
|
204
|
+
await Promise.all([waitForOpen(wsA), waitForOpen(wsB)]);
|
|
205
|
+
|
|
206
|
+
const msgsB: unknown[] = [];
|
|
207
|
+
wsB.addEventListener("message", (e: MessageEvent) => {
|
|
208
|
+
msgsB.push(JSON.parse(e.data as string));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const pendingA = collectMessages(wsA, 1, 1000);
|
|
212
|
+
insertEvent(eventsDb, "agent-x", "run-A", "tool_start");
|
|
213
|
+
|
|
214
|
+
await pendingA;
|
|
215
|
+
// Give run-B subscriber extra time to (not) receive anything
|
|
216
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
217
|
+
expect(msgsB.length).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Test 4: Mail fans out to both agent:<from> and agent:<to>
|
|
221
|
+
test("mail insert fans out to both agent:<from> and agent:<to>", async () => {
|
|
222
|
+
const server = await startWithBroadcaster();
|
|
223
|
+
|
|
224
|
+
const wsFrom = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=sender`);
|
|
225
|
+
const wsTo = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=recipient`);
|
|
226
|
+
wsConnections.push(wsFrom, wsTo);
|
|
227
|
+
await Promise.all([waitForOpen(wsFrom), waitForOpen(wsTo)]);
|
|
228
|
+
|
|
229
|
+
const pendingFrom = collectMessages(wsFrom, 1, 1000);
|
|
230
|
+
const pendingTo = collectMessages(wsTo, 1, 1000);
|
|
231
|
+
|
|
232
|
+
insertMail(mailDb, "msg-test-1", "sender", "recipient");
|
|
233
|
+
|
|
234
|
+
const [msgsFrom, msgsTo] = await Promise.all([pendingFrom, pendingTo]);
|
|
235
|
+
|
|
236
|
+
expect(msgsFrom.length).toBe(1);
|
|
237
|
+
const frameFrom = msgsFrom[0] as Record<string, unknown>;
|
|
238
|
+
expect(frameFrom.type).toBe("mail");
|
|
239
|
+
|
|
240
|
+
expect(msgsTo.length).toBe(1);
|
|
241
|
+
const frameTo = msgsTo[0] as Record<string, unknown>;
|
|
242
|
+
expect(frameTo.type).toBe("mail");
|
|
243
|
+
const payload = frameTo.payload as Record<string, unknown>;
|
|
244
|
+
const message = payload.message as Record<string, unknown>;
|
|
245
|
+
expect(message.from).toBe("sender");
|
|
246
|
+
expect(message.to).toBe("recipient");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Test 5: Text events batch; non-text events don't
|
|
250
|
+
test("text events in rapid succession arrive batched; non-text arrive un-batched", async () => {
|
|
251
|
+
const server = await startWithBroadcaster();
|
|
252
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=batch-agent`);
|
|
253
|
+
wsConnections.push(ws);
|
|
254
|
+
await waitForOpen(ws);
|
|
255
|
+
|
|
256
|
+
// Collect messages over a window large enough for batch flush
|
|
257
|
+
const received: unknown[] = [];
|
|
258
|
+
ws.addEventListener("message", (e: MessageEvent) => {
|
|
259
|
+
received.push(JSON.parse(e.data as string));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Insert 3 text events before any poll fires
|
|
263
|
+
for (let i = 0; i < 3; i++) {
|
|
264
|
+
insertEvent(eventsDb, "batch-agent", null, "text", null, null);
|
|
265
|
+
}
|
|
266
|
+
// Wait for poll + batch window
|
|
267
|
+
await new Promise((r) => setTimeout(r, POLL_MS + 300));
|
|
268
|
+
|
|
269
|
+
// Should have received exactly 1 batched message
|
|
270
|
+
const textFrames = received.filter((m) => {
|
|
271
|
+
const f = m as Record<string, unknown>;
|
|
272
|
+
const p = f.payload as Record<string, unknown>;
|
|
273
|
+
return f.type === "event" && "batched" in p;
|
|
274
|
+
});
|
|
275
|
+
expect(textFrames.length).toBe(1);
|
|
276
|
+
const batched = (textFrames[0] as Record<string, unknown>).payload as Record<string, unknown>;
|
|
277
|
+
expect(batched.batched).toBe(true);
|
|
278
|
+
expect(Array.isArray(batched.events)).toBe(true);
|
|
279
|
+
expect((batched.events as unknown[]).length).toBe(3);
|
|
280
|
+
|
|
281
|
+
// Now test non-text: reset and insert a tool_start
|
|
282
|
+
received.length = 0;
|
|
283
|
+
insertEvent(eventsDb, "batch-agent", null, "tool_start");
|
|
284
|
+
await new Promise((r) => setTimeout(r, POLL_MS + 50));
|
|
285
|
+
|
|
286
|
+
const nonTextFrames = received.filter((m) => {
|
|
287
|
+
const f = m as Record<string, unknown>;
|
|
288
|
+
const p = f.payload as Record<string, unknown>;
|
|
289
|
+
return f.type === "event" && !("batched" in p);
|
|
290
|
+
});
|
|
291
|
+
expect(nonTextFrames.length).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Test 6: Disconnect removes socket from registry
|
|
295
|
+
test("disconnecting removes socket from all rooms (registry empty after close)", async () => {
|
|
296
|
+
const server = await startWithBroadcaster();
|
|
297
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=cleanup-run`);
|
|
298
|
+
wsConnections.push(ws);
|
|
299
|
+
await waitForOpen(ws);
|
|
300
|
+
|
|
301
|
+
// Room should now contain the socket
|
|
302
|
+
expect(_getRoomCount()).toBe(1);
|
|
303
|
+
|
|
304
|
+
// Disconnect
|
|
305
|
+
const closed = new Promise<void>((resolve) => {
|
|
306
|
+
ws.addEventListener("close", () => resolve());
|
|
307
|
+
});
|
|
308
|
+
ws.close();
|
|
309
|
+
await closed;
|
|
310
|
+
// Give server-side close callback time to fire
|
|
311
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
312
|
+
|
|
313
|
+
expect(_getRoomCount()).toBe(0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Test 6b: ?mail=true upgrades and an inserted mail row is delivered to subscriber
|
|
317
|
+
test("?mail=true upgrades and inserted mail row is delivered to subscriber", async () => {
|
|
318
|
+
const server = await startWithBroadcaster();
|
|
319
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?mail=true`);
|
|
320
|
+
wsConnections.push(ws);
|
|
321
|
+
await waitForOpen(ws);
|
|
322
|
+
|
|
323
|
+
const pending = collectMessages(ws, 1, 1000);
|
|
324
|
+
insertMail(mailDb, "msg-mail-room-1", "alpha", "beta");
|
|
325
|
+
|
|
326
|
+
const msgs = await pending;
|
|
327
|
+
expect(msgs.length).toBe(1);
|
|
328
|
+
const frame = msgs[0] as Record<string, unknown>;
|
|
329
|
+
expect(frame.type).toBe("mail");
|
|
330
|
+
const payload = frame.payload as Record<string, unknown>;
|
|
331
|
+
const message = payload.message as Record<string, unknown>;
|
|
332
|
+
expect(message.from).toBe("alpha");
|
|
333
|
+
expect(message.to).toBe("beta");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Test 7: /ws with no run/agent → 400 JSON
|
|
337
|
+
test("/ws with no run or agent query param returns 400 JSON envelope", async () => {
|
|
338
|
+
const server = await startWithBroadcaster();
|
|
339
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/ws`);
|
|
340
|
+
expect(res.status).toBe(400);
|
|
341
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
342
|
+
expect(body.success).toBe(false);
|
|
343
|
+
expect(typeof body.error).toBe("string");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Test 8: /ws with no handler → 404 JSON
|
|
347
|
+
test("/ws with no handler installed returns 404 JSON envelope", async () => {
|
|
348
|
+
// No installBroadcaster called — no WsHandler registered
|
|
349
|
+
const origCwd = process.cwd;
|
|
350
|
+
process.cwd = () => tempDir;
|
|
351
|
+
const server = await createServeServer({ port: 0, host: "127.0.0.1" });
|
|
352
|
+
process.cwd = origCwd;
|
|
353
|
+
servers.push(server);
|
|
354
|
+
|
|
355
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/ws`);
|
|
356
|
+
expect(res.status).toBe(404);
|
|
357
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
358
|
+
expect(body.success).toBe(false);
|
|
359
|
+
expect(typeof body.error).toBe("string");
|
|
360
|
+
});
|
|
361
|
+
});
|