chapterhouse 0.3.23 → 0.3.25
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 +52 -15
- package/dist/api/server.test.js +67 -94
- package/dist/api/worker-events-sse.integration.test.js +241 -1
- package/dist/copilot/orchestrator.js +2 -0
- package/dist/copilot/task-event-log.js +6 -1
- package/dist/copilot/task-event-log.test.js +45 -0
- package/dist/copilot/tools.js +39 -4
- package/dist/store/db.js +53 -7
- package/dist/store/db.test.js +57 -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-BK-hInnO.js → index-BRPJa1DK.js} +83 -83
- package/web/dist/assets/{index-BK-hInnO.js.map → index-BRPJa1DK.js.map} +1 -1
- package/web/dist/assets/index-DhY5yWmC.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D__tBB0X.css +0 -10
package/dist/copilot/tools.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { approveAll, defineTool } from "@github/copilot-sdk";
|
|
3
|
-
import { getDb } from "../store/db.js";
|
|
3
|
+
import { getDb, appendTaskOutputDeltaEvent, appendTaskStatusEvent, updateTaskResult } from "../store/db.js";
|
|
4
4
|
import { readdirSync, readFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
|
+
import { agentEventBus } from "./agent-event-bus.js";
|
|
9
10
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
10
11
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
11
12
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
@@ -137,6 +138,34 @@ export function createTools(deps) {
|
|
|
137
138
|
// `executeOnSession` finishes.
|
|
138
139
|
const parentActivity = getCurrentActivityCallback();
|
|
139
140
|
const childUnsubs = [];
|
|
141
|
+
const emitTaskLogEvent = (taskEvent) => {
|
|
142
|
+
void agentEventBus.emit({
|
|
143
|
+
type: "session:tool_call",
|
|
144
|
+
sessionId: task.taskId,
|
|
145
|
+
payload: {
|
|
146
|
+
toolName: "",
|
|
147
|
+
toolArgs: {},
|
|
148
|
+
_kind: taskEvent.kind,
|
|
149
|
+
_seq: taskEvent.seq,
|
|
150
|
+
_ts: taskEvent.ts,
|
|
151
|
+
_summary: taskEvent.summary,
|
|
152
|
+
_text: taskEvent.text,
|
|
153
|
+
_status: taskEvent.status,
|
|
154
|
+
},
|
|
155
|
+
timestamp: new Date(taskEvent.ts),
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
let workerOutput = "";
|
|
159
|
+
childUnsubs.push(session.on("assistant.message_delta", (event) => {
|
|
160
|
+
const delta = typeof event.data.deltaContent === "string" ? event.data.deltaContent : "";
|
|
161
|
+
if (!delta)
|
|
162
|
+
return;
|
|
163
|
+
workerOutput += delta;
|
|
164
|
+
const taskEvent = appendTaskOutputDeltaEvent(task.taskId, delta);
|
|
165
|
+
if (!taskEvent)
|
|
166
|
+
return;
|
|
167
|
+
emitTaskLogEvent(taskEvent);
|
|
168
|
+
}));
|
|
140
169
|
if (parentActivity) {
|
|
141
170
|
childUnsubs.push(session.on("assistant.reasoning_delta", (event) => {
|
|
142
171
|
parentActivity({
|
|
@@ -179,15 +208,21 @@ export function createTools(deps) {
|
|
|
179
208
|
(async () => {
|
|
180
209
|
try {
|
|
181
210
|
const result = await session.sendAndWait({ prompt: taskPrompt }, timeoutMs);
|
|
182
|
-
const output = result?.data?.content || "No response";
|
|
211
|
+
const output = workerOutput || result?.data?.content || "No response";
|
|
183
212
|
completeTask(task.taskId, output);
|
|
184
|
-
|
|
213
|
+
updateTaskResult(task.taskId, "completed", output);
|
|
214
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
|
|
215
|
+
if (statusEvent)
|
|
216
|
+
emitTaskLogEvent(statusEvent);
|
|
185
217
|
deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
|
|
186
218
|
}
|
|
187
219
|
catch (err) {
|
|
188
220
|
const msg = err instanceof Error ? err.message : String(err);
|
|
189
221
|
failTask(task.taskId, msg);
|
|
190
|
-
|
|
222
|
+
updateTaskResult(task.taskId, "error", msg);
|
|
223
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
|
|
224
|
+
if (statusEvent)
|
|
225
|
+
emitTaskLogEvent(statusEvent);
|
|
191
226
|
deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
|
|
192
227
|
}
|
|
193
228
|
finally {
|
package/dist/store/db.js
CHANGED
|
@@ -44,6 +44,14 @@ export function getDb() {
|
|
|
44
44
|
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
45
45
|
completed_at DATETIME
|
|
46
46
|
)
|
|
47
|
+
`);
|
|
48
|
+
db.exec(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
50
|
+
slug TEXT PRIMARY KEY,
|
|
51
|
+
cwd TEXT NOT NULL,
|
|
52
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
53
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
54
|
+
)
|
|
47
55
|
`);
|
|
48
56
|
db.exec(`
|
|
49
57
|
CREATE TABLE IF NOT EXISTS max_state (
|
|
@@ -118,19 +126,45 @@ export function getDb() {
|
|
|
118
126
|
if (!taskCols.some((c) => c.name === "prompt")) {
|
|
119
127
|
db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
|
|
120
128
|
}
|
|
121
|
-
// agent_task_events: append-only per-task
|
|
129
|
+
// agent_task_events: append-only per-task activity log for /workers streaming
|
|
122
130
|
db.exec(`
|
|
123
131
|
CREATE TABLE IF NOT EXISTS agent_task_events (
|
|
124
132
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
133
|
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
126
134
|
seq INTEGER NOT NULL,
|
|
127
135
|
ts INTEGER NOT NULL,
|
|
128
|
-
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
|
|
136
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
|
|
129
137
|
tool_name TEXT,
|
|
130
|
-
summary TEXT
|
|
138
|
+
summary TEXT,
|
|
139
|
+
text TEXT,
|
|
140
|
+
status TEXT
|
|
131
141
|
)
|
|
132
142
|
`);
|
|
133
143
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
144
|
+
// Migrate existing agent_task_events tables that lack text/status columns
|
|
145
|
+
const taskEventCols = db.prepare(`PRAGMA table_info(agent_task_events)`).all();
|
|
146
|
+
if (!taskEventCols.some((c) => c.name === "text") || !taskEventCols.some((c) => c.name === "status")) {
|
|
147
|
+
db.exec(`ALTER TABLE agent_task_events RENAME TO agent_task_events_old`);
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE TABLE agent_task_events (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
152
|
+
seq INTEGER NOT NULL,
|
|
153
|
+
ts INTEGER NOT NULL,
|
|
154
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
|
|
155
|
+
tool_name TEXT,
|
|
156
|
+
summary TEXT,
|
|
157
|
+
text TEXT,
|
|
158
|
+
status TEXT
|
|
159
|
+
)
|
|
160
|
+
`);
|
|
161
|
+
db.exec(`
|
|
162
|
+
INSERT INTO agent_task_events (id, task_id, seq, ts, kind, tool_name, summary)
|
|
163
|
+
SELECT id, task_id, seq, ts, kind, tool_name, summary FROM agent_task_events_old
|
|
164
|
+
`);
|
|
165
|
+
db.exec(`DROP TABLE agent_task_events_old`);
|
|
166
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
167
|
+
}
|
|
134
168
|
// Migrate: add event_seq column to agent_tasks for monotonic event numbering
|
|
135
169
|
const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
136
170
|
if (!taskColsNow.some((c) => c.name === 'event_seq')) {
|
|
@@ -334,7 +368,7 @@ export function getSessionMessages(sessionKey, limit) {
|
|
|
334
368
|
* Uses a transaction so seq is monotonically incremented.
|
|
335
369
|
* Non-fatal: silently ignores DB errors (task may not exist yet due to race).
|
|
336
370
|
*/
|
|
337
|
-
export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
371
|
+
export function appendTaskEvent(taskId, kind, toolName, summary, text = null, status = null) {
|
|
338
372
|
const db = getDb();
|
|
339
373
|
try {
|
|
340
374
|
return db.transaction(() => {
|
|
@@ -344,20 +378,30 @@ export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
|
344
378
|
return undefined;
|
|
345
379
|
const seq = row.event_seq;
|
|
346
380
|
const ts = Date.now();
|
|
347
|
-
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
|
|
348
|
-
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
|
|
381
|
+
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary, text, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary, text, status);
|
|
382
|
+
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary, text, status };
|
|
349
383
|
})();
|
|
350
384
|
}
|
|
351
385
|
catch {
|
|
352
386
|
return undefined;
|
|
353
387
|
}
|
|
354
388
|
}
|
|
389
|
+
export function appendTaskOutputDeltaEvent(taskId, text) {
|
|
390
|
+
return appendTaskEvent(taskId, "output_delta", null, null, text, null);
|
|
391
|
+
}
|
|
392
|
+
export function appendTaskStatusEvent(taskId, status, summary = null) {
|
|
393
|
+
return appendTaskEvent(taskId, "task_status", null, summary, null, status);
|
|
394
|
+
}
|
|
395
|
+
export function updateTaskResult(taskId, status, result) {
|
|
396
|
+
const db = getDb();
|
|
397
|
+
db.prepare(`UPDATE agent_tasks SET status = ?, result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(status, result ? result.slice(0, 10000) : null, taskId);
|
|
398
|
+
}
|
|
355
399
|
/**
|
|
356
400
|
* Return all events for a task ordered by seq ascending.
|
|
357
401
|
*/
|
|
358
402
|
export function getTaskEvents(taskId, afterSeq = 0) {
|
|
359
403
|
const db = getDb();
|
|
360
|
-
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
|
|
404
|
+
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary, text, status
|
|
361
405
|
FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
|
|
362
406
|
return rows.map((r) => ({
|
|
363
407
|
id: r.id,
|
|
@@ -367,6 +411,8 @@ export function getTaskEvents(taskId, afterSeq = 0) {
|
|
|
367
411
|
kind: r.kind,
|
|
368
412
|
toolName: r.tool_name,
|
|
369
413
|
summary: r.summary,
|
|
414
|
+
text: r.text,
|
|
415
|
+
status: r.status,
|
|
370
416
|
}));
|
|
371
417
|
}
|
|
372
418
|
export function closeDb() {
|
package/dist/store/db.test.js
CHANGED
|
@@ -32,6 +32,7 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
|
|
|
32
32
|
"worker_sessions",
|
|
33
33
|
"agent_sessions",
|
|
34
34
|
"agent_tasks",
|
|
35
|
+
"projects",
|
|
35
36
|
"max_state",
|
|
36
37
|
"conversation_log",
|
|
37
38
|
"memories",
|
|
@@ -265,6 +266,62 @@ test("#86: agent_task_events table exists in schema after getDb()", async () =>
|
|
|
265
266
|
dbModule.closeDb();
|
|
266
267
|
}
|
|
267
268
|
});
|
|
269
|
+
test("#158: appendTaskOutputDeltaEvent writes output_delta event with text", async () => {
|
|
270
|
+
const dbModule = await loadDbModule();
|
|
271
|
+
try {
|
|
272
|
+
const db = dbModule.getDb();
|
|
273
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-delta-001", "coder", "delta test", "running");
|
|
274
|
+
const ev = dbModule.appendTaskOutputDeltaEvent("task-delta-001", "Hello world");
|
|
275
|
+
assert.ok(ev, "appendTaskOutputDeltaEvent must return the event");
|
|
276
|
+
assert.equal(ev.kind, "output_delta");
|
|
277
|
+
assert.equal(ev.text, "Hello world");
|
|
278
|
+
assert.equal(ev.toolName, null);
|
|
279
|
+
assert.equal(ev.status, null);
|
|
280
|
+
assert.equal(ev.seq, 1);
|
|
281
|
+
const events = dbModule.getTaskEvents("task-delta-001");
|
|
282
|
+
assert.equal(events.length, 1);
|
|
283
|
+
assert.equal(events[0].text, "Hello world");
|
|
284
|
+
assert.equal(events[0].kind, "output_delta");
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
dbModule.closeDb();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
test("#158: appendTaskStatusEvent writes task_status event with status field", async () => {
|
|
291
|
+
const dbModule = await loadDbModule();
|
|
292
|
+
try {
|
|
293
|
+
const db = dbModule.getDb();
|
|
294
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-status-001", "coder", "status test", "running");
|
|
295
|
+
const ev = dbModule.appendTaskStatusEvent("task-status-001", "completed", "All done");
|
|
296
|
+
assert.ok(ev, "appendTaskStatusEvent must return the event");
|
|
297
|
+
assert.equal(ev.kind, "task_status");
|
|
298
|
+
assert.equal(ev.status, "completed");
|
|
299
|
+
assert.equal(ev.summary, "All done");
|
|
300
|
+
assert.equal(ev.text, null);
|
|
301
|
+
assert.equal(ev.toolName, null);
|
|
302
|
+
const events = dbModule.getTaskEvents("task-status-001");
|
|
303
|
+
assert.equal(events.length, 1);
|
|
304
|
+
assert.equal(events[0].status, "completed");
|
|
305
|
+
assert.equal(events[0].kind, "task_status");
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
dbModule.closeDb();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
test("#158: updateTaskResult updates agent_tasks status and result", async () => {
|
|
312
|
+
const dbModule = await loadDbModule();
|
|
313
|
+
try {
|
|
314
|
+
const db = dbModule.getDb();
|
|
315
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-result-001", "coder", "result test", "running");
|
|
316
|
+
dbModule.updateTaskResult("task-result-001", "completed", "output text");
|
|
317
|
+
const row = db.prepare("SELECT status, result FROM agent_tasks WHERE task_id = ?").get("task-result-001");
|
|
318
|
+
assert.equal(row.status, "completed");
|
|
319
|
+
assert.equal(row.result, "output text");
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
dbModule.closeDb();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
268
325
|
// ---------------------------------------------------------------------------
|
|
269
326
|
// normalizeSqliteTsToIso — unit tests
|
|
270
327
|
// ---------------------------------------------------------------------------
|
|
@@ -1,47 +1,91 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { getDb } from "../store/db.js";
|
|
3
|
+
import { childLogger } from "../util/logger.js";
|
|
4
|
+
import { deletePage, pageExists, readPage } from "./fs.js";
|
|
5
|
+
const log = childLogger("project-registry");
|
|
6
|
+
const LEGACY_PROJECTS_INDEX_PATH = "pages/projects/index.md";
|
|
4
7
|
const REGISTRY_HEADING = "## Project Registry";
|
|
5
8
|
const OPENING_FENCE = "```yaml";
|
|
6
9
|
const CLOSING_FENCE = "```";
|
|
7
10
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
8
11
|
export function loadRegistry() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return {};
|
|
15
|
-
return parseRegistryBlock(section.blockLines);
|
|
12
|
+
ensureRegistryMigrated();
|
|
13
|
+
const rows = getDb()
|
|
14
|
+
.prepare("SELECT slug, cwd FROM projects ORDER BY slug")
|
|
15
|
+
.all();
|
|
16
|
+
return Object.fromEntries(rows.map(({ slug, cwd }) => [slug, cwd]));
|
|
16
17
|
}
|
|
17
18
|
export function saveRegistry(registry) {
|
|
18
19
|
validateRegistry(registry);
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
ensureRegistryMigrated();
|
|
21
|
+
const entries = Object.entries(registry).sort(([left], [right]) => left.localeCompare(right));
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const save = db.transaction(() => {
|
|
24
|
+
db.prepare("DELETE FROM projects").run();
|
|
25
|
+
const insert = db.prepare(`
|
|
26
|
+
INSERT INTO projects (slug, cwd, created_at, updated_at)
|
|
27
|
+
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
28
|
+
`);
|
|
29
|
+
for (const [slug, cwd] of entries) {
|
|
30
|
+
insert.run(slug, cwd);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
save();
|
|
34
|
+
removeLegacyRegistryFile("Removed legacy wiki registry after SQLite save");
|
|
35
|
+
}
|
|
36
|
+
export function assertValidProjectSlug(slug) {
|
|
37
|
+
if (!SLUG_RE.test(slug)) {
|
|
38
|
+
throw new Error(`Project registry has invalid project slug '${slug}'. Expected a lowercase slug.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function ensureRegistryMigrated() {
|
|
42
|
+
const db = getDb();
|
|
43
|
+
let migratedRegistry;
|
|
44
|
+
const migrate = db.transaction(() => {
|
|
45
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM projects").get();
|
|
46
|
+
if (row.count > 0 || !pageExists(LEGACY_PROJECTS_INDEX_PATH)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const legacyContent = readPage(LEGACY_PROJECTS_INDEX_PATH);
|
|
50
|
+
if (!legacyContent) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const registry = parseLegacyRegistry(legacyContent);
|
|
54
|
+
if (!registry) {
|
|
55
|
+
log.warn({ path: LEGACY_PROJECTS_INDEX_PATH }, "Legacy projects page had no registry section; skipping SQLite migration");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const insert = db.prepare(`
|
|
59
|
+
INSERT INTO projects (slug, cwd, created_at, updated_at)
|
|
60
|
+
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
61
|
+
`);
|
|
62
|
+
for (const [slug, cwd] of Object.entries(registry).sort(([left], [right]) => left.localeCompare(right))) {
|
|
63
|
+
insert.run(slug, cwd);
|
|
64
|
+
}
|
|
65
|
+
migratedRegistry = registry;
|
|
66
|
+
});
|
|
67
|
+
migrate.immediate();
|
|
68
|
+
if (!migratedRegistry) {
|
|
23
69
|
return;
|
|
24
70
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
writePage(PROJECTS_INDEX_PATH, `${prefix}${renderedSection}\n`);
|
|
71
|
+
removeLegacyRegistryFile("Migrated project registry from wiki to SQLite", Object.keys(migratedRegistry).length);
|
|
72
|
+
}
|
|
73
|
+
function removeLegacyRegistryFile(message, count) {
|
|
74
|
+
if (!deletePage(LEGACY_PROJECTS_INDEX_PATH)) {
|
|
30
75
|
return;
|
|
31
76
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (before) {
|
|
36
|
-
pieces.push(before);
|
|
37
|
-
pieces.push("");
|
|
77
|
+
if (typeof count === "number") {
|
|
78
|
+
log.info({ count, path: LEGACY_PROJECTS_INDEX_PATH }, message);
|
|
79
|
+
return;
|
|
38
80
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
log.info({ path: LEGACY_PROJECTS_INDEX_PATH }, message);
|
|
82
|
+
}
|
|
83
|
+
function parseLegacyRegistry(content) {
|
|
84
|
+
const section = parseRegistrySection(content);
|
|
85
|
+
if (!section) {
|
|
86
|
+
return undefined;
|
|
43
87
|
}
|
|
44
|
-
|
|
88
|
+
return parseRegistryBlock(section.blockLines);
|
|
45
89
|
}
|
|
46
90
|
function parseRegistrySection(content) {
|
|
47
91
|
const normalized = normalizeLineEndings(content);
|
|
@@ -81,11 +125,7 @@ function parseRegistrySection(content) {
|
|
|
81
125
|
throw new Error("Project registry is malformed: unexpected content after the fenced block.");
|
|
82
126
|
}
|
|
83
127
|
}
|
|
84
|
-
return {
|
|
85
|
-
before: lines.slice(0, headingIndex),
|
|
86
|
-
blockLines,
|
|
87
|
-
after: lines.slice(sectionEnd),
|
|
88
|
-
};
|
|
128
|
+
return { blockLines };
|
|
89
129
|
}
|
|
90
130
|
function parseRegistryBlock(lines) {
|
|
91
131
|
const registry = {};
|
|
@@ -114,28 +154,11 @@ function validateRegistry(registry) {
|
|
|
114
154
|
validatePath(path);
|
|
115
155
|
}
|
|
116
156
|
}
|
|
117
|
-
export function assertValidProjectSlug(slug) {
|
|
118
|
-
if (!SLUG_RE.test(slug)) {
|
|
119
|
-
throw new Error(`Project registry has invalid project slug '${slug}'. Expected a lowercase slug.`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
157
|
function validatePath(path) {
|
|
123
158
|
if (!path || !isAbsolute(path)) {
|
|
124
159
|
throw new Error(`Project registry path '${path}' must be an absolute path.`);
|
|
125
160
|
}
|
|
126
161
|
}
|
|
127
|
-
function renderRegistrySection(registry) {
|
|
128
|
-
const lines = [
|
|
129
|
-
REGISTRY_HEADING,
|
|
130
|
-
"",
|
|
131
|
-
OPENING_FENCE,
|
|
132
|
-
...Object.keys(registry)
|
|
133
|
-
.sort()
|
|
134
|
-
.map((slug) => `${slug}: ${registry[slug]}`),
|
|
135
|
-
CLOSING_FENCE,
|
|
136
|
-
];
|
|
137
|
-
return lines.join("\n");
|
|
138
|
-
}
|
|
139
162
|
function findNextHeading(lines, startIndex) {
|
|
140
163
|
for (let index = startIndex; index < lines.length; index += 1) {
|
|
141
164
|
if (/^##\s+/.test(lines[index])) {
|
|
@@ -147,14 +170,4 @@ function findNextHeading(lines, startIndex) {
|
|
|
147
170
|
function normalizeLineEndings(content) {
|
|
148
171
|
return content.replace(/\r\n/g, "\n");
|
|
149
172
|
}
|
|
150
|
-
function stripTrailingBlankLines(content) {
|
|
151
|
-
return content.replace(/\n+$/g, "");
|
|
152
|
-
}
|
|
153
|
-
function stripLeadingBlankLines(lines) {
|
|
154
|
-
let start = 0;
|
|
155
|
-
while (start < lines.length && lines[start].trim() === "") {
|
|
156
|
-
start += 1;
|
|
157
|
-
}
|
|
158
|
-
return lines.slice(start);
|
|
159
|
-
}
|
|
160
173
|
//# sourceMappingURL=project-registry.js.map
|
|
@@ -6,67 +6,115 @@ const repoRoot = process.cwd();
|
|
|
6
6
|
const sandboxRoot = join(repoRoot, ".test-work", `wiki-project-registry-${process.pid}`);
|
|
7
7
|
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
8
8
|
async function loadModules() {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
return { projectRegistry, wikiFs };
|
|
9
|
+
const projectRegistry = await import(new URL("./project-registry.js", import.meta.url).href);
|
|
10
|
+
const wikiFs = await import(new URL("./fs.js", import.meta.url).href);
|
|
11
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
12
|
+
return { projectRegistry, wikiFs, dbModule };
|
|
13
13
|
}
|
|
14
14
|
function resetSandbox() {
|
|
15
15
|
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
16
16
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
17
17
|
}
|
|
18
|
-
test.beforeEach(() => {
|
|
18
|
+
test.beforeEach(async () => {
|
|
19
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
20
|
+
dbModule.closeDb();
|
|
19
21
|
resetSandbox();
|
|
20
22
|
});
|
|
21
|
-
test.after(() => {
|
|
23
|
+
test.after(async () => {
|
|
24
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
25
|
+
dbModule.closeDb();
|
|
22
26
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
23
27
|
});
|
|
24
|
-
test("loadRegistry returns an empty object when
|
|
25
|
-
const { projectRegistry } = await loadModules();
|
|
26
|
-
|
|
28
|
+
test("loadRegistry returns an empty object when the projects table is empty", async () => {
|
|
29
|
+
const { projectRegistry, dbModule } = await loadModules();
|
|
30
|
+
try {
|
|
31
|
+
assert.deepEqual(projectRegistry.loadRegistry(), {});
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
dbModule.closeDb();
|
|
35
|
+
}
|
|
27
36
|
});
|
|
28
|
-
test("
|
|
29
|
-
const { projectRegistry, wikiFs } = await loadModules();
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
test("saveRegistry persists the registry in SQLite", async () => {
|
|
38
|
+
const { projectRegistry, dbModule, wikiFs } = await loadModules();
|
|
39
|
+
try {
|
|
40
|
+
projectRegistry.saveRegistry({
|
|
41
|
+
"docs-site": "/home/bjk/projects/docs-site",
|
|
42
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
43
|
+
});
|
|
44
|
+
assert.deepEqual(projectRegistry.loadRegistry(), {
|
|
45
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
46
|
+
"docs-site": "/home/bjk/projects/docs-site",
|
|
47
|
+
});
|
|
48
|
+
assert.equal(wikiFs.readPage("pages/projects/index.md"), undefined);
|
|
49
|
+
assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
|
|
50
|
+
{ slug: "chapterhouse", cwd: "/home/bjk/projects/chapterhouse" },
|
|
51
|
+
{ slug: "docs-site", cwd: "/home/bjk/projects/docs-site" },
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
dbModule.closeDb();
|
|
56
|
+
}
|
|
32
57
|
});
|
|
33
|
-
test("
|
|
34
|
-
const { projectRegistry,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
test("saveRegistry replaces prior SQLite-backed registry contents", async () => {
|
|
59
|
+
const { projectRegistry, dbModule } = await loadModules();
|
|
60
|
+
try {
|
|
61
|
+
projectRegistry.saveRegistry({
|
|
62
|
+
alpha: "/srv/alpha",
|
|
63
|
+
zeta: "/srv/zeta",
|
|
64
|
+
});
|
|
65
|
+
projectRegistry.saveRegistry({
|
|
66
|
+
beta: "/srv/beta",
|
|
67
|
+
alpha: "/srv/alpha",
|
|
68
|
+
});
|
|
69
|
+
assert.deepEqual(projectRegistry.loadRegistry(), {
|
|
70
|
+
alpha: "/srv/alpha",
|
|
71
|
+
beta: "/srv/beta",
|
|
72
|
+
});
|
|
73
|
+
assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
|
|
74
|
+
{ slug: "alpha", cwd: "/srv/alpha" },
|
|
75
|
+
{ slug: "beta", cwd: "/srv/beta" },
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
dbModule.closeDb();
|
|
80
|
+
}
|
|
40
81
|
});
|
|
41
|
-
test("loadRegistry
|
|
42
|
-
const { projectRegistry, wikiFs } = await loadModules();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
test("loadRegistry migrates the legacy wiki registry into SQLite and removes the file", async () => {
|
|
83
|
+
const { projectRegistry, wikiFs, dbModule } = await loadModules();
|
|
84
|
+
try {
|
|
85
|
+
wikiFs.writePage("pages/projects/index.md", "---\n"
|
|
86
|
+
+ "title: Projects\n"
|
|
87
|
+
+ "summary: Canonical project registry.\n"
|
|
88
|
+
+ "updated: 2026-05-12\n"
|
|
89
|
+
+ "---\n\n"
|
|
90
|
+
+ "# Projects\n\n"
|
|
91
|
+
+ "## Project Registry\n\n"
|
|
92
|
+
+ "```yaml\n"
|
|
93
|
+
+ "chapterhouse: /home/bjk/projects/chapterhouse\n"
|
|
94
|
+
+ "docs-site: /home/bjk/Documents/docs site\n"
|
|
95
|
+
+ "```\n");
|
|
96
|
+
assert.deepEqual(projectRegistry.loadRegistry(), {
|
|
97
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
98
|
+
"docs-site": "/home/bjk/Documents/docs site",
|
|
99
|
+
});
|
|
100
|
+
assert.equal(wikiFs.pageExists("pages/projects/index.md"), false);
|
|
101
|
+
assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
|
|
102
|
+
{ slug: "chapterhouse", cwd: "/home/bjk/projects/chapterhouse" },
|
|
103
|
+
{ slug: "docs-site", cwd: "/home/bjk/Documents/docs site" },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
dbModule.closeDb();
|
|
108
|
+
}
|
|
66
109
|
});
|
|
67
110
|
test("saveRegistry rejects invalid slugs and non-absolute paths", async () => {
|
|
68
|
-
const { projectRegistry } = await loadModules();
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
const { projectRegistry, dbModule } = await loadModules();
|
|
112
|
+
try {
|
|
113
|
+
assert.throws(() => projectRegistry.saveRegistry({ ChapterHouse: "/home/bjk/projects/chapterhouse" }), /invalid project slug/);
|
|
114
|
+
assert.throws(() => projectRegistry.saveRegistry({ chapterhouse: "./relative" }), /absolute path/);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
dbModule.closeDb();
|
|
118
|
+
}
|
|
71
119
|
});
|
|
72
120
|
//# sourceMappingURL=project-registry.test.js.map
|
package/package.json
CHANGED