chapterhouse 0.3.12 → 0.3.14

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 (53) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +15 -157
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/api/turn-sse.integration.test.js +36 -0
  5. package/dist/cli.js +0 -30
  6. package/dist/config.js +0 -3
  7. package/dist/copilot/agent-event-bus.js +41 -0
  8. package/dist/copilot/agent-event-bus.test.js +23 -0
  9. package/dist/copilot/agents.js +4 -59
  10. package/dist/copilot/orchestrator.js +60 -65
  11. package/dist/copilot/orchestrator.test.js +73 -158
  12. package/dist/copilot/task-event-log.js +5 -5
  13. package/dist/copilot/task-event-log.test.js +68 -142
  14. package/dist/copilot/tools.js +9 -85
  15. package/dist/daemon.js +0 -22
  16. package/dist/store/db.js +2 -50
  17. package/dist/store/db.test.js +0 -45
  18. package/package.json +1 -3
  19. package/web/dist/assets/index-BlIWCM11.js +217 -0
  20. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  21. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  22. package/web/dist/index.html +2 -2
  23. package/dist/api/ralph.js +0 -153
  24. package/dist/api/ralph.test.js +0 -101
  25. package/dist/copilot/agents.squad.test.js +0 -72
  26. package/dist/copilot/hooks.js +0 -157
  27. package/dist/copilot/hooks.test.js +0 -315
  28. package/dist/copilot/squad-event-bus.js +0 -27
  29. package/dist/copilot/tools.squad.test.js +0 -168
  30. package/dist/squad/charter.js +0 -125
  31. package/dist/squad/charter.test.js +0 -89
  32. package/dist/squad/context.js +0 -48
  33. package/dist/squad/context.test.js +0 -59
  34. package/dist/squad/discovery.js +0 -268
  35. package/dist/squad/discovery.test.js +0 -154
  36. package/dist/squad/index.js +0 -9
  37. package/dist/squad/init-cli.js +0 -109
  38. package/dist/squad/init.js +0 -395
  39. package/dist/squad/init.test.js +0 -351
  40. package/dist/squad/mirror.js +0 -83
  41. package/dist/squad/mirror.scheduler.js +0 -80
  42. package/dist/squad/mirror.scheduler.test.js +0 -197
  43. package/dist/squad/mirror.test.js +0 -172
  44. package/dist/squad/registry.js +0 -162
  45. package/dist/squad/registry.test.js +0 -31
  46. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  47. package/dist/squad/squad-session-routing.test.js +0 -260
  48. package/dist/squad/types.js +0 -4
  49. package/dist/squad/worktree.js +0 -295
  50. package/dist/squad/worktree.test.js +0 -189
  51. package/dist/store/squad-sessions.test.js +0 -341
  52. package/web/dist/assets/index-BR2cks94.js +0 -219
  53. package/web/dist/assets/index-BR2cks94.js.map +0 -1
