chapterhouse 0.3.22 → 0.3.24
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/dist/api/server.js +71 -29
- package/dist/api/server.test.js +67 -94
- package/dist/api/worker-events-sse.integration.test.js +336 -0
- package/dist/copilot/orchestrator.js +10 -5
- package/dist/copilot/orchestrator.test.js +6 -1
- package/dist/copilot/task-event-log.js +6 -5
- package/dist/store/db.js +8 -0
- package/dist/store/db.test.js +1 -0
- package/dist/wiki/project-registry.js +74 -61
- package/dist/wiki/project-registry.test.js +96 -48
- package/package.json +1 -1
- package/web/dist/assets/{index-Ch4AYrQP.js → index-BK-hInnO.js} +87 -84
- package/web/dist/assets/index-BK-hInnO.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-Ch4AYrQP.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -361,48 +361,90 @@ app.get("/api/workers/:taskId", (req, res) => {
|
|
|
361
361
|
completedAt: row.completed_at,
|
|
362
362
|
});
|
|
363
363
|
});
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
//
|
|
364
|
+
const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
|
|
365
|
+
// SSE stream for per-task tool-call activity.
|
|
366
|
+
// Replays buffered/persisted backlog on connect, then streams live events until
|
|
367
|
+
// the task reaches a terminal state.
|
|
367
368
|
app.get("/api/workers/:taskId/events", (req, res) => {
|
|
368
369
|
const taskId = req.params.taskId;
|
|
369
|
-
const afterSeqRaw = req.query.afterSeq;
|
|
370
|
-
const afterSeq = typeof afterSeqRaw === "string" && !isNaN(Number(afterSeqRaw)) ? Number(afterSeqRaw) : 0;
|
|
371
370
|
const taskRow = getDb()
|
|
372
371
|
.prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
|
|
373
372
|
.get(taskId);
|
|
374
373
|
if (!taskRow) {
|
|
375
374
|
throw new NotFoundError("Task not found");
|
|
376
375
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
res.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
376
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
377
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
378
|
+
res.setHeader("Connection", "keep-alive");
|
|
379
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
380
|
+
res.flushHeaders();
|
|
381
|
+
const rawLastId = req.headers["last-event-id"];
|
|
382
|
+
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
383
|
+
? parseInt(rawLastId.trim(), 10)
|
|
384
|
+
: undefined;
|
|
385
|
+
const sendEvent = (event) => {
|
|
386
|
+
const payload = {
|
|
387
|
+
taskId: event.taskId,
|
|
388
|
+
seq: event.seq,
|
|
389
|
+
ts: event.ts,
|
|
390
|
+
kind: event.kind,
|
|
391
|
+
toolName: event.toolName,
|
|
392
|
+
summary: event.summary,
|
|
393
|
+
};
|
|
394
|
+
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
395
|
+
};
|
|
396
|
+
let replayHighSeq = lastSeq;
|
|
397
|
+
if (lastSeq !== undefined) {
|
|
398
|
+
const bufferedEvents = getTaskLogEvents(taskId);
|
|
399
|
+
const oldestBufferedSeq = bufferedEvents[0]?.seq;
|
|
400
|
+
const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
|
|
401
|
+
if (bufferMissesRange) {
|
|
402
|
+
const dbEvents = getTaskEvents(taskId, lastSeq);
|
|
403
|
+
for (const event of dbEvents) {
|
|
404
|
+
sendEvent(event);
|
|
405
|
+
if (replayHighSeq === undefined || event.seq > replayHighSeq) {
|
|
406
|
+
replayHighSeq = event.seq;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
391
410
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
411
|
+
const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
|
|
412
|
+
const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
|
|
413
|
+
for (const event of backlog) {
|
|
414
|
+
sendEvent(event);
|
|
415
|
+
}
|
|
416
|
+
const isTerminal = () => {
|
|
417
|
+
const row = getDb()
|
|
418
|
+
.prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
|
|
419
|
+
.get(taskId);
|
|
420
|
+
return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
|
|
421
|
+
};
|
|
422
|
+
res.write(`: connected task=${taskId}\n\n`);
|
|
423
|
+
if (isTerminal()) {
|
|
424
|
+
res.end();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const heartbeat = setInterval(() => {
|
|
428
|
+
res.write(`: keep-alive\n\n`);
|
|
429
|
+
}, 15_000);
|
|
430
|
+
const unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
|
|
431
|
+
sendEvent(event);
|
|
396
432
|
});
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
433
|
+
const unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
|
|
434
|
+
if (event.sessionId === taskId && isTerminal()) {
|
|
435
|
+
res.end();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
const unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
|
|
439
|
+
if (event.sessionId === taskId && isTerminal()) {
|
|
440
|
+
res.end();
|
|
441
|
+
}
|
|
402
442
|
});
|
|
403
443
|
req.on("close", () => {
|
|
404
444
|
clearInterval(heartbeat);
|
|
405
|
-
|
|
445
|
+
unsubscribeTaskLog();
|
|
446
|
+
unsubscribeDestroyed();
|
|
447
|
+
unsubscribeError();
|
|
406
448
|
});
|
|
407
449
|
});
|
|
408
450
|
// ---------------------------------------------------------------------------
|
package/dist/api/server.test.js
CHANGED
|
@@ -68,6 +68,47 @@ test("formats named SSE status events", async () => {
|
|
|
68
68
|
assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
|
|
69
69
|
});
|
|
70
70
|
const repoRoot = process.cwd();
|
|
71
|
+
function getProjectDbPath(testRoot) {
|
|
72
|
+
return join(testRoot, ".chapterhouse", "chapterhouse.db");
|
|
73
|
+
}
|
|
74
|
+
function seedProjectRegistry(testRoot, registry) {
|
|
75
|
+
mkdirSync(join(testRoot, ".chapterhouse"), { recursive: true });
|
|
76
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
77
|
+
try {
|
|
78
|
+
db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
80
|
+
slug TEXT PRIMARY KEY,
|
|
81
|
+
cwd TEXT NOT NULL,
|
|
82
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
83
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
84
|
+
)
|
|
85
|
+
`);
|
|
86
|
+
db.prepare("DELETE FROM projects").run();
|
|
87
|
+
const insert = db.prepare(`
|
|
88
|
+
INSERT INTO projects (slug, cwd, created_at, updated_at)
|
|
89
|
+
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
90
|
+
`);
|
|
91
|
+
for (const [slug, cwd] of Object.entries(registry).sort(([left], [right]) => left.localeCompare(right))) {
|
|
92
|
+
insert.run(slug, cwd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function readProjectRegistryRows(testRoot) {
|
|
100
|
+
const dbPath = getProjectDbPath(testRoot);
|
|
101
|
+
if (!existsSync(dbPath)) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const db = new Database(dbPath, { readonly: true });
|
|
105
|
+
try {
|
|
106
|
+
return db.prepare("SELECT slug, cwd FROM projects ORDER BY slug").all();
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
71
112
|
async function getFreePort() {
|
|
72
113
|
const server = createServer();
|
|
73
114
|
await new Promise((resolve) => {
|
|
@@ -402,19 +443,10 @@ test("server projects route returns an empty list when the registry is missing",
|
|
|
402
443
|
});
|
|
403
444
|
test("server projects route sorts by slug and summarizes rule counts", async () => {
|
|
404
445
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
+ "summary: Canonical project registry.\n"
|
|
410
|
-
+ "updated: 2026-05-12\n"
|
|
411
|
-
+ "---\n\n"
|
|
412
|
-
+ "# Projects\n\n"
|
|
413
|
-
+ "## Project Registry\n\n"
|
|
414
|
-
+ "```yaml\n"
|
|
415
|
-
+ "zeta: /srv/zeta\n"
|
|
416
|
-
+ "alpha: /srv/alpha\n"
|
|
417
|
-
+ "```\n", "utf-8");
|
|
446
|
+
seedProjectRegistry(testRoot, {
|
|
447
|
+
zeta: "/srv/zeta",
|
|
448
|
+
alpha: "/srv/alpha",
|
|
449
|
+
});
|
|
418
450
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
419
451
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
420
452
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -448,18 +480,9 @@ test("server projects route sorts by slug and summarizes rule counts", async ()
|
|
|
448
480
|
});
|
|
449
481
|
test("server project detail route returns cwd plus hard and soft rules", async () => {
|
|
450
482
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
+ "title: Projects\n"
|
|
455
|
-
+ "summary: Canonical project registry.\n"
|
|
456
|
-
+ "updated: 2026-05-12\n"
|
|
457
|
-
+ "---\n\n"
|
|
458
|
-
+ "# Projects\n\n"
|
|
459
|
-
+ "## Project Registry\n\n"
|
|
460
|
-
+ "```yaml\n"
|
|
461
|
-
+ "alpha: /srv/alpha\n"
|
|
462
|
-
+ "```\n", "utf-8");
|
|
483
|
+
seedProjectRegistry(testRoot, {
|
|
484
|
+
alpha: "/srv/alpha",
|
|
485
|
+
});
|
|
463
486
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
464
487
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
465
488
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -509,18 +532,9 @@ test("server project detail route returns 404 for an unknown slug", async () =>
|
|
|
509
532
|
});
|
|
510
533
|
test("server projects create route rejects a duplicate slug", async () => {
|
|
511
534
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
+ "title: Projects\n"
|
|
516
|
-
+ "summary: Canonical project registry.\n"
|
|
517
|
-
+ "updated: 2026-05-12\n"
|
|
518
|
-
+ "---\n\n"
|
|
519
|
-
+ "# Projects\n\n"
|
|
520
|
-
+ "## Project Registry\n\n"
|
|
521
|
-
+ "```yaml\n"
|
|
522
|
-
+ "alpha: /srv/original\n"
|
|
523
|
-
+ "```\n", "utf-8");
|
|
535
|
+
seedProjectRegistry(testRoot, {
|
|
536
|
+
alpha: "/srv/original",
|
|
537
|
+
});
|
|
524
538
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
|
525
539
|
method: "POST",
|
|
526
540
|
headers: {
|
|
@@ -534,33 +548,17 @@ test("server projects create route rejects a duplicate slug", async () => {
|
|
|
534
548
|
});
|
|
535
549
|
assert.equal(response.status, 400);
|
|
536
550
|
assert.deepEqual(await response.json(), { error: "Project 'alpha' already exists" });
|
|
537
|
-
assert.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
+ "updated: 2026-05-12\n"
|
|
541
|
-
+ "---\n\n"
|
|
542
|
-
+ "# Projects\n\n"
|
|
543
|
-
+ "## Project Registry\n\n"
|
|
544
|
-
+ "```yaml\n"
|
|
545
|
-
+ "alpha: /srv/original\n"
|
|
546
|
-
+ "```\n");
|
|
551
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), [
|
|
552
|
+
{ slug: "alpha", cwd: "/srv/original" },
|
|
553
|
+
]);
|
|
547
554
|
}, {}, 60_000);
|
|
548
555
|
});
|
|
549
556
|
test("server projects delete route removes the registry entry and rules page", async () => {
|
|
550
557
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
+ "summary: Canonical project registry.\n"
|
|
556
|
-
+ "updated: 2026-05-12\n"
|
|
557
|
-
+ "---\n\n"
|
|
558
|
-
+ "# Projects\n\n"
|
|
559
|
-
+ "## Project Registry\n\n"
|
|
560
|
-
+ "```yaml\n"
|
|
561
|
-
+ "alpha: /srv/alpha\n"
|
|
562
|
-
+ "beta: /srv/beta\n"
|
|
563
|
-
+ "```\n", "utf-8");
|
|
558
|
+
seedProjectRegistry(testRoot, {
|
|
559
|
+
alpha: "/srv/alpha",
|
|
560
|
+
beta: "/srv/beta",
|
|
561
|
+
});
|
|
564
562
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
565
563
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
566
564
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -574,16 +572,9 @@ test("server projects delete route removes the registry entry and rules page", a
|
|
|
574
572
|
});
|
|
575
573
|
assert.equal(response.status, 200);
|
|
576
574
|
assert.deepEqual(await response.json(), { ok: true, slug: "alpha" });
|
|
577
|
-
assert.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
+ "updated: 2026-05-12\n"
|
|
581
|
-
+ "---\n\n"
|
|
582
|
-
+ "# Projects\n\n"
|
|
583
|
-
+ "## Project Registry\n\n"
|
|
584
|
-
+ "```yaml\n"
|
|
585
|
-
+ "beta: /srv/beta\n"
|
|
586
|
-
+ "```\n");
|
|
575
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), [
|
|
576
|
+
{ slug: "beta", cwd: "/srv/beta" },
|
|
577
|
+
]);
|
|
587
578
|
assert.equal(existsSync(alphaRulesPath), false);
|
|
588
579
|
}, {}, 60_000);
|
|
589
580
|
});
|
|
@@ -599,18 +590,9 @@ test("server projects delete route returns 404 for an unknown slug", async () =>
|
|
|
599
590
|
});
|
|
600
591
|
test("server project hard-rules update route rewrites only hard-rule frontmatter fields", async () => {
|
|
601
592
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
+ "title: Projects\n"
|
|
606
|
-
+ "summary: Canonical project registry.\n"
|
|
607
|
-
+ "updated: 2026-05-12\n"
|
|
608
|
-
+ "---\n\n"
|
|
609
|
-
+ "# Projects\n\n"
|
|
610
|
-
+ "## Project Registry\n\n"
|
|
611
|
-
+ "```yaml\n"
|
|
612
|
-
+ "alpha: /srv/alpha\n"
|
|
613
|
-
+ "```\n", "utf-8");
|
|
593
|
+
seedProjectRegistry(testRoot, {
|
|
594
|
+
alpha: "/srv/alpha",
|
|
595
|
+
});
|
|
614
596
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
615
597
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
616
598
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -688,18 +670,9 @@ test("server project hard-rules update route rewrites only hard-rule frontmatter
|
|
|
688
670
|
});
|
|
689
671
|
test("server project soft-rules update route rewrites the body while preserving frontmatter", async () => {
|
|
690
672
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
+ "title: Projects\n"
|
|
695
|
-
+ "summary: Canonical project registry.\n"
|
|
696
|
-
+ "updated: 2026-05-12\n"
|
|
697
|
-
+ "---\n\n"
|
|
698
|
-
+ "# Projects\n\n"
|
|
699
|
-
+ "## Project Registry\n\n"
|
|
700
|
-
+ "```yaml\n"
|
|
701
|
-
+ "alpha: /srv/alpha\n"
|
|
702
|
-
+ "```\n", "utf-8");
|
|
673
|
+
seedProjectRegistry(testRoot, {
|
|
674
|
+
alpha: "/srv/alpha",
|
|
675
|
+
});
|
|
703
676
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
704
677
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
705
678
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
const repoRoot = process.cwd();
|
|
8
|
+
const testWorkRoot = join(repoRoot, ".test-work", `worker-events-sse-${process.pid}`);
|
|
9
|
+
let instanceCounter = 0;
|
|
10
|
+
const CONTROL_PREFIX = "__CH__";
|
|
11
|
+
const READY_PREFIX = "__CH_READY__";
|
|
12
|
+
const controlledServerScript = `
|
|
13
|
+
import readline from "node:readline";
|
|
14
|
+
import { startApiServer } from "./dist/api/server.js";
|
|
15
|
+
import { agentEventBus } from "./dist/copilot/agent-event-bus.js";
|
|
16
|
+
import { initTaskEventLog } from "./dist/copilot/task-event-log.js";
|
|
17
|
+
import { appendTaskEvent, getDb } from "./dist/store/db.js";
|
|
18
|
+
|
|
19
|
+
const db = getDb();
|
|
20
|
+
initTaskEventLog();
|
|
21
|
+
const reply = (payload) => process.stdout.write("${CONTROL_PREFIX}" + JSON.stringify(payload) + "\\n");
|
|
22
|
+
|
|
23
|
+
function emitTaskEvent(taskId, kind, toolName, summary) {
|
|
24
|
+
const event = appendTaskEvent(taskId, kind, toolName ?? null, summary ?? null);
|
|
25
|
+
if (!event) {
|
|
26
|
+
throw new Error("appendTaskEvent returned undefined");
|
|
27
|
+
}
|
|
28
|
+
agentEventBus.emit({
|
|
29
|
+
type: "session:tool_call",
|
|
30
|
+
sessionId: taskId,
|
|
31
|
+
payload: {
|
|
32
|
+
_kind: event.kind,
|
|
33
|
+
_seq: event.seq,
|
|
34
|
+
_ts: event.ts,
|
|
35
|
+
_summary: event.summary,
|
|
36
|
+
...(event.toolName ? { toolName: event.toolName } : {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return event;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await startApiServer();
|
|
43
|
+
process.stdout.write("${READY_PREFIX}\\n");
|
|
44
|
+
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
46
|
+
for await (const line of rl) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
|
|
49
|
+
const command = JSON.parse(line);
|
|
50
|
+
try {
|
|
51
|
+
switch (command.type) {
|
|
52
|
+
case "createTask":
|
|
53
|
+
db.prepare(
|
|
54
|
+
"INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)"
|
|
55
|
+
).run(command.taskId, command.agentSlug ?? "coder", command.description ?? "Test worker task", command.status ?? "running");
|
|
56
|
+
reply({ ok: true });
|
|
57
|
+
break;
|
|
58
|
+
case "appendEvent":
|
|
59
|
+
reply({
|
|
60
|
+
ok: true,
|
|
61
|
+
event: emitTaskEvent(command.taskId, command.kind, command.toolName ?? null, command.summary ?? null),
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
case "finishTask": {
|
|
65
|
+
db.prepare(
|
|
66
|
+
"UPDATE agent_tasks SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?"
|
|
67
|
+
).run(command.status, command.taskId);
|
|
68
|
+
agentEventBus.emit({
|
|
69
|
+
type: command.status === "failed" ? "session:error" : "session:destroyed",
|
|
70
|
+
sessionId: command.taskId,
|
|
71
|
+
payload: command.status === "failed"
|
|
72
|
+
? { agentName: command.agentName ?? "coder", error: command.reason ?? "Task failed" }
|
|
73
|
+
: { agentName: command.agentName ?? "coder", reason: command.reason ?? "complete" },
|
|
74
|
+
});
|
|
75
|
+
reply({ ok: true });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
throw new Error(\`Unknown command: \${command.type}\`);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
async function getFreePort() {
|
|
87
|
+
const server = createServer();
|
|
88
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
89
|
+
const address = server.address();
|
|
90
|
+
assert.ok(address && typeof address === "object");
|
|
91
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
92
|
+
return address.port;
|
|
93
|
+
}
|
|
94
|
+
async function stopChild(child) {
|
|
95
|
+
if (child.exitCode !== null)
|
|
96
|
+
return;
|
|
97
|
+
child.kill("SIGTERM");
|
|
98
|
+
await new Promise((resolve) => {
|
|
99
|
+
child.once("exit", () => resolve());
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (child.exitCode === null)
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}, 2_000);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function withControlledServer(run, timeoutMs = 30_000) {
|
|
107
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
108
|
+
const instanceRoot = `${testWorkRoot}-${Date.now()}-${++instanceCounter}`;
|
|
109
|
+
rmSync(instanceRoot, { recursive: true, force: true });
|
|
110
|
+
mkdirSync(instanceRoot, { recursive: true });
|
|
111
|
+
const port = await getFreePort();
|
|
112
|
+
const logs = [];
|
|
113
|
+
let stdoutBuffer = "";
|
|
114
|
+
let readyResolve;
|
|
115
|
+
let readyReject;
|
|
116
|
+
const ready = new Promise((resolve, reject) => {
|
|
117
|
+
readyResolve = resolve;
|
|
118
|
+
readyReject = reject;
|
|
119
|
+
});
|
|
120
|
+
const pendingReplies = [];
|
|
121
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", controlledServerScript], {
|
|
122
|
+
cwd: repoRoot,
|
|
123
|
+
env: {
|
|
124
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("COPILOT_") && !key.startsWith("NODE_TEST_"))),
|
|
125
|
+
CHAPTERHOUSE_DISABLE_DOTENV: "1",
|
|
126
|
+
CHAPTERHOUSE_HOME: instanceRoot,
|
|
127
|
+
API_HOST: "127.0.0.1",
|
|
128
|
+
API_PORT: String(port),
|
|
129
|
+
API_TOKEN: "test-token",
|
|
130
|
+
},
|
|
131
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
132
|
+
});
|
|
133
|
+
child.stdout?.on("data", (chunk) => {
|
|
134
|
+
stdoutBuffer += String(chunk);
|
|
135
|
+
const lines = stdoutBuffer.split("\n");
|
|
136
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (line === READY_PREFIX) {
|
|
139
|
+
readyResolve?.();
|
|
140
|
+
}
|
|
141
|
+
else if (line.startsWith(CONTROL_PREFIX)) {
|
|
142
|
+
const pending = pendingReplies.shift();
|
|
143
|
+
if (!pending)
|
|
144
|
+
continue;
|
|
145
|
+
clearTimeout(pending.timer);
|
|
146
|
+
pending.resolve(JSON.parse(line.slice(CONTROL_PREFIX.length)));
|
|
147
|
+
}
|
|
148
|
+
else if (line.length > 0) {
|
|
149
|
+
logs.push(`${line}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
|
|
154
|
+
const sendCommand = async (command) => {
|
|
155
|
+
assert.ok(child.stdin, "Controlled server stdin should be writable");
|
|
156
|
+
const reply = new Promise((resolve, reject) => {
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
reject(new Error(`Timed out waiting for control reply for ${command.type}`));
|
|
159
|
+
}, 2_000);
|
|
160
|
+
pendingReplies.push({ resolve, reject, timer });
|
|
161
|
+
});
|
|
162
|
+
child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
163
|
+
const payload = await reply;
|
|
164
|
+
if (!payload.ok) {
|
|
165
|
+
throw new Error(payload.error ?? `Control command ${command.type} failed`);
|
|
166
|
+
}
|
|
167
|
+
return payload;
|
|
168
|
+
};
|
|
169
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
170
|
+
try {
|
|
171
|
+
const timeout = setTimeout(() => {
|
|
172
|
+
readyReject?.(new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`));
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
try {
|
|
175
|
+
await ready;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(timeout);
|
|
179
|
+
}
|
|
180
|
+
if (child.exitCode !== null) {
|
|
181
|
+
throw new Error(`Server exited early:\n${logs.join("")}`);
|
|
182
|
+
}
|
|
183
|
+
await run({ baseUrl, authHeader: "Bearer test-token", sendCommand });
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
child.stdin?.end();
|
|
187
|
+
await stopChild(child);
|
|
188
|
+
rmSync(instanceRoot, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function createSseReader(body) {
|
|
192
|
+
const reader = body.getReader();
|
|
193
|
+
const decoder = new TextDecoder();
|
|
194
|
+
let leftover = "";
|
|
195
|
+
function drainFrames() {
|
|
196
|
+
const frames = [];
|
|
197
|
+
const parts = leftover.split("\n\n");
|
|
198
|
+
leftover = parts.pop() ?? "";
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
if (!part.trim())
|
|
201
|
+
continue;
|
|
202
|
+
const frame = { data: null };
|
|
203
|
+
for (const line of part.split("\n")) {
|
|
204
|
+
if (line.startsWith(":"))
|
|
205
|
+
continue;
|
|
206
|
+
if (line.startsWith("id: "))
|
|
207
|
+
frame.id = line.slice(4);
|
|
208
|
+
else if (line.startsWith("event: "))
|
|
209
|
+
frame.event = line.slice(7);
|
|
210
|
+
else if (line.startsWith("data: ")) {
|
|
211
|
+
try {
|
|
212
|
+
frame.data = JSON.parse(line.slice(6));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
frame.data = line.slice(6);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (frame.data !== null)
|
|
220
|
+
frames.push(frame);
|
|
221
|
+
}
|
|
222
|
+
return frames;
|
|
223
|
+
}
|
|
224
|
+
async function readFrames(count, timeoutMs = 3_000) {
|
|
225
|
+
const frames = [];
|
|
226
|
+
const deadline = Date.now() + timeoutMs;
|
|
227
|
+
while (frames.length < count && Date.now() < deadline) {
|
|
228
|
+
const { value, done } = await Promise.race([
|
|
229
|
+
reader.read(),
|
|
230
|
+
new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
|
|
231
|
+
]);
|
|
232
|
+
if (done)
|
|
233
|
+
break;
|
|
234
|
+
if (value !== undefined) {
|
|
235
|
+
leftover += decoder.decode(value, { stream: true });
|
|
236
|
+
frames.push(...drainFrames());
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return frames;
|
|
240
|
+
}
|
|
241
|
+
async function waitForClose(timeoutMs = 3_000) {
|
|
242
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
|
+
while (Date.now() < deadline) {
|
|
244
|
+
const { value, done } = await Promise.race([
|
|
245
|
+
reader.read(),
|
|
246
|
+
new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
|
|
247
|
+
]);
|
|
248
|
+
if (done)
|
|
249
|
+
return true;
|
|
250
|
+
if (value !== undefined) {
|
|
251
|
+
leftover += decoder.decode(value, { stream: true });
|
|
252
|
+
void drainFrames();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
async function cancel() {
|
|
258
|
+
try {
|
|
259
|
+
await reader.cancel();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Ignore already-closed streams.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { readFrames, waitForClose, cancel };
|
|
266
|
+
}
|
|
267
|
+
test("worker events SSE replays backlog, streams live events, and closes on completion", async () => {
|
|
268
|
+
await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
|
|
269
|
+
const taskId = "worker-events-sse-001";
|
|
270
|
+
await sendCommand({
|
|
271
|
+
type: "createTask",
|
|
272
|
+
taskId,
|
|
273
|
+
description: "Stream worker task events",
|
|
274
|
+
status: "running",
|
|
275
|
+
});
|
|
276
|
+
await sendCommand({
|
|
277
|
+
type: "appendEvent",
|
|
278
|
+
taskId,
|
|
279
|
+
kind: "tool_start",
|
|
280
|
+
toolName: "bash",
|
|
281
|
+
summary: "npm run build",
|
|
282
|
+
});
|
|
283
|
+
await sendCommand({
|
|
284
|
+
type: "appendEvent",
|
|
285
|
+
taskId,
|
|
286
|
+
kind: "tool_complete",
|
|
287
|
+
summary: "build complete",
|
|
288
|
+
});
|
|
289
|
+
const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: authHeader,
|
|
292
|
+
Accept: "text/event-stream",
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
assert.equal(response.status, 200);
|
|
296
|
+
assert.ok(response.headers.get("content-type")?.includes("text/event-stream"), `Content-Type should include text/event-stream, got ${response.headers.get("content-type")}`);
|
|
297
|
+
assert.ok(response.body, "SSE response should have a readable body");
|
|
298
|
+
const reader = createSseReader(response.body);
|
|
299
|
+
try {
|
|
300
|
+
const backlogFrames = await reader.readFrames(2, 2_000);
|
|
301
|
+
assert.equal(backlogFrames.length, 2, "Expected backlog replay frames");
|
|
302
|
+
assert.deepEqual(backlogFrames.map((frame) => frame.id), ["1", "2"], "Backlog frames should preserve task event sequence ids");
|
|
303
|
+
const backlogKinds = backlogFrames.map((frame) => frame.data.kind);
|
|
304
|
+
assert.deepEqual(backlogKinds, ["tool_start", "tool_complete"]);
|
|
305
|
+
await sendCommand({
|
|
306
|
+
type: "appendEvent",
|
|
307
|
+
taskId,
|
|
308
|
+
kind: "tool_start",
|
|
309
|
+
toolName: "view",
|
|
310
|
+
summary: "README.md",
|
|
311
|
+
});
|
|
312
|
+
const liveFrames = await reader.readFrames(1, 2_000);
|
|
313
|
+
assert.equal(liveFrames.length, 1, "Expected one live frame");
|
|
314
|
+
assert.equal(liveFrames[0]?.id, "3");
|
|
315
|
+
assert.deepEqual(liveFrames[0]?.data, {
|
|
316
|
+
taskId,
|
|
317
|
+
seq: 3,
|
|
318
|
+
ts: (liveFrames[0]?.data).ts,
|
|
319
|
+
kind: "tool_start",
|
|
320
|
+
toolName: "view",
|
|
321
|
+
summary: "README.md",
|
|
322
|
+
});
|
|
323
|
+
await sendCommand({
|
|
324
|
+
type: "finishTask",
|
|
325
|
+
taskId,
|
|
326
|
+
status: "completed",
|
|
327
|
+
reason: "complete",
|
|
328
|
+
});
|
|
329
|
+
assert.equal(await reader.waitForClose(2_000), true, "SSE stream should close when the task completes");
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await reader.cancel();
|
|
333
|
+
}
|
|
334
|
+
}, 30_000);
|
|
335
|
+
});
|
|
336
|
+
//# sourceMappingURL=worker-events-sse.integration.test.js.map
|