@neuralnomads/codenomad-dev 0.20.0-dev-20260910-abaf6eba → 0.20.0-dev-20260911-ea37f97b
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-types.js.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/opencode/pruning-installation.js +65 -0
- package/dist/opencode/pruning-installation.js.map +1 -0
- package/dist/opencode/pruning-installation.test.js +101 -0
- package/dist/opencode/pruning-installation.test.js.map +1 -0
- package/dist/opencode/pruning-lifecycle.js +66 -0
- package/dist/opencode/pruning-lifecycle.js.map +1 -0
- package/dist/opencode/session-pruning/claim-fence.js +33 -0
- package/dist/opencode/session-pruning/claim-fence.js.map +1 -0
- package/dist/opencode/session-pruning/claim-fence.test.js +104 -0
- package/dist/opencode/session-pruning/claim-fence.test.js.map +1 -0
- package/dist/opencode/session-pruning/contract.js +40 -0
- package/dist/opencode/session-pruning/contract.js.map +1 -0
- package/dist/opencode/session-pruning/database-path.js +17 -0
- package/dist/opencode/session-pruning/database-path.js.map +1 -0
- package/dist/opencode/session-pruning/database-path.test.js +22 -0
- package/dist/opencode/session-pruning/database-path.test.js.map +1 -0
- package/dist/opencode/session-pruning/desktop-plugin.js +14 -0
- package/dist/opencode/session-pruning/desktop-plugin.js.map +1 -0
- package/dist/opencode/session-pruning/index.js +2 -0
- package/dist/opencode/session-pruning/index.js.map +1 -0
- package/dist/opencode/session-pruning/isolated-store.js +18 -0
- package/dist/opencode/session-pruning/isolated-store.js.map +1 -0
- package/dist/opencode/session-pruning/planner.js +36 -0
- package/dist/opencode/session-pruning/planner.js.map +1 -0
- package/dist/opencode/session-pruning/plugin.js +45 -0
- package/dist/opencode/session-pruning/plugin.js.map +1 -0
- package/dist/opencode/session-pruning/presence.js +42 -0
- package/dist/opencode/session-pruning/presence.js.map +1 -0
- package/dist/opencode/session-pruning/preview-store.js +21 -0
- package/dist/opencode/session-pruning/preview-store.js.map +1 -0
- package/dist/opencode/session-pruning/pruning.test.js +172 -0
- package/dist/opencode/session-pruning/pruning.test.js.map +1 -0
- package/dist/opencode/session-pruning/revision.js +18 -0
- package/dist/opencode/session-pruning/revision.js.map +1 -0
- package/dist/opencode/session-pruning/service.js +56 -0
- package/dist/opencode/session-pruning/service.js.map +1 -0
- package/dist/opencode/session-pruning/service.test.js +74 -0
- package/dist/opencode/session-pruning/service.test.js.map +1 -0
- package/dist/opencode/session-pruning/storage-path.js +6 -0
- package/dist/opencode/session-pruning/storage-path.js.map +1 -0
- package/dist/opencode/session-pruning/transaction.js +53 -0
- package/dist/opencode/session-pruning/transaction.js.map +1 -0
- package/dist/opencode/session-pruning/tui-reload.js +84 -0
- package/dist/opencode/session-pruning/tui-reload.js.map +1 -0
- package/dist/opencode/session-pruning/tui-reload.native.js +269 -0
- package/dist/opencode/session-pruning/tui-reload.native.js.map +1 -0
- package/dist/opencode/session-pruning/tui-reload.test.js +71 -0
- package/dist/opencode/session-pruning/tui-reload.test.js.map +1 -0
- package/dist/opencode/session-pruning/tui.js +12 -0
- package/dist/opencode/session-pruning/tui.js.map +1 -0
- package/dist/plugins/session-pruning/plugin.mjs +26559 -0
- package/dist/server/__tests__/instance-proxy.test.js +10 -5
- package/dist/server/__tests__/instance-proxy.test.js.map +1 -1
- package/dist/server/http-server.js +2 -1
- package/dist/server/http-server.js.map +1 -1
- package/dist/server/routes/session-pruning.js +45 -0
- package/dist/server/routes/session-pruning.js.map +1 -0
- package/dist/server/routes/session-pruning.test.js +98 -0
- package/dist/server/routes/session-pruning.test.js.map +1 -0
- package/dist/workspaces/manager.js +1 -0
- package/dist/workspaces/manager.js.map +1 -1
- package/package.json +6 -3
- package/public/assets/App-0-FcHzPS.js +44 -0
- package/public/assets/App-0-FcHzPS.js.map +1 -0
- package/public/assets/{FilesTab-BVJawmU7.js → FilesTab-CRpGDs1B.js} +3 -3
- package/public/assets/{FilesTab-BVJawmU7.js.map → FilesTab-CRpGDs1B.js.map} +1 -1
- package/public/assets/{GitChangesTab-BVdfbJ_l.js → GitChangesTab-DxFebTiq.js} +3 -3
- package/public/assets/{GitChangesTab-BVdfbJ_l.js.map → GitChangesTab-DxFebTiq.js.map} +1 -1
- package/public/assets/{SplitFilePanel-CSR5-RHY.js → SplitFilePanel-DgTMrrO-.js} +2 -2
- package/public/assets/{SplitFilePanel-CSR5-RHY.js.map → SplitFilePanel-DgTMrrO-.js.map} +1 -1
- package/public/assets/{StatusTab-DIodfPQd.js → StatusTab-LKUA9cbH.js} +2 -2
- package/public/assets/{StatusTab-DIodfPQd.js.map → StatusTab-LKUA9cbH.js.map} +1 -1
- package/public/assets/{align-justify-kDFhSvpm.js → align-justify-v_yPXSys.js} +2 -2
- package/public/assets/{align-justify-kDFhSvpm.js.map → align-justify-v_yPXSys.js.map} +1 -1
- package/public/assets/{bundle-full-B4eYITdG.js → bundle-full-HzL_Jtqk.js} +2 -2
- package/public/assets/{bundle-full-B4eYITdG.js.map → bundle-full-HzL_Jtqk.js.map} +1 -1
- package/public/assets/{client-state-D3g-WH8W.js → client-state-CF-PJ0GG.js} +2 -2
- package/public/assets/{client-state-D3g-WH8W.js.map → client-state-CF-PJ0GG.js.map} +1 -1
- package/public/assets/{diff-viewer-BLL0OcUK.js → diff-viewer-BFDQBDVy.js} +2 -2
- package/public/assets/{diff-viewer-BLL0OcUK.js.map → diff-viewer-BFDQBDVy.js.map} +1 -1
- package/public/assets/{index-D21P0GMO.js → index-8iPlm2r0.js} +2 -2
- package/public/assets/index-8iPlm2r0.js.map +1 -0
- package/public/assets/{index-DnqYBc2b.js → index-B185mC7m.js} +2 -2
- package/public/assets/index-B185mC7m.js.map +1 -0
- package/public/assets/{index-DLtH2OSD.js → index-Bbj1xF8i.js} +2 -2
- package/public/assets/index-Bbj1xF8i.js.map +1 -0
- package/public/assets/{index-BtuH3zKP.js → index-BsmFN3k1.js} +2 -2
- package/public/assets/index-BsmFN3k1.js.map +1 -0
- package/public/assets/{index-CrOPzgHN.js → index-C52CfqK3.js} +2 -2
- package/public/assets/index-C52CfqK3.js.map +1 -0
- package/public/assets/{index-DWnsOFes.js → index-CHYmKl10.js} +2 -2
- package/public/assets/index-CHYmKl10.js.map +1 -0
- package/public/assets/{index-DbfYo3i-.js → index-CTKYD_VJ.js} +2 -2
- package/public/assets/index-CTKYD_VJ.js.map +1 -0
- package/public/assets/index-D2UHSOG5.js +2 -0
- package/public/assets/{index-BIqxsbaj.js.map → index-D2UHSOG5.js.map} +1 -1
- package/public/assets/{index-DLRrUgI7.js → index-DDoB_Z36.js} +2 -2
- package/public/assets/index-DDoB_Z36.js.map +1 -0
- package/public/assets/{index-e5yyN2yK.js → index-DcwAtZw-.js} +3 -3
- package/public/assets/{index-e5yyN2yK.js.map → index-DcwAtZw-.js.map} +1 -1
- package/public/assets/{index-ZnQhzm1X.js → index-DxUD3RYr.js} +2 -2
- package/public/assets/{index-ZnQhzm1X.js.map → index-DxUD3RYr.js.map} +1 -1
- package/public/assets/{index-CeF-jpOV.js → index-RK9gf0uk.js} +2 -2
- package/public/assets/{index-CeF-jpOV.js.map → index-RK9gf0uk.js.map} +1 -1
- package/public/assets/{loading-CaoeVfIV.js → loading-BSB82snY.js} +2 -2
- package/public/assets/{loading-CaoeVfIV.js.map → loading-BSB82snY.js.map} +1 -1
- package/public/assets/main-CZztwrfv.js +48 -0
- package/public/assets/main-CZztwrfv.js.map +1 -0
- package/public/assets/{markdown-BeJ9YIFU.js → markdown-DtidoL2Z.js} +4 -4
- package/public/assets/{markdown-BeJ9YIFU.js.map → markdown-DtidoL2Z.js.map} +1 -1
- package/public/assets/{monaco-viewer-Cc9bWg7I.js → monaco-viewer-CpxDfB-6.js} +2 -2
- package/public/assets/monaco-viewer-CpxDfB-6.js.map +1 -0
- package/public/assets/{startup-state-settings-card-BvpJJ6ei.js → startup-state-settings-card-BY741Rt0.js} +2 -2
- package/public/assets/{startup-state-settings-card-BvpJJ6ei.js.map → startup-state-settings-card-BY741Rt0.js.map} +1 -1
- package/public/assets/{terminal-square-DBCSEAmh.js → terminal-square-BdC_jwm3.js} +2 -2
- package/public/assets/{terminal-square-DBCSEAmh.js.map → terminal-square-BdC_jwm3.js.map} +1 -1
- package/public/assets/{tool-call-BJiSHSx7.js → tool-call-DbtCsJ4x.js} +4 -4
- package/public/assets/{tool-call-BJiSHSx7.js.map → tool-call-DbtCsJ4x.js.map} +1 -1
- package/public/assets/{unified-picker-CAEyj3a0.js → unified-picker-C0eo1chJ.js} +2 -2
- package/public/assets/{unified-picker-CAEyj3a0.js.map → unified-picker-C0eo1chJ.js.map} +1 -1
- package/public/assets/{wrap-text-DJNV0ypl.js → wrap-text-BuMtdvMx.js} +2 -2
- package/public/assets/{wrap-text-DJNV0ypl.js.map → wrap-text-BuMtdvMx.js.map} +1 -1
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/sw.js +1 -1
- package/public/sw.js.map +1 -1
- package/public/assets/App-0ECD8eRb.js +0 -43
- package/public/assets/App-0ECD8eRb.js.map +0 -1
- package/public/assets/index-BIqxsbaj.js +0 -2
- package/public/assets/index-BtuH3zKP.js.map +0 -1
- package/public/assets/index-CrOPzgHN.js.map +0 -1
- package/public/assets/index-D21P0GMO.js.map +0 -1
- package/public/assets/index-DLRrUgI7.js.map +0 -1
- package/public/assets/index-DLtH2OSD.js.map +0 -1
- package/public/assets/index-DWnsOFes.js.map +0 -1
- package/public/assets/index-DbfYo3i-.js.map +0 -1
- package/public/assets/index-DnqYBc2b.js.map +0 -1
- package/public/assets/main-CeMcnDRn.js +0 -48
- package/public/assets/main-CeMcnDRn.js.map +0 -1
- package/public/assets/monaco-viewer-Cc9bWg7I.js.map +0 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { storageDirectory } from "./storage-path";
|
|
3
|
+
export async function readPruningPreview(filename, target, directory) {
|
|
4
|
+
// Deliberately no default ~/.local/share path or UI-supplied override.
|
|
5
|
+
if (typeof filename !== "string" || !path.isAbsolute(filename))
|
|
6
|
+
return undefined;
|
|
7
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
8
|
+
const db = new DatabaseSync(filename, { readOnly: true, timeout: 1000 });
|
|
9
|
+
try {
|
|
10
|
+
db.exec("PRAGMA query_only=ON");
|
|
11
|
+
const row = db.prepare(`SELECT m.data FROM session_message m
|
|
12
|
+
JOIN session_v2 s ON s.id = m.session_id
|
|
13
|
+
WHERE m.id = ? AND m.session_id = ? AND m.type = 'assistant' AND s.directory = ?
|
|
14
|
+
AND length(CAST(m.data AS BLOB)) <= 16777216`).get(target.messageID, target.sessionID, storageDirectory(directory));
|
|
15
|
+
return typeof row?.data === "string" ? JSON.parse(row.data) : undefined;
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
db.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=preview-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview-store.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/preview-store.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAA;AAG5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,QAAiB,EAAE,MAA2C,EAAE,SAAiB;IAEjF,uEAAuE;IACvE,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAA;IAChF,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAA;IACpD,MAAM,EAAE,GAAG,IAAI,YAAY,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;IACxE,IAAI,CAAC;QACH,EAAE,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAA;QAC/B,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC;;;mDAGwB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAA;QACrH,OAAO,OAAO,GAAG,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACzE,CAAC;YAAS,CAAC;QAAC,EAAE,CAAC,KAAK,EAAE,CAAA;IAAC,CAAC;AAC1B,CAAC","sourcesContent":["import path from \"node:path\"\nimport type { z } from \"zod\"\nimport type { messageTargetSchema } from \"./contract\"\nimport { storageDirectory } from \"./storage-path\"\n\nexport async function readPruningPreview(\n filename: unknown, target: z.infer<typeof messageTargetSchema>, directory: string,\n): Promise<unknown | undefined> {\n // Deliberately no default ~/.local/share path or UI-supplied override.\n if (typeof filename !== \"string\" || !path.isAbsolute(filename)) return undefined\n const { DatabaseSync } = await import(\"node:sqlite\")\n const db = new DatabaseSync(filename, { readOnly: true, timeout: 1000 })\n try {\n db.exec(\"PRAGMA query_only=ON\")\n const row = db.prepare(`SELECT m.data FROM session_message m\n JOIN session_v2 s ON s.id = m.session_id\n WHERE m.id = ? AND m.session_id = ? AND m.type = 'assistant' AND s.directory = ?\n AND length(CAST(m.data AS BLOB)) <= 16777216`).get(target.messageID, target.sessionID, storageDirectory(directory))\n return typeof row?.data === \"string\" ? JSON.parse(row.data) : undefined\n } finally { db.close() }\n}\n"]}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pruneIsolatedMessage } from "./isolated-store";
|
|
8
|
+
import { previewContent, revision } from "./planner";
|
|
9
|
+
import { contentRevision } from "./revision";
|
|
10
|
+
import { pruneRequestSchema } from "./contract";
|
|
11
|
+
import { readPruningPreview } from "./preview-store";
|
|
12
|
+
import plugin from "./plugin";
|
|
13
|
+
const content = [
|
|
14
|
+
{ type: "reasoning", text: "thinking", time: { created: 1, completed: 2 } },
|
|
15
|
+
{ type: "tool", id: "call-1", state: { status: "completed", content: [{ type: "text", text: "logs" }] } },
|
|
16
|
+
{ type: "text", text: "Keep the conclusion" },
|
|
17
|
+
];
|
|
18
|
+
const data = { content, time: { created: 1, completed: 3 }, tokens: { input: 123 }, snapshot: { start: "snapshot" } };
|
|
19
|
+
const input = (indexes = [0, 1]) => ({ sessionID: "s", messageID: "m", revision: revision(content), indexes });
|
|
20
|
+
function fixture(filename = ":memory:") {
|
|
21
|
+
const db = new DatabaseSync(filename);
|
|
22
|
+
db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY, directory TEXT);
|
|
23
|
+
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT);
|
|
24
|
+
CREATE TABLE event(aggregate_id TEXT,data TEXT);
|
|
25
|
+
INSERT INTO session_v2 VALUES ('s','/work');`);
|
|
26
|
+
db.prepare("INSERT INTO session_message VALUES ('m','s','assistant',7,?)").run(JSON.stringify(data));
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
function stored(db) {
|
|
30
|
+
return JSON.parse(db.prepare("SELECT data FROM session_message WHERE id='m'").get().data);
|
|
31
|
+
}
|
|
32
|
+
test("browser and plugin revisions ignore key order but detect content/order changes", async () => {
|
|
33
|
+
assert.equal(await contentRevision(content), revision(content));
|
|
34
|
+
assert.equal(revision([{ type: "text", text: "é" }]), revision([{ text: "é", type: "text" }]));
|
|
35
|
+
assert.notEqual(revision(content), revision([...content].reverse()));
|
|
36
|
+
});
|
|
37
|
+
test("preview reports technical parts only without changing storage", () => {
|
|
38
|
+
const before = JSON.stringify(data);
|
|
39
|
+
const preview = previewContent(data);
|
|
40
|
+
assert.equal(preview.status, "preview");
|
|
41
|
+
if (preview.status !== "preview")
|
|
42
|
+
assert.fail();
|
|
43
|
+
assert.equal(preview.liveMutation, false);
|
|
44
|
+
assert.deepEqual(preview.parts.map(p => [p.index, p.type]), [[0, "reasoning"], [1, "tool"]]);
|
|
45
|
+
assert.equal(JSON.stringify(data), before);
|
|
46
|
+
});
|
|
47
|
+
test("isolated SQL prune preserves IDs, ordering, text and metadata", () => {
|
|
48
|
+
const db = fixture();
|
|
49
|
+
try {
|
|
50
|
+
const result = pruneIsolatedMessage(db, input(), "/work");
|
|
51
|
+
assert.equal(result.status, "pruned");
|
|
52
|
+
assert.deepEqual(stored(db), { ...data, content: [content[2]] });
|
|
53
|
+
assert.equal(db.prepare("SELECT seq FROM session_message").get().seq, 7);
|
|
54
|
+
assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
|
|
55
|
+
assert.equal(db.isTransaction, false);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
db.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("individual pruning and all-technical messages are supported", () => {
|
|
62
|
+
const db = fixture();
|
|
63
|
+
try {
|
|
64
|
+
assert.equal(pruneIsolatedMessage(db, input([1]), "/work").status, "pruned");
|
|
65
|
+
assert.deepEqual(stored(db).content, [content[0], content[2]]);
|
|
66
|
+
db.prepare("UPDATE session_message SET data=?").run(JSON.stringify({ ...data, content: [content[0]] }));
|
|
67
|
+
assert.equal(pruneIsolatedMessage(db, { ...input([0]), revision: revision([content[0]]) }, "/work").status, "pruned");
|
|
68
|
+
assert.deepEqual(stored(db).content, []);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
for (const [name, change] of [
|
|
75
|
+
["incomplete message", (d) => { delete d.time.completed; }],
|
|
76
|
+
["running tool", (d) => { d.content[1].state.status = "running"; }],
|
|
77
|
+
["unknown content kind", (d) => { d.content.push({ type: "future" }); }],
|
|
78
|
+
]) {
|
|
79
|
+
test(`rejects ${name} without partial changes`, () => {
|
|
80
|
+
const db = fixture();
|
|
81
|
+
try {
|
|
82
|
+
const changed = structuredClone(data);
|
|
83
|
+
change(changed);
|
|
84
|
+
db.prepare("UPDATE session_message SET data=?").run(JSON.stringify(changed));
|
|
85
|
+
assert.equal(pruneIsolatedMessage(db, input(), "/work").status, "blocked");
|
|
86
|
+
assert.deepEqual(stored(db), changed);
|
|
87
|
+
assert.equal(db.isTransaction, false);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
test("rejects stale revisions, text selections, out-of-range and wrong ownership", () => {
|
|
95
|
+
const db = fixture();
|
|
96
|
+
try {
|
|
97
|
+
for (const request of [input([2]), input([99]), { ...input(), revision: "0".repeat(64) }, { ...input(), sessionID: "other" }]) {
|
|
98
|
+
assert.equal(pruneIsolatedMessage(db, request, "/work").status, "blocked");
|
|
99
|
+
assert.deepEqual(stored(db), data);
|
|
100
|
+
}
|
|
101
|
+
assert.equal(pruneIsolatedMessage(db, input(), "/elsewhere").status, "blocked");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("rejects replacement content, duplicate indexes and oversized selections at the contract", () => {
|
|
108
|
+
for (const invalid of [{ ...input(), content: [] }, input([]), input([1, 1]), input([-1]), input(Array.from({ length: 100_002 }, (_, i) => i))]) {
|
|
109
|
+
assert.equal(pruneRequestSchema.safeParse(invalid).success, false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test("retained events and triggers fail closed rather than silently corrupt replay", () => {
|
|
113
|
+
const db = fixture();
|
|
114
|
+
try {
|
|
115
|
+
db.exec("INSERT INTO event VALUES ('s','{}')");
|
|
116
|
+
assert.deepEqual(pruneIsolatedMessage(db, input(), "/work"), { status: "blocked", reason: "unsupported_storage" });
|
|
117
|
+
db.exec("DELETE FROM event; CREATE TRIGGER guard AFTER UPDATE ON session_message BEGIN SELECT 1; END");
|
|
118
|
+
assert.equal(pruneIsolatedMessage(db, input(), "/work").status, "blocked");
|
|
119
|
+
assert.deepEqual(stored(db), data);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
db.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
test("whole-message cleanup supports more than 4096 technical parts", () => {
|
|
126
|
+
const db = fixture();
|
|
127
|
+
try {
|
|
128
|
+
const content = [...Array.from({ length: 5000 }, () => ({ type: "reasoning", text: "remove" })), { type: "text", text: "keep" }];
|
|
129
|
+
db.prepare("UPDATE session_message SET data=?").run(JSON.stringify({ ...data, content }));
|
|
130
|
+
const result = pruneIsolatedMessage(db, { ...input(), revision: revision(content), indexes: Array.from({ length: 5000 }, (_, i) => i) }, "/work");
|
|
131
|
+
assert.equal(result.status, "pruned");
|
|
132
|
+
if (result.status === "pruned")
|
|
133
|
+
assert.equal(result.removedCount, 5000);
|
|
134
|
+
assert.deepEqual(stored(db).content, [content.at(-1)]);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
db.close();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
test("refuses file-backed writes, but supports explicit read-only preview", async () => {
|
|
141
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "codenomad-pruning-test-"));
|
|
142
|
+
const filename = path.join(dir, "fixture.db");
|
|
143
|
+
const db = fixture(filename);
|
|
144
|
+
try {
|
|
145
|
+
assert.deepEqual(pruneIsolatedMessage(db, input(), "/work"), { status: "blocked", reason: "maintenance_required" });
|
|
146
|
+
assert.deepEqual(await readPruningPreview(filename, input(), "/work"), data);
|
|
147
|
+
assert.equal(await readPruningPreview(filename, input(), "/other"), undefined);
|
|
148
|
+
assert.equal(await readPruningPreview(undefined, input(), "/work"), undefined);
|
|
149
|
+
assert.deepEqual(stored(db), data);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
db.close();
|
|
153
|
+
await rm(dir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
test("loading the plugin registers RPC without reading or writing a database", async () => {
|
|
157
|
+
let handlers;
|
|
158
|
+
let disposed = false;
|
|
159
|
+
const cleanup = await plugin.setup({
|
|
160
|
+
location: { directory: "/work" }, options: {},
|
|
161
|
+
rpc: { register: async (definition, implementation) => {
|
|
162
|
+
assert.equal(definition.id, "codenomad.session-pruning");
|
|
163
|
+
handlers = implementation;
|
|
164
|
+
return { dispose: () => { disposed = true; } };
|
|
165
|
+
} },
|
|
166
|
+
});
|
|
167
|
+
assert.equal(typeof handlers.prune, "function");
|
|
168
|
+
assert.equal(typeof handlers.preview, "function");
|
|
169
|
+
await cleanup?.();
|
|
170
|
+
assert.equal(disposed, true);
|
|
171
|
+
});
|
|
172
|
+
//# sourceMappingURL=pruning.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pruning.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/pruning.test.ts"],"names":[],"mappings":"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","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport { DatabaseSync } from \"node:sqlite\"\nimport { mkdtemp, rm } from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { pruneIsolatedMessage } from \"./isolated-store\"\nimport { previewContent, revision } from \"./planner\"\nimport { contentRevision } from \"./revision\"\nimport { pruneRequestSchema } from \"./contract\"\nimport { readPruningPreview } from \"./preview-store\"\nimport plugin from \"./plugin\"\n\nconst content = [\n { type: \"reasoning\", text: \"thinking\", time: { created: 1, completed: 2 } },\n { type: \"tool\", id: \"call-1\", state: { status: \"completed\", content: [{ type: \"text\", text: \"logs\" }] } },\n { type: \"text\", text: \"Keep the conclusion\" },\n]\nconst data = { content, time: { created: 1, completed: 3 }, tokens: { input: 123 }, snapshot: { start: \"snapshot\" } }\nconst input = (indexes = [0, 1]) => ({ sessionID: \"s\", messageID: \"m\", revision: revision(content), indexes })\nfunction fixture(filename = \":memory:\") {\n const db = new DatabaseSync(filename)\n db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY, directory TEXT);\n CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT);\n CREATE TABLE event(aggregate_id TEXT,data TEXT);\n INSERT INTO session_v2 VALUES ('s','/work');`)\n db.prepare(\"INSERT INTO session_message VALUES ('m','s','assistant',7,?)\").run(JSON.stringify(data))\n return db\n}\nfunction stored(db: DatabaseSync) {\n return JSON.parse(db.prepare(\"SELECT data FROM session_message WHERE id='m'\").get()!.data as string)\n}\n\ntest(\"browser and plugin revisions ignore key order but detect content/order changes\", async () => {\n assert.equal(await contentRevision(content), revision(content))\n assert.equal(revision([{ type: \"text\", text: \"é\" }]), revision([{ text: \"é\", type: \"text\" }]))\n assert.notEqual(revision(content), revision([...content].reverse()))\n})\n\ntest(\"preview reports technical parts only without changing storage\", () => {\n const before = JSON.stringify(data)\n const preview = previewContent(data)\n assert.equal(preview.status, \"preview\")\n if (preview.status !== \"preview\") assert.fail()\n assert.equal(preview.liveMutation, false)\n assert.deepEqual(preview.parts.map(p => [p.index, p.type]), [[0, \"reasoning\"], [1, \"tool\"]])\n assert.equal(JSON.stringify(data), before)\n})\n\ntest(\"isolated SQL prune preserves IDs, ordering, text and metadata\", () => {\n const db = fixture()\n try {\n const result = pruneIsolatedMessage(db, input(), \"/work\")\n assert.equal(result.status, \"pruned\")\n assert.deepEqual(stored(db), { ...data, content: [content[2]] })\n assert.equal(db.prepare(\"SELECT seq FROM session_message\").get()!.seq, 7)\n assert.equal(db.prepare(\"PRAGMA integrity_check\").get()!.integrity_check, \"ok\")\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"individual pruning and all-technical messages are supported\", () => {\n const db = fixture()\n try {\n assert.equal(pruneIsolatedMessage(db, input([1]), \"/work\").status, \"pruned\")\n assert.deepEqual(stored(db).content, [content[0], content[2]])\n db.prepare(\"UPDATE session_message SET data=?\").run(JSON.stringify({ ...data, content: [content[0]] }))\n assert.equal(pruneIsolatedMessage(db, { ...input([0]), revision: revision([content[0]]) }, \"/work\").status, \"pruned\")\n assert.deepEqual(stored(db).content, [])\n } finally { db.close() }\n})\n\nfor (const [name, change] of [\n [\"incomplete message\", (d: any) => { delete d.time.completed }],\n [\"running tool\", (d: any) => { d.content[1].state.status = \"running\" }],\n [\"unknown content kind\", (d: any) => { d.content.push({ type: \"future\" }) }],\n] as const) {\n test(`rejects ${name} without partial changes`, () => {\n const db = fixture()\n try {\n const changed = structuredClone(data); change(changed)\n db.prepare(\"UPDATE session_message SET data=?\").run(JSON.stringify(changed))\n assert.equal(pruneIsolatedMessage(db, input(), \"/work\").status, \"blocked\")\n assert.deepEqual(stored(db), changed)\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n })\n}\n\ntest(\"rejects stale revisions, text selections, out-of-range and wrong ownership\", () => {\n const db = fixture()\n try {\n for (const request of [input([2]), input([99]), { ...input(), revision: \"0\".repeat(64) }, { ...input(), sessionID: \"other\" }]) {\n assert.equal(pruneIsolatedMessage(db, request, \"/work\").status, \"blocked\")\n assert.deepEqual(stored(db), data)\n }\n assert.equal(pruneIsolatedMessage(db, input(), \"/elsewhere\").status, \"blocked\")\n } finally { db.close() }\n})\n\ntest(\"rejects replacement content, duplicate indexes and oversized selections at the contract\", () => {\n for (const invalid of [{ ...input(), content: [] }, input([]), input([1, 1]), input([-1]), input(Array.from({ length: 100_002 }, (_, i) => i))]) {\n assert.equal(pruneRequestSchema.safeParse(invalid).success, false)\n }\n})\n\ntest(\"retained events and triggers fail closed rather than silently corrupt replay\", () => {\n const db = fixture()\n try {\n db.exec(\"INSERT INTO event VALUES ('s','{}')\")\n assert.deepEqual(pruneIsolatedMessage(db, input(), \"/work\"), { status: \"blocked\", reason: \"unsupported_storage\" })\n db.exec(\"DELETE FROM event; CREATE TRIGGER guard AFTER UPDATE ON session_message BEGIN SELECT 1; END\")\n assert.equal(pruneIsolatedMessage(db, input(), \"/work\").status, \"blocked\")\n assert.deepEqual(stored(db), data)\n } finally { db.close() }\n})\n\ntest(\"whole-message cleanup supports more than 4096 technical parts\", () => {\n const db = fixture()\n try {\n const content = [...Array.from({ length: 5000 }, () => ({ type: \"reasoning\", text: \"remove\" })), { type: \"text\", text: \"keep\" }]\n db.prepare(\"UPDATE session_message SET data=?\").run(JSON.stringify({ ...data, content }))\n const result = pruneIsolatedMessage(db, { ...input(), revision: revision(content), indexes: Array.from({ length: 5000 }, (_, i) => i) }, \"/work\")\n assert.equal(result.status, \"pruned\")\n if (result.status === \"pruned\") assert.equal(result.removedCount, 5000)\n assert.deepEqual(stored(db).content, [content.at(-1)])\n } finally { db.close() }\n})\n\ntest(\"refuses file-backed writes, but supports explicit read-only preview\", async () => {\n const dir = await mkdtemp(path.join(os.tmpdir(), \"codenomad-pruning-test-\"))\n const filename = path.join(dir, \"fixture.db\")\n const db = fixture(filename)\n try {\n assert.deepEqual(pruneIsolatedMessage(db, input(), \"/work\"), { status: \"blocked\", reason: \"maintenance_required\" })\n assert.deepEqual(await readPruningPreview(filename, input(), \"/work\"), data)\n assert.equal(await readPruningPreview(filename, input(), \"/other\"), undefined)\n assert.equal(await readPruningPreview(undefined, input(), \"/work\"), undefined)\n assert.deepEqual(stored(db), data)\n } finally { db.close(); await rm(dir, { recursive: true, force: true }) }\n})\n\ntest(\"loading the plugin registers RPC without reading or writing a database\", async () => {\n let handlers: any\n let disposed = false\n const cleanup = await plugin.setup({\n location: { directory: \"/work\" }, options: {},\n rpc: { register: async (definition: any, implementation: any) => {\n assert.equal(definition.id, \"codenomad.session-pruning\")\n handlers = implementation\n return { dispose: () => { disposed = true } }\n } },\n } as any)\n assert.equal(typeof handlers.prune, \"function\")\n assert.equal(typeof handlers.preview, \"function\")\n await cleanup?.()\n assert.equal(disposed, true)\n})\n"]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Shared by the browser and plugin. SDK decoding can change object key order.
|
|
2
|
+
function canonical(value) {
|
|
3
|
+
if (Array.isArray(value))
|
|
4
|
+
return value.map(canonical);
|
|
5
|
+
if (value && typeof value === "object") {
|
|
6
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
7
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => [key, canonical(entry)]));
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function canonicalContent(content) {
|
|
12
|
+
return JSON.stringify(canonical(content));
|
|
13
|
+
}
|
|
14
|
+
export async function contentRevision(content) {
|
|
15
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalContent(content)));
|
|
16
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=revision.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revision.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/revision.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,SAAS,SAAS,CAAC,KAAc;IAC/B,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACrD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACvC,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,CAAC;aACvF,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IACnG,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,OAAgB;IAC/C,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAA;AAC3C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,OAAgB;IACpD,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAA;IACpH,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAClG,CAAC","sourcesContent":["// Shared by the browser and plugin. SDK decoding can change object key order.\nfunction canonical(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(canonical)\n if (value && typeof value === \"object\") {\n return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)\n .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => [key, canonical(entry)]))\n }\n return value\n}\n\nexport function canonicalContent(content: unknown): string {\n return JSON.stringify(canonical(content))\n}\n\nexport async function contentRevision(content: unknown): Promise<string> {\n const digest = await globalThis.crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(canonicalContent(content)))\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\")\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { storageKey, validateClaimFence } from "./claim-fence";
|
|
5
|
+
import { pruneTransaction } from "./transaction";
|
|
6
|
+
import { pruneRequestSchema } from "./contract";
|
|
7
|
+
import { pruningDatabasePath } from "./database-path";
|
|
8
|
+
// Only an explicit pruning RPC calls this service; loading the plugin never prunes.
|
|
9
|
+
// No database access is exposed through CodeNomad's HTTP broker.
|
|
10
|
+
export async function pruneBoundMessage(ctx, input, signal) {
|
|
11
|
+
const request = pruneRequestSchema.parse(input);
|
|
12
|
+
const configured = pruningDatabasePath(ctx.options.databasePath, ctx.app.channel);
|
|
13
|
+
if (typeof configured !== "string" || !path.isAbsolute(configured)
|
|
14
|
+
|| (process.platform === "win32" && configured.replaceAll("/", "\\").startsWith("\\\\"))) {
|
|
15
|
+
return { status: "blocked", reason: "unsupported_storage" };
|
|
16
|
+
}
|
|
17
|
+
const session = await ctx.session.get({ sessionID: request.sessionID });
|
|
18
|
+
if (session.location.directory !== ctx.location.directory || session.projectID !== ctx.location.project.id
|
|
19
|
+
|| session.location.workspaceID !== ctx.location.workspaceID)
|
|
20
|
+
return { status: "blocked", reason: "not_deletable" };
|
|
21
|
+
const filename = await realpath(configured);
|
|
22
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
23
|
+
const key = `pruning/binding/${randomUUID()}`;
|
|
24
|
+
const nonce = randomUUID();
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
await ctx.storage.set(key, nonce);
|
|
27
|
+
try {
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
const db = new DatabaseSync(filename);
|
|
30
|
+
try {
|
|
31
|
+
// A blocking busy timeout could deadlock Core's async transaction on the
|
|
32
|
+
// same event loop. Return busy immediately; don't interrupt native work.
|
|
33
|
+
db.exec("PRAGMA busy_timeout=0");
|
|
34
|
+
try {
|
|
35
|
+
return pruneTransaction(db, request, () => validateClaimFence(db, request.sessionID, {
|
|
36
|
+
key: storageKey(key), nonce,
|
|
37
|
+
directory: session.location.directory, projectID: session.projectID,
|
|
38
|
+
workspaceID: session.location.workspaceID,
|
|
39
|
+
}), storageKey("pruning/receipt/"));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const code = error.errcode;
|
|
43
|
+
return { status: "blocked", reason: code === 5 || code === 6 || error.code === "SQLITE_BUSY"
|
|
44
|
+
? "maintenance_required" : "unsupported_storage" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
db.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
// Cleanup failure must not turn a committed prune into a failed response.
|
|
53
|
+
await ctx.storage.remove(key).catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/service.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\"\nimport { realpath } from \"node:fs/promises\"\nimport path from \"node:path\"\nimport type { Plugin } from \"@opencode/plugin\"\nimport { storageKey, validateClaimFence } from \"./claim-fence\"\nimport { pruneTransaction } from \"./transaction\"\nimport { pruneRequestSchema, type PruneResult } from \"./contract\"\nimport { pruningDatabasePath } from \"./database-path\"\n\n// Only an explicit pruning RPC calls this service; loading the plugin never prunes.\n// No database access is exposed through CodeNomad's HTTP broker.\nexport async function pruneBoundMessage(\n ctx: Plugin.Context, input: unknown, signal: AbortSignal,\n): Promise<PruneResult> {\n const request = pruneRequestSchema.parse(input)\n const configured = pruningDatabasePath(ctx.options.databasePath, ctx.app.channel)\n if (typeof configured !== \"string\" || !path.isAbsolute(configured)\n || (process.platform === \"win32\" && configured.replaceAll(\"/\", \"\\\\\").startsWith(\"\\\\\\\\\"))) {\n return { status: \"blocked\", reason: \"unsupported_storage\" }\n }\n const session = await ctx.session.get({ sessionID: request.sessionID })\n if (session.location.directory !== ctx.location.directory || session.projectID !== ctx.location.project.id\n || session.location.workspaceID !== ctx.location.workspaceID) return { status: \"blocked\", reason: \"not_deletable\" }\n const filename = await realpath(configured)\n const { DatabaseSync } = await import(\"node:sqlite\")\n const key = `pruning/binding/${randomUUID()}`\n const nonce = randomUUID()\n signal.throwIfAborted()\n await ctx.storage.set(key, nonce)\n try {\n signal.throwIfAborted()\n const db = new DatabaseSync(filename)\n try {\n // A blocking busy timeout could deadlock Core's async transaction on the\n // same event loop. Return busy immediately; don't interrupt native work.\n db.exec(\"PRAGMA busy_timeout=0\")\n try {\n return pruneTransaction(db, request, () => validateClaimFence(db, request.sessionID, {\n key: storageKey(key), nonce,\n directory: session.location.directory, projectID: session.projectID,\n workspaceID: session.location.workspaceID,\n }), storageKey(\"pruning/receipt/\"))\n } catch (error) {\n const code = (error as { errcode?: number; code?: string }).errcode\n return { status: \"blocked\", reason: code === 5 || code === 6 || (error as { code?: string }).code === \"SQLITE_BUSY\"\n ? \"maintenance_required\" : \"unsupported_storage\" }\n }\n } finally { db.close() }\n } finally {\n // Cleanup failure must not turn a committed prune into a failed response.\n await ctx.storage.remove(key).catch(() => {})\n }\n}\n"]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import plugin from "./plugin";
|
|
8
|
+
import { storageDirectory } from "./storage-path";
|
|
9
|
+
import { storageKey } from "./claim-fence";
|
|
10
|
+
import { revision } from "./planner";
|
|
11
|
+
// Exercise the real RPC handlers, storage challenge and file-backed transaction.
|
|
12
|
+
// No shared service, user configuration or real conversation is involved.
|
|
13
|
+
for (const version of ["0.0.0-beta-19425", "future-release", undefined]) {
|
|
14
|
+
test(`pruning uses storage capabilities, not runtime version ${version}`, async () => {
|
|
15
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "pruning-capabilities-"));
|
|
16
|
+
const filename = path.join(root, "fixture.db");
|
|
17
|
+
const db = new DatabaseSync(filename);
|
|
18
|
+
const content = [{ type: "reasoning", text: "REMOVE" }, { type: "text", text: "KEEP" }];
|
|
19
|
+
const data = { time: { completed: 1 }, content };
|
|
20
|
+
const input = { sessionID: "s", messageID: "m", revision: revision(content), indexes: [0] };
|
|
21
|
+
let handlers;
|
|
22
|
+
const events = [];
|
|
23
|
+
let cleanup = undefined;
|
|
24
|
+
try {
|
|
25
|
+
db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY,directory TEXT,project_id TEXT,workspace_id TEXT,time_suspended INTEGER,time_compacting INTEGER,revert TEXT);
|
|
26
|
+
CREATE TABLE event_sequence(aggregate_id TEXT PRIMARY KEY,owner_id TEXT);
|
|
27
|
+
INSERT INTO event_sequence VALUES('s',NULL);
|
|
28
|
+
CREATE TABLE event(aggregate_id TEXT);
|
|
29
|
+
CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER);
|
|
30
|
+
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`);
|
|
31
|
+
db.prepare("INSERT INTO session_v2 VALUES('s',?,'p',NULL,NULL,NULL,NULL)").run(storageDirectory(root));
|
|
32
|
+
db.prepare("INSERT INTO session_message VALUES('m','s','assistant',?)").run(JSON.stringify(data));
|
|
33
|
+
cleanup = await plugin.setup({
|
|
34
|
+
app: { version },
|
|
35
|
+
options: { databasePath: filename },
|
|
36
|
+
location: { directory: root, project: { id: "p" } },
|
|
37
|
+
session: { get: async () => ({ location: { directory: root }, projectID: "p" }) },
|
|
38
|
+
storage: {
|
|
39
|
+
set: async (key, value) => {
|
|
40
|
+
db.prepare("INSERT INTO kv VALUES(?,?,1,1)").run(storageKey(key), JSON.stringify(value));
|
|
41
|
+
},
|
|
42
|
+
remove: async (key) => { db.prepare("DELETE FROM kv WHERE key=?").run(storageKey(key)); },
|
|
43
|
+
},
|
|
44
|
+
rpc: { register: async (_definition, implementation) => {
|
|
45
|
+
handlers = implementation;
|
|
46
|
+
return { dispose: async () => { }, events: { emit: async (...event) => { events.push(event); } } };
|
|
47
|
+
} },
|
|
48
|
+
});
|
|
49
|
+
assert.equal((await handlers.preview({ sessionID: "s", messageID: "m" })).liveMutation, true);
|
|
50
|
+
const call = { signal: new AbortController().signal };
|
|
51
|
+
// Native storage capabilities still fail closed irrespective of the label.
|
|
52
|
+
db.exec("ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim");
|
|
53
|
+
assert.deepEqual(await handlers.prune(input, call), { status: "blocked", reason: "unsupported_storage" });
|
|
54
|
+
assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data), data);
|
|
55
|
+
assert.equal(events.length, 0);
|
|
56
|
+
db.exec("ALTER TABLE session_v2 RENAME COLUMN missing_claim TO time_suspended");
|
|
57
|
+
db.exec("UPDATE session_v2 SET time_suspended=1");
|
|
58
|
+
assert.deepEqual(await handlers.prune(input, call), { status: "blocked", reason: "maintenance_required" });
|
|
59
|
+
db.exec("UPDATE session_v2 SET time_suspended=NULL");
|
|
60
|
+
const result = await handlers.prune(input, call);
|
|
61
|
+
assert.equal(result.status, "pruned");
|
|
62
|
+
assert.deepEqual(await handlers.prune(input, call), result);
|
|
63
|
+
assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, [content[1]]);
|
|
64
|
+
assert.equal(events.length, 2);
|
|
65
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM kv").get().n, 1, "only the atomic receipt remains");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await cleanup?.();
|
|
69
|
+
db.close();
|
|
70
|
+
await rm(root, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=service.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/service.test.ts"],"names":[],"mappings":"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","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport { DatabaseSync } from \"node:sqlite\"\nimport { mkdtemp, rm } from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport plugin from \"./plugin\"\nimport { storageDirectory } from \"./storage-path\"\nimport { storageKey } from \"./claim-fence\"\nimport { revision } from \"./planner\"\n\n// Exercise the real RPC handlers, storage challenge and file-backed transaction.\n// No shared service, user configuration or real conversation is involved.\nfor (const version of [\"0.0.0-beta-19425\", \"future-release\", undefined]) {\n test(`pruning uses storage capabilities, not runtime version ${version}`, async () => {\n const root = await mkdtemp(path.join(os.tmpdir(), \"pruning-capabilities-\"))\n const filename = path.join(root, \"fixture.db\")\n const db = new DatabaseSync(filename)\n const content = [{ type: \"reasoning\", text: \"REMOVE\" }, { type: \"text\", text: \"KEEP\" }]\n const data = { time: { completed: 1 }, content }\n const input = { sessionID: \"s\", messageID: \"m\", revision: revision(content), indexes: [0] }\n let handlers: any\n const events: unknown[] = []\n let cleanup: (() => unknown) | void = undefined\n try {\n db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY,directory TEXT,project_id TEXT,workspace_id TEXT,time_suspended INTEGER,time_compacting INTEGER,revert TEXT);\n CREATE TABLE event_sequence(aggregate_id TEXT PRIMARY KEY,owner_id TEXT);\n INSERT INTO event_sequence VALUES('s',NULL);\n CREATE TABLE event(aggregate_id TEXT);\n CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER);\n CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`)\n db.prepare(\"INSERT INTO session_v2 VALUES('s',?,'p',NULL,NULL,NULL,NULL)\").run(storageDirectory(root))\n db.prepare(\"INSERT INTO session_message VALUES('m','s','assistant',?)\").run(JSON.stringify(data))\n cleanup = await plugin.setup({\n app: { version },\n options: { databasePath: filename },\n location: { directory: root, project: { id: \"p\" } },\n session: { get: async () => ({ location: { directory: root }, projectID: \"p\" }) },\n storage: {\n set: async (key: string, value: unknown) => {\n db.prepare(\"INSERT INTO kv VALUES(?,?,1,1)\").run(storageKey(key), JSON.stringify(value))\n },\n remove: async (key: string) => { db.prepare(\"DELETE FROM kv WHERE key=?\").run(storageKey(key)) },\n },\n rpc: { register: async (_definition: unknown, implementation: unknown) => {\n handlers = implementation\n return { dispose: async () => {}, events: { emit: async (...event: unknown[]) => { events.push(event) } } }\n } },\n } as any)\n assert.equal((await handlers.preview({ sessionID: \"s\", messageID: \"m\" })).liveMutation, true)\n const call = { signal: new AbortController().signal }\n // Native storage capabilities still fail closed irrespective of the label.\n db.exec(\"ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim\")\n assert.deepEqual(await handlers.prune(input, call), { status: \"blocked\", reason: \"unsupported_storage\" })\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string), data)\n assert.equal(events.length, 0)\n db.exec(\"ALTER TABLE session_v2 RENAME COLUMN missing_claim TO time_suspended\")\n db.exec(\"UPDATE session_v2 SET time_suspended=1\")\n assert.deepEqual(await handlers.prune(input, call), { status: \"blocked\", reason: \"maintenance_required\" })\n db.exec(\"UPDATE session_v2 SET time_suspended=NULL\")\n const result = await handlers.prune(input, call)\n assert.equal(result.status, \"pruned\")\n assert.deepEqual(await handlers.prune(input, call), result)\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, [content[1]])\n assert.equal(events.length, 2)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv\").get()!.n, 1, \"only the atomic receipt remains\")\n } finally {\n await cleanup?.()\n db.close()\n await rm(root, { recursive: true, force: true })\n }\n })\n}\n"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Matches OpenCode's database/path.ts encoding, without changing case or
|
|
2
|
+
// treating POSIX backslashes as separators.
|
|
3
|
+
export function storageDirectory(directory) {
|
|
4
|
+
return process.platform === "win32" ? directory.replaceAll("\\", "/") : directory;
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=storage-path.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-path.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/storage-path.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,4CAA4C;AAC5C,MAAM,UAAU,gBAAgB,CAAC,SAAiB;IAChD,OAAO,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AACnF,CAAC","sourcesContent":["// Matches OpenCode's database/path.ts encoding, without changing case or\n// treating POSIX backslashes as separators.\nexport function storageDirectory(directory: string): string {\n return process.platform === \"win32\" ? directory.replaceAll(\"\\\\\", \"/\") : directory\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { pruneRequestSchema, pruneResultSchema } from "./contract";
|
|
3
|
+
import { planPrune, revision } from "./planner";
|
|
4
|
+
import { canonicalContent } from "./revision";
|
|
5
|
+
// Internal kernel. No async callback or network work is allowed while the
|
|
6
|
+
// SQLite write lock is held. Production callers must supply the claim fence.
|
|
7
|
+
export function pruneTransaction(db, input, authorize, receiptPrefix) {
|
|
8
|
+
const request = pruneRequestSchema.parse(input);
|
|
9
|
+
if (db.isTransaction)
|
|
10
|
+
return { status: "blocked", reason: "maintenance_required" };
|
|
11
|
+
db.exec("BEGIN IMMEDIATE");
|
|
12
|
+
try {
|
|
13
|
+
const blocked = authorize();
|
|
14
|
+
if (blocked)
|
|
15
|
+
return { status: "blocked", reason: blocked };
|
|
16
|
+
const key = receiptPrefix && receiptPrefix + createHash("sha256")
|
|
17
|
+
.update(canonicalContent({ ...request, indexes: [...request.indexes].sort((a, b) => a - b) })).digest("hex");
|
|
18
|
+
if (key) {
|
|
19
|
+
const receipt = db.prepare("SELECT value FROM kv WHERE key=?").get(key);
|
|
20
|
+
if (typeof receipt?.value === "string") {
|
|
21
|
+
const result = pruneResultSchema.parse(JSON.parse(receipt.value));
|
|
22
|
+
if (result.status !== "pruned" || result.messageID !== request.messageID)
|
|
23
|
+
return { status: "blocked", reason: "unsupported_storage" };
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const row = db.prepare(`SELECT type, data FROM session_message WHERE session_id=? AND id=?
|
|
28
|
+
AND length(CAST(data AS BLOB)) <= 16777216`).get(request.sessionID, request.messageID);
|
|
29
|
+
if (!row || row.type !== "assistant" || typeof row.data !== "string")
|
|
30
|
+
return { status: "blocked", reason: "not_deletable" };
|
|
31
|
+
const plan = planPrune(JSON.parse(row.data), request);
|
|
32
|
+
if (plan.status !== "planned")
|
|
33
|
+
return plan;
|
|
34
|
+
const update = db.prepare("UPDATE session_message SET data=json_set(data,'$.content',json(?)) WHERE session_id=? AND id=? AND data=?")
|
|
35
|
+
.run(JSON.stringify(plan.content), request.sessionID, request.messageID, row.data);
|
|
36
|
+
if (update.changes !== 1)
|
|
37
|
+
return { status: "blocked", reason: "conflict" };
|
|
38
|
+
const result = { status: "pruned", messageID: request.messageID, revision: revision(plan.content), removedCount: request.indexes.length };
|
|
39
|
+
if (key) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
// A receipt contains hashes/counts/IDs only, not deleted tool payloads.
|
|
42
|
+
db.prepare("INSERT INTO kv(key,value,time_created,time_updated) VALUES(?,?,?,?)")
|
|
43
|
+
.run(key, JSON.stringify(result), now, now);
|
|
44
|
+
}
|
|
45
|
+
db.exec("COMMIT");
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (db.isTransaction)
|
|
50
|
+
db.exec("ROLLBACK");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=transaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transaction.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/transaction.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DatabaseSync } from \"node:sqlite\"\nimport { createHash } from \"node:crypto\"\nimport { pruneRequestSchema, pruneResultSchema, type PruneRequest, type PruneResult } from \"./contract\"\nimport { planPrune, revision } from \"./planner\"\nimport { canonicalContent } from \"./revision\"\n\ntype BlockReason = Extract<PruneResult, { status: \"blocked\" }>[\"reason\"]\n\n// Internal kernel. No async callback or network work is allowed while the\n// SQLite write lock is held. Production callers must supply the claim fence.\nexport function pruneTransaction(\n db: DatabaseSync, input: PruneRequest, authorize: () => BlockReason | undefined,\n receiptPrefix?: string,\n): PruneResult {\n const request = pruneRequestSchema.parse(input)\n if (db.isTransaction) return { status: \"blocked\", reason: \"maintenance_required\" }\n db.exec(\"BEGIN IMMEDIATE\")\n try {\n const blocked = authorize()\n if (blocked) return { status: \"blocked\", reason: blocked }\n const key = receiptPrefix && receiptPrefix + createHash(\"sha256\")\n .update(canonicalContent({ ...request, indexes: [...request.indexes].sort((a, b) => a - b) })).digest(\"hex\")\n if (key) {\n const receipt = db.prepare(\"SELECT value FROM kv WHERE key=?\").get(key)\n if (typeof receipt?.value === \"string\") {\n const result = pruneResultSchema.parse(JSON.parse(receipt.value))\n if (result.status !== \"pruned\" || result.messageID !== request.messageID) return { status: \"blocked\", reason: \"unsupported_storage\" }\n return result\n }\n }\n const row = db.prepare(`SELECT type, data FROM session_message WHERE session_id=? AND id=?\n AND length(CAST(data AS BLOB)) <= 16777216`).get(request.sessionID, request.messageID)\n if (!row || row.type !== \"assistant\" || typeof row.data !== \"string\") return { status: \"blocked\", reason: \"not_deletable\" }\n const plan = planPrune(JSON.parse(row.data), request)\n if (plan.status !== \"planned\") return plan\n const update = db.prepare(\"UPDATE session_message SET data=json_set(data,'$.content',json(?)) WHERE session_id=? AND id=? AND data=?\")\n .run(JSON.stringify(plan.content), request.sessionID, request.messageID, row.data)\n if (update.changes !== 1) return { status: \"blocked\", reason: \"conflict\" }\n const result: PruneResult = { status: \"pruned\", messageID: request.messageID, revision: revision(plan.content), removedCount: request.indexes.length }\n if (key) {\n const now = Date.now()\n // A receipt contains hashes/counts/IDs only, not deleted tool payloads.\n db.prepare(\"INSERT INTO kv(key,value,time_created,time_updated) VALUES(?,?,?,?)\")\n .run(key, JSON.stringify(result), now, now)\n }\n db.exec(\"COMMIT\")\n return result\n } finally {\n if (db.isTransaction) db.exec(\"ROLLBACK\")\n }\n}\n"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { PRUNING_EVENT, prunedEventSchema } from "./contract";
|
|
2
|
+
function hasPagination(message) {
|
|
3
|
+
return "loading" in message && typeof message.loading === "function"
|
|
4
|
+
&& "loadMore" in message && typeof message.loadMore === "function";
|
|
5
|
+
}
|
|
6
|
+
export function createPruningReload(cache) {
|
|
7
|
+
// These are public @opencode/client/solid APIs. beta-19419's plugin Data
|
|
8
|
+
// declaration omits them, but its audited TUI host passes the original cache
|
|
9
|
+
// directly (f91c6d8b25, packages/tui/src/plugin/api.tsx: data: host.data).
|
|
10
|
+
// Fail explicitly if that capability changes; invalidate/sync alone is unsafe.
|
|
11
|
+
const messages = cache.message;
|
|
12
|
+
if (!hasPagination(messages))
|
|
13
|
+
throw new Error("Unsupported TUI cache: pagination publication API is unavailable");
|
|
14
|
+
const pending = new Map();
|
|
15
|
+
const lifetime = new AbortController();
|
|
16
|
+
let disposed = false;
|
|
17
|
+
const refresh = (sessionID) => {
|
|
18
|
+
if (disposed)
|
|
19
|
+
return;
|
|
20
|
+
cache.message.invalidate(sessionID);
|
|
21
|
+
const current = pending.get(sessionID);
|
|
22
|
+
if (current) {
|
|
23
|
+
current.dirty = true;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const state = { dirty: false };
|
|
27
|
+
pending.set(sessionID, state);
|
|
28
|
+
void (async () => {
|
|
29
|
+
try {
|
|
30
|
+
do {
|
|
31
|
+
state.dirty = false;
|
|
32
|
+
// beta-19419 tracks pagination independently of invalidate/sync. Join
|
|
33
|
+
// the public loadMore publication before the authoritative read; an
|
|
34
|
+
// older page could otherwise reinsert deleted blocks after sync.
|
|
35
|
+
// With no `all` option, loadMore joins an active successful page rather
|
|
36
|
+
// than fetching another. A failed page must not prevent the reread.
|
|
37
|
+
if (messages.loading(sessionID)) {
|
|
38
|
+
await messages.loadMore(sessionID, { signal: lifetime.signal }).catch(() => undefined);
|
|
39
|
+
}
|
|
40
|
+
if (disposed)
|
|
41
|
+
return;
|
|
42
|
+
// Invalidate again after the join: another UI owner may have synced
|
|
43
|
+
// while we waited, and each coalesced prune needs a fresh read.
|
|
44
|
+
cache.message.invalidate(sessionID);
|
|
45
|
+
await cache.message.sync(sessionID);
|
|
46
|
+
} while (state.dirty && !disposed);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Leave stale data invalidated. Reopening or reconnecting retries it.
|
|
50
|
+
if (!disposed)
|
|
51
|
+
cache.message.invalidate(sessionID);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
pending.delete(sessionID);
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
event(event) {
|
|
60
|
+
if (disposed)
|
|
61
|
+
return;
|
|
62
|
+
if (event.type === "server.connected") {
|
|
63
|
+
for (const session of cache.list()) {
|
|
64
|
+
if (cache.message.list(session.id).length)
|
|
65
|
+
refresh(session.id);
|
|
66
|
+
else
|
|
67
|
+
cache.message.invalidate(session.id);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (event.type !== PRUNING_EVENT)
|
|
72
|
+
return;
|
|
73
|
+
const parsed = prunedEventSchema.safeParse(event.data);
|
|
74
|
+
if (!parsed.success)
|
|
75
|
+
return;
|
|
76
|
+
const session = cache.get(parsed.data.sessionID);
|
|
77
|
+
if (!session || session.location.directory !== event.location?.directory)
|
|
78
|
+
return;
|
|
79
|
+
refresh(parsed.data.sessionID);
|
|
80
|
+
},
|
|
81
|
+
dispose() { disposed = true; lifetime.abort(); },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=tui-reload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tui-reload.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/tui-reload.ts"],"names":[],"mappings":"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","sourcesContent":["import { PRUNING_EVENT, prunedEventSchema } from \"./contract\"\n\ninterface SessionCache {\n list(): Array<{ id: string }>\n get(id: string): { location: { directory: string } } | undefined\n message: Partial<Pagination> & {\n list(id: string): unknown[]\n invalidate(id: string): void\n sync(id: string): Promise<void>\n }\n}\n\ninterface Pagination {\n loading(id: string): boolean\n loadMore(id: string, options?: { signal?: AbortSignal }): Promise<void>\n}\n\nfunction hasPagination(message: SessionCache[\"message\"]): message is SessionCache[\"message\"] & Pagination {\n return \"loading\" in message && typeof message.loading === \"function\"\n && \"loadMore\" in message && typeof message.loadMore === \"function\"\n}\n\nexport function createPruningReload(cache: SessionCache) {\n // These are public @opencode/client/solid APIs. beta-19419's plugin Data\n // declaration omits them, but its audited TUI host passes the original cache\n // directly (f91c6d8b25, packages/tui/src/plugin/api.tsx: data: host.data).\n // Fail explicitly if that capability changes; invalidate/sync alone is unsafe.\n const messages = cache.message\n if (!hasPagination(messages)) throw new Error(\"Unsupported TUI cache: pagination publication API is unavailable\")\n const pending = new Map<string, { dirty: boolean }>()\n const lifetime = new AbortController()\n let disposed = false\n const refresh = (sessionID: string) => {\n if (disposed) return\n cache.message.invalidate(sessionID)\n const current = pending.get(sessionID)\n if (current) { current.dirty = true; return }\n const state = { dirty: false }\n pending.set(sessionID, state)\n void (async () => {\n try {\n do {\n state.dirty = false\n // beta-19419 tracks pagination independently of invalidate/sync. Join\n // the public loadMore publication before the authoritative read; an\n // older page could otherwise reinsert deleted blocks after sync.\n // With no `all` option, loadMore joins an active successful page rather\n // than fetching another. A failed page must not prevent the reread.\n if (messages.loading(sessionID)) {\n await messages.loadMore(sessionID, { signal: lifetime.signal }).catch(() => undefined)\n }\n if (disposed) return\n // Invalidate again after the join: another UI owner may have synced\n // while we waited, and each coalesced prune needs a fresh read.\n cache.message.invalidate(sessionID)\n await cache.message.sync(sessionID)\n } while (state.dirty && !disposed)\n } catch {\n // Leave stale data invalidated. Reopening or reconnecting retries it.\n if (!disposed) cache.message.invalidate(sessionID)\n } finally { pending.delete(sessionID) }\n })()\n }\n return {\n event(event: { type: string; data?: unknown; location?: { directory: string } }) {\n if (disposed) return\n if (event.type === \"server.connected\") {\n for (const session of cache.list()) {\n if (cache.message.list(session.id).length) refresh(session.id)\n else cache.message.invalidate(session.id)\n }\n return\n }\n if (event.type !== PRUNING_EVENT) return\n const parsed = prunedEventSchema.safeParse(event.data)\n if (!parsed.success) return\n const session = cache.get(parsed.data.sessionID)\n if (!session || session.location.directory !== event.location?.directory) return\n refresh(parsed.data.sessionID)\n },\n dispose() { disposed = true; lifetime.abort() },\n }\n}\n"]}
|