@@ -1,341 +0,0 @@
1
- /**
2
- * Phase 4 — DB + orchestrator session isolation tests
3
- *
4
- * Covers:
5
- * - copilot_sessions table creation
6
- * - conversation_log.session_key migration
7
- * - upsertCopilotSession / getCopilotSession roundtrip
8
- * - default session key semantics
9
- * - project session isolation
10
- * - session key derivation via normalizeProjectPath
11
- */
12
- import assert from "node:assert/strict";
13
- import { mkdirSync, rmSync } from "node:fs";
14
- import { join } from "node:path";
15
- import test from "node:test";
16
- import Database from "better-sqlite3";
17
- const repoRoot = process.cwd();
18
- const sandboxRoot = join(repoRoot, ".test-work", `squad-sessions-${process.pid}`);
19
- const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
20
- const dbPath = join(chapterhouseHome, "chapterhouse.db");
21
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
22
- async function loadDbModule() {
23
- return await import(new URL(`./db.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
24
- }
25
- async function loadContextModule() {
26
- return await import(new URL(`../squad/context.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
27
- }
28
- function resetSandbox() {
29
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
30
- rmSync(sandboxRoot, { recursive: true, force: true });
31
- mkdirSync(chapterhouseHome, { recursive: true });
32
- }
33
- test.beforeEach(() => {
34
- resetSandbox();
35
- });
36
- test.after(() => {
37
- rmSync(sandboxRoot, { recursive: true, force: true });
38
- });
39
- // ---------------------------------------------------------------------------
40
- // 1. copilot_sessions table exists after getDb()
41
- // ---------------------------------------------------------------------------
42
- test("copilot_sessions table exists with correct schema after getDb()", async () => {
43
- const dbModule = await loadDbModule();
44
- try {
45
- const db = dbModule.getDb();
46
- const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
47
- const tableNames = new Set(tables.map((row) => row.name));
48
- assert.equal(tableNames.has("copilot_sessions"), true, "copilot_sessions table should exist");
49
- // Verify columns
50
- const cols = db.prepare(`PRAGMA table_info(copilot_sessions)`).all();
51
- const colMap = new Map(cols.map((c) => [c.name, c]));
52
- assert.equal(colMap.has("session_key"), true, "should have session_key column");
53
- assert.equal(colMap.has("mode"), true, "should have mode column");
54
- assert.equal(colMap.has("project_root"), true, "should have project_root column");
55
- assert.equal(colMap.has("copilot_session_id"), true, "should have copilot_session_id column");
56
- assert.equal(colMap.has("model"), true, "should have model column");
57
- assert.equal(colMap.has("updated_at"), true, "should have updated_at column");
58
- // session_key is PRIMARY KEY
59
- assert.equal(colMap.get("session_key")?.pk, 1, "session_key should be primary key");
60
- // mode is NOT NULL
61
- assert.equal(colMap.get("mode")?.notnull, 1, "mode should be NOT NULL");
62
- // copilot_session_id is NOT NULL
63
- assert.equal(colMap.get("copilot_session_id")?.notnull, 1, "copilot_session_id should be NOT NULL");
64
- }
65
- finally {
66
- dbModule.closeDb();
67
- }
68
- });
69
- // ---------------------------------------------------------------------------
70
- // 2. conversation_log has session_key column
71
- // ---------------------------------------------------------------------------
72
- test("conversation_log has session_key column with default 'default'", async () => {
73
- const dbModule = await loadDbModule();
74
- try {
75
- dbModule.getDb();
76
- const db = new Database(dbPath, { readonly: true });
77
- const cols = db.prepare(`PRAGMA table_info(conversation_log)`).all();
78
- db.close();
79
- const sessionKeyCol = cols.find((c) => c.name === "session_key");
80
- assert.ok(sessionKeyCol, "conversation_log should have a session_key column");
81
- assert.match(sessionKeyCol.dflt_value ?? "", /default/i, `session_key default should be 'default', got: ${sessionKeyCol.dflt_value}`);
82
- }
83
- finally {
84
- dbModule.closeDb();
85
- }
86
- });
87
- // ---------------------------------------------------------------------------
88
- // 3. upsertCopilotSession + getCopilotSession roundtrip
89
- // ---------------------------------------------------------------------------
90
- test("upsertCopilotSession and getCopilotSession roundtrip preserves all fields", async () => {
91
- const dbModule = await loadDbModule();
92
- try {
93
- dbModule.getDb();
94
- assert.equal(typeof dbModule.upsertCopilotSession, "function", "upsertCopilotSession should be exported from db module");
95
- assert.equal(typeof dbModule.getCopilotSession, "function", "getCopilotSession should be exported from db module");
96
- dbModule.upsertCopilotSession("project:/home/user/myrepo", "project", "sdk-abc-123", "/home/user/myrepo", "gpt-4.1");
97
- const row = dbModule.getCopilotSession("project:/home/user/myrepo");
98
- assert.ok(row, "getCopilotSession should return a row for the upserted key");
99
- assert.equal(row.copilotSessionId, "sdk-abc-123");
100
- assert.equal(row.model, "gpt-4.1");
101
- }
102
- finally {
103
- dbModule.closeDb();
104
- }
105
- });
106
- // ---------------------------------------------------------------------------
107
- // 4. Default session uses 'default' key
108
- // ---------------------------------------------------------------------------
109
- test("logConversation without session_key defaults to 'default' session", async () => {
110
- const dbModule = await loadDbModule();
111
- try {
112
- const db = dbModule.getDb();
113
- // logConversation existing signature — should log to 'default' session
114
- dbModule.logConversation("user", "Hello world", "web");
115
- const rows = db.prepare(`SELECT session_key FROM conversation_log WHERE content = 'Hello world'`).all();
116
- assert.equal(rows.length, 1, "should have logged one row");
117
- assert.equal(rows[0].session_key, "default", "session_key should default to 'default'");
118
- }
119
- finally {
120
- dbModule.closeDb();
121
- }
122
- });
123
- // ---------------------------------------------------------------------------
124
- // 5. Project sessions are isolated
125
- // ---------------------------------------------------------------------------
126
- test("conversation_log rows for project:a do not appear in project:b query", async () => {
127
- const dbModule = await loadDbModule();
128
- try {
129
- const db = dbModule.getDb();
130
- // Verify logConversation accepts an optional session_key param
131
- const log = dbModule.logConversation;
132
- log("user", "Message in project A", "web", "project:/repo/a");
133
- log("user", "Message in project B", "web", "project:/repo/b");
134
- log("user", "Default message", "web");
135
- const projectARows = db.prepare(`SELECT content FROM conversation_log WHERE session_key = 'project:/repo/a'`).all();
136
- const projectBRows = db.prepare(`SELECT content FROM conversation_log WHERE session_key = 'project:/repo/b'`).all();
137
- const defaultRows = db.prepare(`SELECT content FROM conversation_log WHERE session_key = 'default'`).all();
138
- assert.equal(projectARows.length, 1, "project A should have exactly 1 row");
139
- assert.equal(projectARows[0].content, "Message in project A");
140
- assert.equal(projectBRows.length, 1, "project B should have exactly 1 row");
141
- assert.equal(projectBRows[0].content, "Message in project B");
142
- assert.equal(defaultRows.length, 1, "default session should have exactly 1 row");
143
- assert.equal(defaultRows[0].content, "Default message");
144
- }
145
- finally {
146
- dbModule.closeDb();
147
- }
148
- });
149
- // ---------------------------------------------------------------------------
150
- // 6. Session key derivation — normalizeProjectPath is consistent
151
- // ---------------------------------------------------------------------------
152
- test("projectSessionKey format uses normalizeProjectPath for consistency", async () => {
153
- const context = await loadContextModule();
154
- // normalizeProjectPath must exist
155
- assert.equal(typeof context.normalizeProjectPath, "function", "normalizeProjectPath should be exported from context module");
156
- const normalized = context.normalizeProjectPath("/home/user/myrepo");
157
- const sessionKey = `project:${normalized}`;
158
- assert.match(sessionKey, /^project:\//, "session key should start with 'project:/'");
159
- assert.equal(sessionKey, `project:${normalized}`, "session key should be project:${normalizedPath}");
160
- // Home-relative paths must normalize to the same value as absolute
161
- const fromHome = context.normalizeProjectPath("~/myrepo");
162
- const fromHomeKey = `project:${fromHome}`;
163
- assert.match(fromHomeKey, /^project:\//, "home-relative path should also produce absolute session key");
164
- // Two calls with same path produce same key
165
- const key1 = `project:${context.normalizeProjectPath("/some/project")}`;
166
- const key2 = `project:${context.normalizeProjectPath("/some/project")}`;
167
- assert.equal(key1, key2, "session key must be deterministic for the same path");
168
- });
169
- // ---------------------------------------------------------------------------
170
- // 7. Backfill: existing conversation_log rows get session_key = 'default'
171
- // ---------------------------------------------------------------------------
172
- test("migration backfills existing conversation_log rows to session_key='default'", async () => {
173
- // Pre-seed a DB without session_key column (simulating pre-migration state)
174
- const seedDb = new Database(dbPath);
175
- seedDb.exec(`
176
- CREATE TABLE conversation_log (
177
- id INTEGER PRIMARY KEY AUTOINCREMENT,
178
- role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
179
- content TEXT NOT NULL,
180
- source TEXT NOT NULL DEFAULT 'unknown',
181
- ts DATETIME DEFAULT CURRENT_TIMESTAMP
182
- )
183
- `);
184
- seedDb.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`).run("user", "Old turn", "cli");
185
- seedDb.close();
186
- const dbModule = await loadDbModule();
187
- try {
188
- dbModule.getDb();
189
- const db = new Database(dbPath, { readonly: true });
190
- const rows = db.prepare(`SELECT session_key FROM conversation_log WHERE content = 'Old turn'`).all();
191
- db.close();
192
- assert.equal(rows.length, 1, "backfilled row should exist");
193
- assert.equal(rows[0].session_key, "default", "backfilled row should have session_key='default'");
194
- }
195
- finally {
196
- dbModule.closeDb();
197
- }
198
- });
199
- // ---------------------------------------------------------------------------
200
- // 8. getCopilotSession returns undefined for unknown key
201
- // ---------------------------------------------------------------------------
202
- test("getCopilotSession returns undefined for an unknown session key", async () => {
203
- const dbModule = await loadDbModule();
204
- try {
205
- dbModule.getDb();
206
- const result = dbModule.getCopilotSession("project:/nonexistent/path");
207
- assert.equal(result, undefined, "should return undefined for unknown session");
208
- }
209
- finally {
210
- dbModule.closeDb();
211
- }
212
- });
213
- // ---------------------------------------------------------------------------
214
- // 9. agent_tasks table has session_key column
215
- // ---------------------------------------------------------------------------
216
- test("agent_tasks table has session_key column for task routing", async () => {
217
- const dbModule = await loadDbModule();
218
- try {
219
- const db = dbModule.getDb();
220
- const cols = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
221
- const colNames = new Set(cols.map((c) => c.name));
222
- assert.equal(colNames.has("session_key"), true, "agent_tasks should have a session_key column for routing completions back to originating session");
223
- }
224
- finally {
225
- dbModule.closeDb();
226
- }
227
- });
228
- // ---------------------------------------------------------------------------
229
- // 10. getRecentConversation(sessionKey) filters to one session only
230
- // ---------------------------------------------------------------------------
231
- test("getRecentConversation with sessionKey returns only rows for that session", async () => {
232
- const dbModule = await loadDbModule();
233
- try {
234
- dbModule.getDb();
235
- // Write rows to two different sessions
236
- dbModule.logConversation("user", "Squad project question", "web", "project:/repo/squad");
237
- dbModule.logConversation("assistant", "Squad project answer", "web", "project:/repo/squad");
238
- dbModule.logConversation("user", "Default chat question", "web", "default");
239
- // Scoped read — only project:/repo/squad rows
240
- const squadHistory = dbModule.getRecentConversation(20, "project:/repo/squad");
241
- assert.ok(squadHistory.includes("Squad project question"), "should include squad session user turn");
242
- assert.ok(squadHistory.includes("Squad project answer"), "should include squad session assistant turn");
243
- assert.ok(!squadHistory.includes("Default chat question"), "should NOT include default session rows");
244
- // Scoped read — only default session rows
245
- const defaultHistory = dbModule.getRecentConversation(20, "default");
246
- assert.ok(defaultHistory.includes("Default chat question"), "should include default session row");
247
- assert.ok(!defaultHistory.includes("Squad project question"), "should NOT include squad session rows");
248
- // No sessionKey — cross-session (legacy behavior)
249
- const allHistory = dbModule.getRecentConversation(20);
250
- assert.ok(allHistory.includes("Squad project question"), "unscoped read should include all sessions");
251
- assert.ok(allHistory.includes("Default chat question"), "unscoped read should include all sessions");
252
- }
253
- finally {
254
- dbModule.closeDb();
255
- }
256
- });
257
- // ---------------------------------------------------------------------------
258
- // 11. getRecentConversation with sessionKey that has no rows returns ""
259
- // ---------------------------------------------------------------------------
260
- test("getRecentConversation with sessionKey for empty session returns empty string", async () => {
261
- const dbModule = await loadDbModule();
262
- try {
263
- dbModule.getDb();
264
- // Write rows only to 'default'
265
- dbModule.logConversation("user", "Default only message", "web", "default");
266
- // Scoped read for a session that has no rows
267
- const emptyHistory = dbModule.getRecentConversation(20, "project:/repo/empty");
268
- assert.equal(emptyHistory, "", "scoped read for a session with no rows should return empty string");
269
- }
270
- finally {
271
- dbModule.closeDb();
272
- }
273
- });
274
- // ---------------------------------------------------------------------------
275
- // 12. getRecentConversation with unknown sessionKey returns "" not other rows
276
- // ---------------------------------------------------------------------------
277
- test("getRecentConversation with unrecognized sessionKey does not bleed rows from other sessions", async () => {
278
- const dbModule = await loadDbModule();
279
- try {
280
- dbModule.getDb();
281
- dbModule.logConversation("user", "Session A content", "web", "project:/repo/a");
282
- dbModule.logConversation("user", "Default content", "web", "default");
283
- // An entirely different sessionKey that has never been written to
284
- const ghost = dbModule.getRecentConversation(20, "project:/repo/ghost");
285
- assert.equal(ghost, "", "unrecognized sessionKey should return '' not bleed other sessions' rows");
286
- }
287
- finally {
288
- dbModule.closeDb();
289
- }
290
- });
291
- // ---------------------------------------------------------------------------
292
- // 13. getTaskSessionKey returns 'default' for an unknown task_id
293
- // ---------------------------------------------------------------------------
294
- test("getTaskSessionKey returns 'default' for an unknown task_id", async () => {
295
- const dbModule = await loadDbModule();
296
- try {
297
- dbModule.getDb();
298
- const key = dbModule.getTaskSessionKey("nonexistent-task-xyz");
299
- assert.equal(key, "default", "unknown task_id should return fallback 'default'");
300
- }
301
- finally {
302
- dbModule.closeDb();
303
- }
304
- });
305
- // ---------------------------------------------------------------------------
306
- // 14. getTaskSessionKey falls back to 'default' on legacy schema (no session_key column)
307
- // ---------------------------------------------------------------------------
308
- test("getTaskSessionKey falls back to 'default' when session_key column is absent (legacy schema)", async () => {
309
- const dbModule = await loadDbModule();
310
- try {
311
- // Initialize the module (migrations run, session_key column added)
312
- dbModule.getDb();
313
- // Insert a task without session_key through the already-open connection
314
- const db = dbModule.getDb();
315
- db.exec(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('legacy-task-z', 'coder', 'Do work', 'done')`);
316
- // Drop and recreate agent_tasks WITHOUT the session_key column while the module
317
- // holds a reference — this simulates a legacy schema that the try/catch must handle.
318
- db.exec(`ALTER TABLE agent_tasks RENAME TO agent_tasks_bk`);
319
- db.exec(`
320
- CREATE TABLE agent_tasks (
321
- task_id TEXT PRIMARY KEY,
322
- agent_slug TEXT NOT NULL,
323
- description TEXT NOT NULL,
324
- status TEXT NOT NULL DEFAULT 'running',
325
- result TEXT,
326
- origin_channel TEXT,
327
- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
328
- completed_at DATETIME
329
- )
330
- `);
331
- db.exec(`INSERT INTO agent_tasks SELECT task_id, agent_slug, description, status, result, origin_channel, started_at, completed_at FROM agent_tasks_bk`);
332
- db.exec(`DROP TABLE agent_tasks_bk`);
333
- // Now getTaskSessionKey must fall back because session_key column doesn't exist
334
- const key = dbModule.getTaskSessionKey("legacy-task-z");
335
- assert.equal(key, "default", "getTaskSessionKey should return 'default' when session_key column is absent");
336
- }
337
- finally {
338
- dbModule.closeDb();
339
- }
340
- });
341
- //# sourceMappingURL=squad-sessions.test.js.map