chapterhouse 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/korg.js +5 -5
- package/dist/api/korg.test.js +3 -3
- package/dist/api/route-coverage.test.js +225 -0
- package/dist/api/server.js +321 -7
- package/dist/api/server.test.js +310 -3
- package/dist/shared/api-schemas.js +618 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BbX9RKf3.js → index-tBfBbEk5.js} +156 -93
- package/web/dist/assets/index-tBfBbEk5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BbX9RKf3.js.map +0 -1
package/dist/api/server.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import Database from "better-sqlite3";
|
|
7
|
+
import { z } from "zod";
|
|
7
8
|
import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
|
|
8
9
|
test("supports API_TOKEN env var and personal health route helpers", async () => {
|
|
9
10
|
const runtime = await import("./server-runtime.js");
|
|
@@ -83,6 +84,23 @@ test("formats named SSE status events", async () => {
|
|
|
83
84
|
assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
|
|
84
85
|
});
|
|
85
86
|
const repoRoot = process.cwd();
|
|
87
|
+
const wikiLinkResponseSchema = z.object({
|
|
88
|
+
links: z.array(z.object({
|
|
89
|
+
source_slug: z.string(),
|
|
90
|
+
target_slug: z.string(),
|
|
91
|
+
link_type: z.string(),
|
|
92
|
+
}).passthrough()),
|
|
93
|
+
});
|
|
94
|
+
const wikiSourcesResponseSchema = z.object({
|
|
95
|
+
sources: z.array(z.object({
|
|
96
|
+
source_url: z.string().optional(),
|
|
97
|
+
path: z.string().optional(),
|
|
98
|
+
kind: z.string(),
|
|
99
|
+
status: z.string(),
|
|
100
|
+
session_id: z.string().nullable().optional(),
|
|
101
|
+
ingested_at: z.string().nullable().optional(),
|
|
102
|
+
}).passthrough()),
|
|
103
|
+
});
|
|
86
104
|
function getProjectDbPath(testRoot) {
|
|
87
105
|
return join(testRoot, ".chapterhouse", "chapterhouse.db");
|
|
88
106
|
}
|
|
@@ -738,6 +756,24 @@ tags: [ops]
|
|
|
738
756
|
assert.deepEqual(await missingResponse.json(), { error: "Page not found" });
|
|
739
757
|
});
|
|
740
758
|
});
|
|
759
|
+
test("POST /api/wiki/page/pin rejects path traversal slugs", async () => {
|
|
760
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
761
|
+
const response = await fetch(`${baseUrl}/api/wiki/page/pin`, {
|
|
762
|
+
method: "POST",
|
|
763
|
+
headers: {
|
|
764
|
+
authorization: authHeader,
|
|
765
|
+
"content-type": "application/json",
|
|
766
|
+
},
|
|
767
|
+
body: JSON.stringify({
|
|
768
|
+
slug: "../../etc/passwd",
|
|
769
|
+
pinned: true,
|
|
770
|
+
}),
|
|
771
|
+
});
|
|
772
|
+
assert.equal(response.status, 400);
|
|
773
|
+
const body = await response.json();
|
|
774
|
+
assert.match(body.error ?? "", /unsafe wiki path/i);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
741
777
|
test("server worker detail returns the stored dispatched prompt", async () => {
|
|
742
778
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
743
779
|
const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
|
|
@@ -873,6 +909,128 @@ test("server wiki route still returns 404 for other missing wiki pages", async (
|
|
|
873
909
|
assert.deepEqual(await response.json(), { error: "Page not found" });
|
|
874
910
|
});
|
|
875
911
|
});
|
|
912
|
+
test("GET /api/wiki/browser-pages maps wiki_pages rows to the browser contract and filters them", async () => {
|
|
913
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
914
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
915
|
+
try {
|
|
916
|
+
db.prepare(`
|
|
917
|
+
INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated, pinned)
|
|
918
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
919
|
+
`).run("pages/topics/rust/index.md", "Rust", "topics", "[]", "Systems programming language", "2026-05-15T12:00:00.000Z", 1);
|
|
920
|
+
db.prepare(`
|
|
921
|
+
INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated, pinned)
|
|
922
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
923
|
+
`).run("pages/projects/chapterhouse/index.md", "Chapterhouse", "projects", "[]", "Team AI assistant", "2026-05-14T12:00:00.000Z", 0);
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
db.close();
|
|
927
|
+
}
|
|
928
|
+
const response = await fetch(`${baseUrl}/api/wiki/browser-pages?type=topics&q=systems`, {
|
|
929
|
+
headers: { authorization: authHeader },
|
|
930
|
+
});
|
|
931
|
+
assert.equal(response.status, 200);
|
|
932
|
+
assert.deepEqual(await response.json(), {
|
|
933
|
+
pages: [
|
|
934
|
+
{
|
|
935
|
+
slug: "topics/rust/index",
|
|
936
|
+
title: "Rust",
|
|
937
|
+
summary: "Systems programming language",
|
|
938
|
+
type: "topics",
|
|
939
|
+
last_updated: "2026-05-15T12:00:00.000Z",
|
|
940
|
+
pinned: true,
|
|
941
|
+
},
|
|
942
|
+
],
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
test("GET /api/wiki/browser-pages falls back to indexed and orphan wiki pages when wiki_pages is empty", async () => {
|
|
947
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
948
|
+
const indexedDir = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "fallback");
|
|
949
|
+
mkdirSync(indexedDir, { recursive: true });
|
|
950
|
+
writeFileSync(join(indexedDir, "index.md"), "# Fallback\n", "utf-8");
|
|
951
|
+
const orphanDir = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "orphan-browser");
|
|
952
|
+
mkdirSync(orphanDir, { recursive: true });
|
|
953
|
+
writeFileSync(join(orphanDir, "index.md"), "# Orphan Browser\n", "utf-8");
|
|
954
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
955
|
+
try {
|
|
956
|
+
db.prepare("DELETE FROM wiki_pages").run();
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
db.close();
|
|
960
|
+
}
|
|
961
|
+
const response = await fetch(`${baseUrl}/api/wiki/browser-pages?type=Unindexed&q=orphan`, {
|
|
962
|
+
headers: { authorization: authHeader },
|
|
963
|
+
});
|
|
964
|
+
assert.equal(response.status, 200);
|
|
965
|
+
const body = await response.json();
|
|
966
|
+
assert.deepEqual(body.pages.map((page) => ({
|
|
967
|
+
slug: page.slug,
|
|
968
|
+
title: page.title,
|
|
969
|
+
type: page.type,
|
|
970
|
+
})), [
|
|
971
|
+
{
|
|
972
|
+
slug: "projects/orphan-browser/index",
|
|
973
|
+
title: "pages/projects/orphan-browser/index.md",
|
|
974
|
+
type: "Unindexed",
|
|
975
|
+
},
|
|
976
|
+
]);
|
|
977
|
+
assert.notEqual(body.pages[0]?.last_updated, "");
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
test("GET /api/wiki/pages coerces empty updated fields to page mtimes", async () => {
|
|
981
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
982
|
+
const indexedPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated", "index.md");
|
|
983
|
+
const indexedMtime = new Date("2026-05-15T12:00:00.000Z");
|
|
984
|
+
mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated"), { recursive: true });
|
|
985
|
+
writeFileSync(indexedPath, "# Coerced Updated\n", "utf-8");
|
|
986
|
+
utimesSync(indexedPath, indexedMtime, indexedMtime);
|
|
987
|
+
const orphanPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated", "index.md");
|
|
988
|
+
const orphanMtime = new Date("2026-05-15T13:30:00.000Z");
|
|
989
|
+
mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated"), { recursive: true });
|
|
990
|
+
writeFileSync(orphanPath, "# Orphan Updated\n", "utf-8");
|
|
991
|
+
utimesSync(orphanPath, orphanMtime, orphanMtime);
|
|
992
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
993
|
+
try {
|
|
994
|
+
db.prepare(`
|
|
995
|
+
INSERT INTO wiki_pages (path, title, tags, summary, last_updated)
|
|
996
|
+
VALUES (?, ?, ?, ?, ?)
|
|
997
|
+
`).run("pages/topics/coerced-updated/index.md", "Coerced Updated", "[]", "Missing updated value", "");
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
db.close();
|
|
1001
|
+
}
|
|
1002
|
+
const response = await fetch(`${baseUrl}/api/wiki/pages`, {
|
|
1003
|
+
headers: { authorization: authHeader },
|
|
1004
|
+
});
|
|
1005
|
+
assert.equal(response.status, 200);
|
|
1006
|
+
const pages = await response.json();
|
|
1007
|
+
assert.equal(pages.find((page) => page.path === "pages/topics/coerced-updated/index.md")?.updated, indexedMtime.toISOString());
|
|
1008
|
+
assert.equal(pages.find((page) => page.path === "pages/topics/orphan-updated/index.md")?.updated, orphanMtime.toISOString());
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
test("GET /api/wiki/pages returns a non-empty updated value for pages with frontmatter", async () => {
|
|
1012
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1013
|
+
const pageDir = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "frontmatter-updated");
|
|
1014
|
+
mkdirSync(pageDir, { recursive: true });
|
|
1015
|
+
writeFileSync(join(pageDir, "index.md"), `---
|
|
1016
|
+
title: Frontmatter Updated
|
|
1017
|
+
summary: Has explicit metadata.
|
|
1018
|
+
updated: 2026-05-14
|
|
1019
|
+
---
|
|
1020
|
+
|
|
1021
|
+
# Frontmatter Updated
|
|
1022
|
+
`, "utf-8");
|
|
1023
|
+
const response = await fetch(`${baseUrl}/api/wiki/pages`, {
|
|
1024
|
+
headers: { authorization: authHeader },
|
|
1025
|
+
});
|
|
1026
|
+
assert.equal(response.status, 200);
|
|
1027
|
+
const pages = await response.json();
|
|
1028
|
+
const page = pages.find((entry) => entry.path === "pages/projects/frontmatter-updated/index.md");
|
|
1029
|
+
assert.ok(page, "expected frontmatter page to be listed");
|
|
1030
|
+
assert.equal(typeof page.updated, "string");
|
|
1031
|
+
assert.notEqual(page.updated, "");
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
876
1034
|
test("server projects route returns an empty list when the registry is missing", async () => {
|
|
877
1035
|
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
878
1036
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
|
@@ -1469,8 +1627,157 @@ test("GET /api/wiki/korg/sessions returns grouped active research sessions", asy
|
|
|
1469
1627
|
id: "compiler-research",
|
|
1470
1628
|
name: "Compiler research",
|
|
1471
1629
|
source_count: 2,
|
|
1472
|
-
|
|
1473
|
-
|
|
1630
|
+
open_questions_count: 0,
|
|
1631
|
+
last_activity_at: "2026-05-14T22:00:00.000Z",
|
|
1632
|
+
},
|
|
1633
|
+
],
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
test("GET /api/wiki/sources returns an empty sources payload when no sources exist", async () => {
|
|
1638
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
1639
|
+
const response = await fetch(`${baseUrl}/api/wiki/sources`, {
|
|
1640
|
+
headers: { authorization: authHeader },
|
|
1641
|
+
});
|
|
1642
|
+
assert.equal(response.status, 200);
|
|
1643
|
+
const body = await response.json();
|
|
1644
|
+
assert.deepEqual(body, { sources: [] });
|
|
1645
|
+
assert.ok(wikiSourcesResponseSchema.safeParse(body).success, "response should match WikiSourcesResponseSchema");
|
|
1646
|
+
});
|
|
1647
|
+
});
|
|
1648
|
+
test("GET /api/wiki/sources filters sources by page", async () => {
|
|
1649
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1650
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
1651
|
+
try {
|
|
1652
|
+
db.prepare(`
|
|
1653
|
+
INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
|
|
1654
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1655
|
+
`).run("src-filter-match", "pdf", "https://example.com/roadmap.pdf", "Roadmap", "2026-05-15T10:00:00.000Z", "sources/roadmap.pdf", "roadmap body", JSON.stringify(["pages/projects/wiki-api/index.md"]), "complete", "sess-1", "Wiki API");
|
|
1656
|
+
db.prepare(`
|
|
1657
|
+
INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
|
|
1658
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1659
|
+
`).run("src-filter-miss", "text", "notes about another page", "Other", "2026-05-15T11:00:00.000Z", "sources/other.md", "other body", JSON.stringify(["pages/projects/other/index.md"]), "active", null, null);
|
|
1660
|
+
}
|
|
1661
|
+
finally {
|
|
1662
|
+
db.close();
|
|
1663
|
+
}
|
|
1664
|
+
const response = await fetch(`${baseUrl}/api/wiki/sources?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
|
|
1665
|
+
headers: { authorization: authHeader },
|
|
1666
|
+
});
|
|
1667
|
+
assert.equal(response.status, 200);
|
|
1668
|
+
const body = await response.json();
|
|
1669
|
+
const parsed = wikiSourcesResponseSchema.safeParse(body);
|
|
1670
|
+
if (!parsed.success) {
|
|
1671
|
+
assert.fail(parsed.error.message);
|
|
1672
|
+
}
|
|
1673
|
+
assert.deepEqual(parsed.data, {
|
|
1674
|
+
sources: [
|
|
1675
|
+
{
|
|
1676
|
+
source_url: "https://example.com/roadmap.pdf",
|
|
1677
|
+
path: "sources/roadmap.pdf",
|
|
1678
|
+
kind: "pdf",
|
|
1679
|
+
status: "complete",
|
|
1680
|
+
session_id: "sess-1",
|
|
1681
|
+
ingested_at: "2026-05-15T10:00:00.000Z",
|
|
1682
|
+
},
|
|
1683
|
+
],
|
|
1684
|
+
});
|
|
1685
|
+
});
|
|
1686
|
+
});
|
|
1687
|
+
test("GET /api/wiki/links returns all links when no page filter is provided", async () => {
|
|
1688
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1689
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
1690
|
+
try {
|
|
1691
|
+
db.prepare(`
|
|
1692
|
+
INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
|
|
1693
|
+
VALUES (?, ?, ?, ?)
|
|
1694
|
+
`).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
|
|
1695
|
+
db.prepare(`
|
|
1696
|
+
INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
|
|
1697
|
+
VALUES (?, ?, ?, ?)
|
|
1698
|
+
`).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
|
|
1699
|
+
}
|
|
1700
|
+
finally {
|
|
1701
|
+
db.close();
|
|
1702
|
+
}
|
|
1703
|
+
const response = await fetch(`${baseUrl}/api/wiki/links`, {
|
|
1704
|
+
headers: { authorization: authHeader },
|
|
1705
|
+
});
|
|
1706
|
+
assert.equal(response.status, 200);
|
|
1707
|
+
const body = await response.json();
|
|
1708
|
+
const parsed = wikiLinkResponseSchema.safeParse(body);
|
|
1709
|
+
if (!parsed.success) {
|
|
1710
|
+
assert.fail(parsed.error.message);
|
|
1711
|
+
}
|
|
1712
|
+
assert.deepEqual(parsed.data, {
|
|
1713
|
+
links: [
|
|
1714
|
+
{
|
|
1715
|
+
source_slug: "trinity",
|
|
1716
|
+
target_slug: "wiki-api",
|
|
1717
|
+
link_type: "implements",
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
source_slug: "wiki-api",
|
|
1721
|
+
target_slug: "contracts",
|
|
1722
|
+
link_type: "references",
|
|
1723
|
+
},
|
|
1724
|
+
],
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
test("GET /api/wiki/links returns an empty typed links payload when a page has no graph edges", async () => {
|
|
1729
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
1730
|
+
const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
|
|
1731
|
+
headers: { authorization: authHeader },
|
|
1732
|
+
});
|
|
1733
|
+
assert.equal(response.status, 200);
|
|
1734
|
+
const body = await response.json();
|
|
1735
|
+
assert.deepEqual(body, { links: [] });
|
|
1736
|
+
assert.ok(wikiLinkResponseSchema.safeParse(body).success, "response should match WikiLinksResponseSchema");
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
test("GET /api/wiki/links filters incoming and outgoing links for a page", async () => {
|
|
1740
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1741
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
1742
|
+
try {
|
|
1743
|
+
db.prepare(`
|
|
1744
|
+
INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
|
|
1745
|
+
VALUES (?, ?, ?, ?)
|
|
1746
|
+
`).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
|
|
1747
|
+
db.prepare(`
|
|
1748
|
+
INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
|
|
1749
|
+
VALUES (?, ?, ?, ?)
|
|
1750
|
+
`).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
|
|
1751
|
+
db.prepare(`
|
|
1752
|
+
INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
|
|
1753
|
+
VALUES (?, ?, ?, ?)
|
|
1754
|
+
`).run("pages/projects/other/index.md", "pages/topics/unrelated/index.md", "references", "2026-05-15T12:02:00.000Z");
|
|
1755
|
+
}
|
|
1756
|
+
finally {
|
|
1757
|
+
db.close();
|
|
1758
|
+
}
|
|
1759
|
+
const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
|
|
1760
|
+
headers: { authorization: authHeader },
|
|
1761
|
+
});
|
|
1762
|
+
assert.equal(response.status, 200);
|
|
1763
|
+
const body = await response.json();
|
|
1764
|
+
const parsed = wikiLinkResponseSchema.safeParse(body);
|
|
1765
|
+
if (!parsed.success) {
|
|
1766
|
+
assert.fail(parsed.error.message);
|
|
1767
|
+
}
|
|
1768
|
+
assert.deepEqual(parsed.data, {
|
|
1769
|
+
links: [
|
|
1770
|
+
{
|
|
1771
|
+
source_slug: "trinity",
|
|
1772
|
+
target_slug: "wiki-api",
|
|
1773
|
+
link_type: "implements",
|
|
1774
|
+
direction: "incoming",
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
source_slug: "wiki-api",
|
|
1778
|
+
target_slug: "contracts",
|
|
1779
|
+
link_type: "references",
|
|
1780
|
+
direction: "outgoing",
|
|
1474
1781
|
},
|
|
1475
1782
|
],
|
|
1476
1783
|
});
|