chapterhouse 0.5.1 → 0.6.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/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/api/server.js +5 -3
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +73 -0
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +31 -212
- package/dist/copilot/orchestrator.test.js +111 -0
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +60 -18
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +65 -39
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +95 -3
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +162 -95
- package/dist/setup.test.js +139 -0
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +6 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
- package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
- package/web/dist/assets/index-DknKAtDS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
package/dist/copilot/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ 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 { ModeContext } from "../mode-context.js";
|
|
9
10
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
10
11
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
11
12
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
@@ -31,6 +32,7 @@ import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
|
31
32
|
import { childLogger } from "../util/logger.js";
|
|
32
33
|
import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
33
34
|
const log = childLogger("tools");
|
|
35
|
+
const modeContext = new ModeContext(config);
|
|
34
36
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
37
|
function yamlEscape(value) {
|
|
36
38
|
// Always quote and escape backslashes, double quotes, and newlines.
|
|
@@ -51,6 +53,21 @@ function yamlListItem(value) {
|
|
|
51
53
|
function indexSafe(text) {
|
|
52
54
|
return text.replace(/[\r\n|]/g, " ").trim();
|
|
53
55
|
}
|
|
56
|
+
function sanitizeWikiUpdateError(err) {
|
|
57
|
+
if (err instanceof z.ZodError) {
|
|
58
|
+
return err.issues.map((issue) => issue.message).join("; ") || "Invalid wiki_update arguments.";
|
|
59
|
+
}
|
|
60
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
61
|
+
if (message.startsWith("Wiki page frontmatter violates the required shape:")
|
|
62
|
+
|| message.startsWith("Wiki path")
|
|
63
|
+
|| message.startsWith("Wiki page paths must end in .md:")
|
|
64
|
+
|| message.startsWith("Refused unsafe wiki path:")
|
|
65
|
+
|| message.startsWith("Refused: only pages under pages/")
|
|
66
|
+
|| message === "Wiki path is required") {
|
|
67
|
+
return message;
|
|
68
|
+
}
|
|
69
|
+
return "Wiki update failed. Check the page path and frontmatter, then try again.";
|
|
70
|
+
}
|
|
54
71
|
function isTimeoutError(err) {
|
|
55
72
|
const msg = err instanceof Error ? err.message : String(err);
|
|
56
73
|
return /timeout|timed?\s*out/i.test(msg);
|
|
@@ -187,6 +204,13 @@ const memoryProposeArgsSchema = z.object({
|
|
|
187
204
|
}
|
|
188
205
|
});
|
|
189
206
|
const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
|
|
207
|
+
const wikiUpdateArgsSchema = z.object({
|
|
208
|
+
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
|
|
209
|
+
title: z.string().describe("Page title for the index"),
|
|
210
|
+
summary: z.string().describe("One-line summary for the index"),
|
|
211
|
+
section: z.string().optional().describe("Index section (default: 'Knowledge')"),
|
|
212
|
+
content: z.string().describe("Full page content (markdown)"),
|
|
213
|
+
});
|
|
190
214
|
function getCurrentQuarter(now = new Date()) {
|
|
191
215
|
return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
|
|
192
216
|
}
|
|
@@ -536,7 +560,7 @@ export function createTools(deps) {
|
|
|
536
560
|
notes: z.string().optional().describe("Optional notes about the work"),
|
|
537
561
|
}),
|
|
538
562
|
handler: async (args) => {
|
|
539
|
-
if (
|
|
563
|
+
if (!modeContext.canLogToAdo()) {
|
|
540
564
|
return "OKR progress logging is only available from personal Chapterhouse instances.";
|
|
541
565
|
}
|
|
542
566
|
const mapper = createOKRMapper();
|
|
@@ -1246,7 +1270,7 @@ export function createTools(deps) {
|
|
|
1246
1270
|
if (args.scope_slug && !requestedScope) {
|
|
1247
1271
|
return `Unknown memory scope '${args.scope_slug}'.`;
|
|
1248
1272
|
}
|
|
1249
|
-
const result = runHousekeeping({
|
|
1273
|
+
const result = await runHousekeeping({
|
|
1250
1274
|
scopeIds: requestedScope ? [requestedScope.id] : undefined,
|
|
1251
1275
|
allScopes: args.all_scopes,
|
|
1252
1276
|
passes: args.passes,
|
|
@@ -1659,46 +1683,48 @@ export function createTools(deps) {
|
|
|
1659
1683
|
"topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
|
|
1660
1684
|
"lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
|
|
1661
1685
|
"routines, decisions). Bad paths are rejected with a suggested correction.",
|
|
1662
|
-
parameters:
|
|
1663
|
-
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
|
|
1664
|
-
title: z.string().describe("Page title for the index"),
|
|
1665
|
-
summary: z.string().describe("One-line summary for the index"),
|
|
1666
|
-
section: z.string().optional().describe("Index section (default: 'Knowledge')"),
|
|
1667
|
-
content: z.string().describe("Full page content (markdown)"),
|
|
1668
|
-
}),
|
|
1686
|
+
parameters: wikiUpdateArgsSchema,
|
|
1669
1687
|
handler: async (args) => {
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
if (rebuilt) {
|
|
1687
|
-
rebuilt.section = args.section || "Knowledge";
|
|
1688
|
-
addToIndex(rebuilt);
|
|
1689
|
-
}
|
|
1690
|
-
else {
|
|
1691
|
-
addToIndex({
|
|
1692
|
-
path: args.path,
|
|
1693
|
-
title: args.title,
|
|
1694
|
-
summary: indexSafe(args.summary).slice(0, 160),
|
|
1695
|
-
section: args.section || "Knowledge",
|
|
1688
|
+
try {
|
|
1689
|
+
const parsedArgs = wikiUpdateArgsSchema.parse(args);
|
|
1690
|
+
return await withWikiWrite(async () => {
|
|
1691
|
+
ensureWikiStructure();
|
|
1692
|
+
assertPagePath(parsedArgs.path);
|
|
1693
|
+
const validation = validateWikiFrontmatter(parsedArgs.content, {
|
|
1694
|
+
allowedTags: loadTaxonomy(),
|
|
1695
|
+
});
|
|
1696
|
+
if (!validation.valid) {
|
|
1697
|
+
throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
|
|
1698
|
+
}
|
|
1699
|
+
writePage(parsedArgs.path, parsedArgs.content);
|
|
1700
|
+
// Rebuild from disk so the index summary/tags/updated reflect the actual page.
|
|
1701
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1702
|
+
const rebuilt = buildIndexEntryForPage(parsedArgs.path, {
|
|
1703
|
+
section: parsedArgs.section || "Knowledge",
|
|
1696
1704
|
updated: today,
|
|
1697
1705
|
});
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1706
|
+
if (rebuilt) {
|
|
1707
|
+
rebuilt.section = parsedArgs.section || "Knowledge";
|
|
1708
|
+
addToIndex(rebuilt);
|
|
1709
|
+
}
|
|
1710
|
+
else {
|
|
1711
|
+
addToIndex({
|
|
1712
|
+
path: parsedArgs.path,
|
|
1713
|
+
title: parsedArgs.title,
|
|
1714
|
+
summary: indexSafe(parsedArgs.summary).slice(0, 160),
|
|
1715
|
+
section: parsedArgs.section || "Knowledge",
|
|
1716
|
+
updated: today,
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
appendLog("update", `wiki_update: ${indexSafe(parsedArgs.title)} (${parsedArgs.path})`);
|
|
1720
|
+
return `Wiki page updated: ${parsedArgs.title} (${parsedArgs.path})`;
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
const error = sanitizeWikiUpdateError(err);
|
|
1725
|
+
log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
|
|
1726
|
+
return { error };
|
|
1727
|
+
}
|
|
1702
1728
|
},
|
|
1703
1729
|
}),
|
|
1704
1730
|
defineTool("wiki_ingest", {
|
|
@@ -23,7 +23,7 @@ test.afterEach(async () => {
|
|
|
23
23
|
rmSync(home, { recursive: true, force: true });
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
|
-
test("wiki_update
|
|
26
|
+
test("wiki_update returns validation errors instead of throwing for invalid frontmatter", async () => {
|
|
27
27
|
const toolsModule = await loadToolsModule();
|
|
28
28
|
const tools = toolsModule.createTools({
|
|
29
29
|
client: { async listModels() { return []; } },
|
|
@@ -31,14 +31,19 @@ test("wiki_update rejects content without required frontmatter", async () => {
|
|
|
31
31
|
});
|
|
32
32
|
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
33
33
|
assert.ok(tool);
|
|
34
|
-
await
|
|
34
|
+
const result = await tool.handler({
|
|
35
35
|
path: "pages/shared/chapterhouse.md",
|
|
36
36
|
title: "Chapterhouse",
|
|
37
37
|
summary: "Runtime notes",
|
|
38
38
|
content: "# Chapterhouse\n\nRuntime notes.\n",
|
|
39
|
-
})
|
|
39
|
+
});
|
|
40
|
+
assert.deepEqual(result, {
|
|
41
|
+
error: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
|
|
42
|
+
});
|
|
43
|
+
const wikiFs = await readWikiArtifacts();
|
|
44
|
+
assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
|
|
40
45
|
});
|
|
41
|
-
test("wiki_update
|
|
46
|
+
test("wiki_update returns descriptive validation errors for malformed summaries and unknown tags", async () => {
|
|
42
47
|
const toolsModule = await loadToolsModule();
|
|
43
48
|
const tools = toolsModule.createTools({
|
|
44
49
|
client: { async listModels() { return []; } },
|
|
@@ -46,7 +51,7 @@ test("wiki_update rejects malformed summaries and unknown tags", async () => {
|
|
|
46
51
|
});
|
|
47
52
|
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
48
53
|
assert.ok(tool);
|
|
49
|
-
await
|
|
54
|
+
const result = await tool.handler({
|
|
50
55
|
path: "pages/shared/chapterhouse.md",
|
|
51
56
|
title: "Chapterhouse",
|
|
52
57
|
summary: "Runtime notes",
|
|
@@ -60,7 +65,11 @@ tags: [engineering, made-up-tag]
|
|
|
60
65
|
|
|
61
66
|
Runtime notes.
|
|
62
67
|
`,
|
|
63
|
-
})
|
|
68
|
+
});
|
|
69
|
+
assert.equal(typeof result, "object");
|
|
70
|
+
assert.match(result.error, /invalid 'summary'/i);
|
|
71
|
+
assert.match(result.error, /unknown tag 'made-up-tag'/i);
|
|
72
|
+
assert.match(result.error, /pages\/_meta\/taxonomy\.md/);
|
|
64
73
|
});
|
|
65
74
|
test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
|
|
66
75
|
const toolsModule = await loadToolsModule();
|
package/dist/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { stopEpisodeWriter } from "./copilot/episode-writer.js";
|
|
|
4
4
|
import { startApiServer, broadcastToSSE } from "./api/server.js";
|
|
5
5
|
import { getDb, closeDb, getState } from "./store/db.js";
|
|
6
6
|
import { config } from "./config.js";
|
|
7
|
+
import { ModeContext } from "./mode-context.js";
|
|
7
8
|
import { spawn } from "child_process";
|
|
8
9
|
import { readdirSync, statSync, rmSync } from "fs";
|
|
9
10
|
import { join } from "path";
|
|
@@ -21,6 +22,7 @@ import { CHAPTERHOUSE_VERSION } from "./version.js";
|
|
|
21
22
|
import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
|
|
22
23
|
import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
|
|
23
24
|
const log = logger.child({ module: "daemon" });
|
|
25
|
+
const modeContext = new ModeContext(config);
|
|
24
26
|
let memoryHousekeepingScheduler;
|
|
25
27
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
26
28
|
/**
|
|
@@ -86,6 +88,9 @@ async function main() {
|
|
|
86
88
|
if (config.selfEditEnabled) {
|
|
87
89
|
log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
|
|
88
90
|
}
|
|
91
|
+
for (const warning of config.modeCompatibilityWarnings) {
|
|
92
|
+
log.warn({ mode: config.chapterhouseMode }, warning);
|
|
93
|
+
}
|
|
89
94
|
// Set up message logging to daemon console
|
|
90
95
|
setMessageLogger((direction, source, text) => {
|
|
91
96
|
const arrow = direction === "in" ? "⟶" : "⟵";
|
|
@@ -100,7 +105,7 @@ async function main() {
|
|
|
100
105
|
if (wikiIsNew) {
|
|
101
106
|
log.info("Created wiki");
|
|
102
107
|
}
|
|
103
|
-
if (
|
|
108
|
+
if (modeContext.isTeam()) {
|
|
104
109
|
const seed = seedTeamWiki();
|
|
105
110
|
if (seed.created.length > 0) {
|
|
106
111
|
log.info({ pages: seed.created }, "Seeded team wiki pages");
|
|
@@ -153,7 +158,7 @@ async function main() {
|
|
|
153
158
|
await startApiServer();
|
|
154
159
|
memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
|
|
155
160
|
memoryHousekeepingScheduler.start();
|
|
156
|
-
if (
|
|
161
|
+
if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
|
|
157
162
|
new StandupScheduler().schedule();
|
|
158
163
|
}
|
|
159
164
|
const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { ModeContext } from "../mode-context.js";
|
|
2
3
|
export class TeamPushClient {
|
|
3
4
|
teamChapterhouseUrl;
|
|
4
5
|
teamChapterhouseToken;
|
|
@@ -6,6 +7,7 @@ export class TeamPushClient {
|
|
|
6
7
|
fetchImpl;
|
|
7
8
|
getAuthorizationHeader;
|
|
8
9
|
getCurrentUser;
|
|
10
|
+
modeContext;
|
|
9
11
|
constructor(options = {}) {
|
|
10
12
|
this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
|
|
11
13
|
this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
|
|
@@ -13,6 +15,11 @@ export class TeamPushClient {
|
|
|
13
15
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
14
16
|
this.getAuthorizationHeader = options.getAuthorizationHeader;
|
|
15
17
|
this.getCurrentUser = options.getCurrentUser;
|
|
18
|
+
this.modeContext = new ModeContext({
|
|
19
|
+
...config,
|
|
20
|
+
teamChapterhouseUrl: this.teamChapterhouseUrl,
|
|
21
|
+
standaloneMode: this.standaloneMode,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
async pushUpdate(payload) {
|
|
18
25
|
if (!this.isEnabled()) {
|
|
@@ -129,7 +136,7 @@ export class TeamPushClient {
|
|
|
129
136
|
throw new Error("Failed to push OKR update: no authenticated engineer identity is available");
|
|
130
137
|
}
|
|
131
138
|
isEnabled() {
|
|
132
|
-
return
|
|
139
|
+
return this.modeContext.canSyncTeamWiki();
|
|
133
140
|
}
|
|
134
141
|
}
|
|
135
142
|
function describeHttpFailure(status) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { ModeContext } from "../mode-context.js";
|
|
2
3
|
import { childLogger } from "../util/logger.js";
|
|
3
4
|
const log = childLogger("teams-notify");
|
|
4
5
|
export const TEAMS_MILESTONE_THRESHOLDS = [25, 50, 75, 100];
|
|
@@ -8,11 +9,17 @@ export class TeamsNotifier {
|
|
|
8
9
|
enabled;
|
|
9
10
|
fetchImpl;
|
|
10
11
|
warn;
|
|
12
|
+
modeContext;
|
|
11
13
|
constructor(options = {}) {
|
|
12
14
|
this.webhookUrl = (options.webhookUrl ?? config.teamsWebhookUrl).trim();
|
|
13
15
|
this.enabled = options.enabled ?? config.teamsNotificationsEnabled;
|
|
14
16
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
15
17
|
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
18
|
+
this.modeContext = new ModeContext({
|
|
19
|
+
...config,
|
|
20
|
+
teamsWebhookUrl: this.webhookUrl,
|
|
21
|
+
teamsNotificationsEnabled: this.enabled,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
async sendMessage(title, body, color = DEFAULT_COLOR) {
|
|
18
25
|
return await this.postCard({
|
|
@@ -61,7 +68,7 @@ export class TeamsNotifier {
|
|
|
61
68
|
});
|
|
62
69
|
}
|
|
63
70
|
async postCard(card) {
|
|
64
|
-
if (!this.
|
|
71
|
+
if (!this.modeContext.canSyncToTeams()) {
|
|
65
72
|
this.warn("[teams] Teams notifications are disabled or TEAMS_WEBHOOK_URL is empty.");
|
|
66
73
|
return false;
|
|
67
74
|
}
|
|
@@ -8,7 +8,8 @@ import { tieringPass } from "./tiering.js";
|
|
|
8
8
|
export { tieringPass };
|
|
9
9
|
const log = childLogger("memory.housekeeping");
|
|
10
10
|
const SIMILARITY_THRESHOLD = 0.8;
|
|
11
|
-
const
|
|
11
|
+
const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
|
|
12
|
+
const inFlightScopesByPass = new Map();
|
|
12
13
|
const PASS_ORDER = [
|
|
13
14
|
"dedup_observations",
|
|
14
15
|
"dedup_decisions",
|
|
@@ -280,38 +281,91 @@ function resolveScopeIds(input) {
|
|
|
280
281
|
const activeScope = getActiveScope();
|
|
281
282
|
return activeScope ? [activeScope.id] : [];
|
|
282
283
|
}
|
|
283
|
-
function
|
|
284
|
+
function getInFlightScopes(pass) {
|
|
285
|
+
let scopes = inFlightScopesByPass.get(pass);
|
|
286
|
+
if (!scopes) {
|
|
287
|
+
scopes = new Set();
|
|
288
|
+
inFlightScopesByPass.set(pass, scopes);
|
|
289
|
+
}
|
|
290
|
+
return scopes;
|
|
291
|
+
}
|
|
292
|
+
function getReservedPassScopes(scopeIds, passes) {
|
|
293
|
+
const reserved = [];
|
|
294
|
+
for (const pass of passes) {
|
|
295
|
+
if (pass === "compact_inbox") {
|
|
296
|
+
reserved.push({ pass, scope: GLOBAL_PASS_SCOPE });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
for (const scopeId of scopeIds) {
|
|
300
|
+
reserved.push({ pass, scope: scopeId });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return reserved;
|
|
304
|
+
}
|
|
305
|
+
function reservePassScopes(reserved) {
|
|
306
|
+
if (reserved.some(({ pass, scope }) => getInFlightScopes(pass).has(scope))) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
for (const { pass, scope } of reserved) {
|
|
310
|
+
getInFlightScopes(pass).add(scope);
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
function releasePassScopes(reserved) {
|
|
315
|
+
for (const { pass, scope } of reserved) {
|
|
316
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
317
|
+
scopes?.delete(scope);
|
|
318
|
+
if (scopes && scopes.size === 0) {
|
|
319
|
+
inFlightScopesByPass.delete(pass);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function runPass(pass, scopeId) {
|
|
284
324
|
switch (pass) {
|
|
285
325
|
case "dedup_observations":
|
|
286
|
-
return dedupObservationsPass(scopeId);
|
|
326
|
+
return await Promise.resolve(dedupObservationsPass(scopeId));
|
|
287
327
|
case "dedup_decisions":
|
|
288
|
-
return dedupDecisionsPass(scopeId);
|
|
328
|
+
return await Promise.resolve(dedupDecisionsPass(scopeId));
|
|
289
329
|
case "orphan_cleanup":
|
|
290
|
-
return orphanCleanupPass(scopeId);
|
|
330
|
+
return await Promise.resolve(orphanCleanupPass(scopeId));
|
|
291
331
|
case "decay":
|
|
292
|
-
return decayPass(scopeId);
|
|
332
|
+
return await Promise.resolve(decayPass(scopeId));
|
|
293
333
|
case "compact_inbox":
|
|
294
|
-
return compactInboxPass();
|
|
334
|
+
return await Promise.resolve(compactInboxPass());
|
|
295
335
|
case "tiering":
|
|
296
|
-
return tieringPass(scopeId);
|
|
336
|
+
return await Promise.resolve(tieringPass(scopeId));
|
|
297
337
|
}
|
|
298
338
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
339
|
+
async function runScopePasses(scopeId, passes) {
|
|
340
|
+
const summaries = [];
|
|
341
|
+
for (const pass of passes) {
|
|
342
|
+
summaries.push(await runPass(pass, scopeId));
|
|
343
|
+
}
|
|
344
|
+
return summaries;
|
|
301
345
|
}
|
|
302
346
|
export function isHousekeepingInFlight(scopeIds, passes) {
|
|
347
|
+
const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
|
|
303
348
|
if (!scopeIds || scopeIds.length === 0) {
|
|
304
|
-
return
|
|
349
|
+
return normalizedPasses.some((pass) => (inFlightScopesByPass.get(pass)?.size ?? 0) > 0);
|
|
305
350
|
}
|
|
306
|
-
const
|
|
307
|
-
return
|
|
351
|
+
const uniqueScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b);
|
|
352
|
+
return normalizedPasses.some((pass) => {
|
|
353
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
354
|
+
if (!scopes) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (pass === "compact_inbox") {
|
|
358
|
+
return scopes.has(GLOBAL_PASS_SCOPE);
|
|
359
|
+
}
|
|
360
|
+
return uniqueScopeIds.some((scopeId) => scopes.has(scopeId));
|
|
361
|
+
});
|
|
308
362
|
}
|
|
309
|
-
export function runHousekeeping(opts = {}) {
|
|
363
|
+
export async function runHousekeeping(opts = {}) {
|
|
310
364
|
const started = performance.now();
|
|
311
365
|
const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
|
|
312
366
|
const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
367
|
+
const reservedPassScopes = getReservedPassScopes(scopeIds, passes);
|
|
368
|
+
if (!reservePassScopes(reservedPassScopes)) {
|
|
315
369
|
return {
|
|
316
370
|
scopeIds,
|
|
317
371
|
summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
|
|
@@ -320,18 +374,12 @@ export function runHousekeeping(opts = {}) {
|
|
|
320
374
|
durationMs: 0,
|
|
321
375
|
};
|
|
322
376
|
}
|
|
323
|
-
inFlightKeys.add(key);
|
|
324
|
-
const summaries = [];
|
|
325
377
|
try {
|
|
326
378
|
const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
|
|
327
379
|
const hasCompactInbox = passes.includes("compact_inbox");
|
|
328
|
-
|
|
329
|
-
for (const pass of scopedPasses) {
|
|
330
|
-
summaries.push(runPass(pass, scopeId));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
380
|
+
const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
|
|
333
381
|
if (hasCompactInbox) {
|
|
334
|
-
summaries.push(
|
|
382
|
+
summaries.push(await runPass("compact_inbox", undefined));
|
|
335
383
|
}
|
|
336
384
|
const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
|
|
337
385
|
const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
|
|
@@ -346,7 +394,7 @@ export function runHousekeeping(opts = {}) {
|
|
|
346
394
|
return { scopeIds, summaries, totalExamined, totalModified, durationMs };
|
|
347
395
|
}
|
|
348
396
|
finally {
|
|
349
|
-
|
|
397
|
+
releasePassScopes(reservedPassScopes);
|
|
350
398
|
}
|
|
351
399
|
}
|
|
352
400
|
//# sourceMappingURL=housekeeping.js.map
|
|
@@ -16,9 +16,51 @@ function resetSandbox() {
|
|
|
16
16
|
async function loadModules() {
|
|
17
17
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
18
18
|
const memoryModule = await import(new URL("./index.js", import.meta.url).href);
|
|
19
|
-
const housekeepingModule = await import(new URL(
|
|
19
|
+
const housekeepingModule = await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
20
20
|
return { dbModule, memoryModule, housekeepingModule };
|
|
21
21
|
}
|
|
22
|
+
async function loadMockedHousekeepingModule(t, options = {}) {
|
|
23
|
+
t.mock.module("../config.js", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
config: {
|
|
26
|
+
memoryDecayDays: 30,
|
|
27
|
+
memoryInboxRetentionDays: 7,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
t.mock.module("../store/db.js", {
|
|
32
|
+
namedExports: {
|
|
33
|
+
getDb: () => {
|
|
34
|
+
throw new Error("getDb should not be called in this test");
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
t.mock.module("../util/logger.js", {
|
|
39
|
+
namedExports: {
|
|
40
|
+
childLogger: () => ({
|
|
41
|
+
info: () => { },
|
|
42
|
+
warn: () => { },
|
|
43
|
+
error: () => { },
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
t.mock.module("./active-scope.js", {
|
|
48
|
+
namedExports: {
|
|
49
|
+
getActiveScope: () => (options.activeScopeId === undefined ? null : { id: options.activeScopeId }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
t.mock.module("./scopes.js", {
|
|
53
|
+
namedExports: {
|
|
54
|
+
listScopes: () => options.scopes ?? [],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
t.mock.module("./tiering.js", {
|
|
58
|
+
namedExports: {
|
|
59
|
+
tieringPass: (scopeId) => options.tieringPass?.(scopeId) ?? { pass: "tieringPass", examined: scopeId, modified: 1, errors: [] },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
63
|
+
}
|
|
22
64
|
function getFunction(module, name) {
|
|
23
65
|
const value = module[name];
|
|
24
66
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
@@ -215,17 +257,67 @@ test("runHousekeeping defaults to the active scope and can target all active sco
|
|
|
215
257
|
const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
|
|
216
258
|
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
|
|
217
259
|
setActiveScope("chapterhouse");
|
|
218
|
-
const activeOnly = housekeepingModule.runHousekeeping({ passes: ["decay"] });
|
|
260
|
+
const activeOnly = await housekeepingModule.runHousekeeping({ passes: ["decay"] });
|
|
219
261
|
assert.deepEqual(activeOnly.scopeIds, [chapterhouse.id]);
|
|
220
262
|
assert.equal(activeOnly.summaries.length, 1);
|
|
221
263
|
assert.equal(activeOnly.summaries[0]?.modified, 1);
|
|
222
264
|
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(chapterhouseOld.id).archived_at);
|
|
223
265
|
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at, null);
|
|
224
|
-
const allScopes = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
|
|
266
|
+
const allScopes = await housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
|
|
225
267
|
assert.ok(allScopes.scopeIds.includes(team.id));
|
|
226
268
|
assert.equal(allScopes.summaries.some((summary) => summary.modified === 1), true);
|
|
227
269
|
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at);
|
|
228
270
|
});
|
|
271
|
+
test("runHousekeeping starts all scoped passes before awaiting completion", async (t) => {
|
|
272
|
+
const releases = new Map();
|
|
273
|
+
const startedScopes = [];
|
|
274
|
+
const housekeepingModule = await loadMockedHousekeepingModule(t, {
|
|
275
|
+
scopes: [
|
|
276
|
+
{ id: 11, active: true },
|
|
277
|
+
{ id: 22, active: true },
|
|
278
|
+
],
|
|
279
|
+
tieringPass: async (scopeId) => {
|
|
280
|
+
startedScopes.push(scopeId);
|
|
281
|
+
await new Promise((resolve) => {
|
|
282
|
+
releases.set(scopeId, resolve);
|
|
283
|
+
});
|
|
284
|
+
return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
const pending = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["tiering"] });
|
|
288
|
+
assert.equal(typeof pending.then, "function");
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
assert.deepEqual(startedScopes.sort((left, right) => left - right), [11, 22]);
|
|
291
|
+
releases.get(11)?.();
|
|
292
|
+
releases.get(22)?.();
|
|
293
|
+
const result = await pending;
|
|
294
|
+
assert.deepEqual(result.scopeIds, [11, 22]);
|
|
295
|
+
assert.deepEqual(result.summaries.map((summary) => summary.pass), ["tieringPass:11", "tieringPass:22"]);
|
|
296
|
+
});
|
|
297
|
+
test("runHousekeeping rejects overlapping runs that share an in-flight scope", async (t) => {
|
|
298
|
+
const releases = new Map();
|
|
299
|
+
const housekeepingModule = await loadMockedHousekeepingModule(t, {
|
|
300
|
+
scopes: [
|
|
301
|
+
{ id: 11, active: true },
|
|
302
|
+
{ id: 22, active: true },
|
|
303
|
+
],
|
|
304
|
+
tieringPass: async (scopeId) => {
|
|
305
|
+
await new Promise((resolve) => {
|
|
306
|
+
releases.set(scopeId, resolve);
|
|
307
|
+
});
|
|
308
|
+
return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const firstRun = housekeepingModule.runHousekeeping({ scopeIds: [11, 22], passes: ["tiering"] });
|
|
312
|
+
await Promise.resolve();
|
|
313
|
+
assert.equal(housekeepingModule.isHousekeepingInFlight([22], ["tiering"]), true);
|
|
314
|
+
const secondRun = await housekeepingModule.runHousekeeping({ scopeIds: [22], passes: ["tiering"] });
|
|
315
|
+
assert.deepEqual(secondRun.scopeIds, [22]);
|
|
316
|
+
assert.match(secondRun.summaries[0]?.errors[0] ?? "", /already in flight/i);
|
|
317
|
+
releases.get(11)?.();
|
|
318
|
+
releases.get(22)?.();
|
|
319
|
+
await firstRun;
|
|
320
|
+
});
|
|
229
321
|
test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
|
|
230
322
|
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
231
323
|
const db = dbModule.getDb();
|