@neuralnomads/codenomad-dev 0.20.0-dev-20260909-855d2ee7 → 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 +42 -31
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +9 -1
- package/dist/index.test.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/__tests__/listener-base-url.test.js +26 -1
- package/dist/server/__tests__/listener-base-url.test.js.map +1 -1
- package/dist/server/__tests__/network-addresses.test.js +40 -0
- package/dist/server/__tests__/network-addresses.test.js.map +1 -1
- package/dist/server/__tests__/network-host.test.js +34 -0
- package/dist/server/__tests__/network-host.test.js.map +1 -0
- package/dist/server/__tests__/tls-ipv6.test.js +54 -0
- package/dist/server/__tests__/tls-ipv6.test.js.map +1 -0
- package/dist/server/http-server.js +8 -6
- package/dist/server/http-server.js.map +1 -1
- package/dist/server/listener-base-url.js +12 -3
- package/dist/server/listener-base-url.js.map +1 -1
- package/dist/server/network-addresses.js +20 -42
- package/dist/server/network-addresses.js.map +1 -1
- package/dist/server/network-host.js +62 -0
- package/dist/server/network-host.js.map +1 -0
- package/dist/server/routes/meta.js +2 -4
- package/dist/server/routes/meta.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/server/tls.js +52 -32
- package/dist/server/tls.js.map +1 -1
- 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-CRpGDs1B.js +3 -0
- package/public/assets/{FilesTab-z7UrjcAQ.js.map → FilesTab-CRpGDs1B.js.map} +1 -1
- package/public/assets/{GitChangesTab-C04bYKhs.js → GitChangesTab-DxFebTiq.js} +3 -3
- package/public/assets/{GitChangesTab-C04bYKhs.js.map → GitChangesTab-DxFebTiq.js.map} +1 -1
- package/public/assets/{SplitFilePanel-B29MqCuU.js → SplitFilePanel-DgTMrrO-.js} +2 -2
- package/public/assets/{SplitFilePanel-B29MqCuU.js.map → SplitFilePanel-DgTMrrO-.js.map} +1 -1
- package/public/assets/{StatusTab-wUKfwnFf.js → StatusTab-LKUA9cbH.js} +2 -2
- package/public/assets/{StatusTab-wUKfwnFf.js.map → StatusTab-LKUA9cbH.js.map} +1 -1
- package/public/assets/{align-justify-glU678D9.js → align-justify-v_yPXSys.js} +2 -2
- package/public/assets/{align-justify-glU678D9.js.map → align-justify-v_yPXSys.js.map} +1 -1
- package/public/assets/{bundle-full-BrsCElYv.js → bundle-full-HzL_Jtqk.js} +2 -2
- package/public/assets/{bundle-full-BrsCElYv.js.map → bundle-full-HzL_Jtqk.js.map} +1 -1
- package/public/assets/{client-state-j9Iu7F-E.js → client-state-CF-PJ0GG.js} +2 -2
- package/public/assets/{client-state-j9Iu7F-E.js.map → client-state-CF-PJ0GG.js.map} +1 -1
- package/public/assets/{diff-viewer-B0VS1voD.js → diff-viewer-BFDQBDVy.js} +2 -2
- package/public/assets/{diff-viewer-B0VS1voD.js.map → diff-viewer-BFDQBDVy.js.map} +1 -1
- package/public/assets/index-8iPlm2r0.js +2 -0
- package/public/assets/index-8iPlm2r0.js.map +1 -0
- package/public/assets/index-B185mC7m.js +2 -0
- package/public/assets/index-B185mC7m.js.map +1 -0
- package/public/assets/index-Bbj1xF8i.js +2 -0
- package/public/assets/index-Bbj1xF8i.js.map +1 -0
- package/public/assets/index-BsmFN3k1.js +2 -0
- package/public/assets/index-BsmFN3k1.js.map +1 -0
- package/public/assets/index-Bz_OQJYA.css +1 -0
- package/public/assets/index-C52CfqK3.js +2 -0
- package/public/assets/index-C52CfqK3.js.map +1 -0
- package/public/assets/index-CHYmKl10.js +2 -0
- package/public/assets/index-CHYmKl10.js.map +1 -0
- package/public/assets/index-CTKYD_VJ.js +2 -0
- package/public/assets/index-CTKYD_VJ.js.map +1 -0
- package/public/assets/index-D2UHSOG5.js +2 -0
- package/public/assets/{index-ClaHfu-1.js.map → index-D2UHSOG5.js.map} +1 -1
- package/public/assets/index-DDoB_Z36.js +2 -0
- package/public/assets/index-DDoB_Z36.js.map +1 -0
- package/public/assets/{index-BzAEHHap.js → index-DcwAtZw-.js} +3 -3
- package/public/assets/index-DcwAtZw-.js.map +1 -0
- package/public/assets/index-DxUD3RYr.js +2 -0
- package/public/assets/index-DxUD3RYr.js.map +1 -0
- package/public/assets/{index-B6bX3AMT.js → index-RK9gf0uk.js} +2 -2
- package/public/assets/{index-B6bX3AMT.js.map → index-RK9gf0uk.js.map} +1 -1
- package/public/assets/{loading-CYiiwb08.js → loading-BSB82snY.js} +2 -2
- package/public/assets/{loading-CYiiwb08.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-DWvSdCZz.js → markdown-DtidoL2Z.js} +4 -4
- package/public/assets/{markdown-DWvSdCZz.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-2KHpBL8U.js → startup-state-settings-card-BY741Rt0.js} +2 -2
- package/public/assets/{startup-state-settings-card-2KHpBL8U.js.map → startup-state-settings-card-BY741Rt0.js.map} +1 -1
- package/public/assets/{terminal-square-lzHuj_of.js → terminal-square-BdC_jwm3.js} +2 -2
- package/public/assets/{terminal-square-lzHuj_of.js.map → terminal-square-BdC_jwm3.js.map} +1 -1
- package/public/assets/{tool-call-DjkszeT4.js → tool-call-DbtCsJ4x.js} +10 -10
- package/public/assets/{tool-call-DjkszeT4.js.map → tool-call-DbtCsJ4x.js.map} +1 -1
- package/public/assets/{unified-picker-5JmIM6a0.js → unified-picker-C0eo1chJ.js} +2 -2
- package/public/assets/{unified-picker-5JmIM6a0.js.map → unified-picker-C0eo1chJ.js.map} +1 -1
- package/public/assets/{wrap-text-N0Cav2Vp.js → wrap-text-BuMtdvMx.js} +2 -2
- package/public/assets/{wrap-text-N0Cav2Vp.js.map → wrap-text-BuMtdvMx.js.map} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/sw.js.map +1 -1
- package/public/assets/App-Nlt9zV-v.js +0 -43
- package/public/assets/App-Nlt9zV-v.js.map +0 -1
- package/public/assets/FilesTab-z7UrjcAQ.js +0 -3
- package/public/assets/index-BIRLnDWY.js +0 -2
- package/public/assets/index-BIRLnDWY.js.map +0 -1
- package/public/assets/index-BuScpzpP.js +0 -2
- package/public/assets/index-BuScpzpP.js.map +0 -1
- package/public/assets/index-BzAEHHap.js.map +0 -1
- package/public/assets/index-C-DZt-oz.css +0 -1
- package/public/assets/index-C9RXVQl9.js +0 -2
- package/public/assets/index-C9RXVQl9.js.map +0 -1
- package/public/assets/index-ClaHfu-1.js +0 -2
- package/public/assets/index-DBx5htn3.js +0 -2
- package/public/assets/index-DBx5htn3.js.map +0 -1
- package/public/assets/index-DHHMf3u1.js +0 -2
- package/public/assets/index-DHHMf3u1.js.map +0 -1
- package/public/assets/index-DN4Kstsd.js +0 -2
- package/public/assets/index-DN4Kstsd.js.map +0 -1
- package/public/assets/index-DbMyNDyo.js +0 -2
- package/public/assets/index-DbMyNDyo.js.map +0 -1
- package/public/assets/index-rcsmuqww.js +0 -2
- package/public/assets/index-rcsmuqww.js.map +0 -1
- package/public/assets/index-uz2QFRlk.js +0 -2
- package/public/assets/index-uz2QFRlk.js.map +0 -1
- package/public/assets/main-OmXs7qtG.js +0 -48
- package/public/assets/main-OmXs7qtG.js.map +0 -1
- package/public/assets/monaco-viewer-Cc9bWg7I.js.map +0 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { pruneTransaction } from "./transaction";
|
|
2
|
+
import { storageDirectory } from "./storage-path";
|
|
3
|
+
// Intentionally memory-only in this draft. No filename discovery or live DB
|
|
4
|
+
// connection exists. A SQLite lock alone does not fence Core execution/caches.
|
|
5
|
+
export function pruneIsolatedMessage(db, input, directory) {
|
|
6
|
+
const databases = db.prepare("PRAGMA database_list").all();
|
|
7
|
+
if (databases.length !== 1 || databases[0].file !== "" || db.isTransaction) {
|
|
8
|
+
return { status: "blocked", reason: "maintenance_required" };
|
|
9
|
+
}
|
|
10
|
+
return pruneTransaction(db, input, () => {
|
|
11
|
+
if (db.prepare("SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1").get()
|
|
12
|
+
|| db.prepare("SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1").get(input.sessionID))
|
|
13
|
+
return "unsupported_storage";
|
|
14
|
+
const session = db.prepare("SELECT directory FROM session_v2 WHERE id=?").get(input.sessionID);
|
|
15
|
+
return session?.directory === storageDirectory(directory) ? undefined : "not_deletable";
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=isolated-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"isolated-store.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/isolated-store.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM,UAAU,oBAAoB,CAAC,EAAgB,EAAE,KAAmB,EAAE,SAAiB;IAC3F,MAAM,SAAS,GAAG,EAAE,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,GAAG,EAAE,CAAA;IAC1D,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,IAAI,EAAE,CAAC,aAAa,EAAE,CAAC;QAC3E,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAA;IAC9D,CAAC;IACD,OAAO,gBAAgB,CAAC,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE;QACtC,IAAI,EAAE,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,GAAG,EAAE;eAC3E,EAAE,CAAC,OAAO,CAAC,kDAAkD,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC;YAAE,OAAO,qBAAqB,CAAA;QACtH,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,6CAA6C,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;QAC9F,OAAO,OAAO,EAAE,SAAS,KAAK,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,eAAe,CAAA;IACzF,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import type { DatabaseSync } from \"node:sqlite\"\nimport { type PruneRequest, type PruneResult } from \"./contract\"\nimport { pruneTransaction } from \"./transaction\"\nimport { storageDirectory } from \"./storage-path\"\n\n// Intentionally memory-only in this draft. No filename discovery or live DB\n// connection exists. A SQLite lock alone does not fence Core execution/caches.\nexport function pruneIsolatedMessage(db: DatabaseSync, input: PruneRequest, directory: string): PruneResult {\n const databases = db.prepare(\"PRAGMA database_list\").all()\n if (databases.length !== 1 || databases[0].file !== \"\" || db.isTransaction) {\n return { status: \"blocked\", reason: \"maintenance_required\" }\n }\n return pruneTransaction(db, input, () => {\n if (db.prepare(\"SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1\").get()\n || db.prepare(\"SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1\").get(input.sessionID)) return \"unsupported_storage\"\n const session = db.prepare(\"SELECT directory FROM session_v2 WHERE id=?\").get(input.sessionID)\n return session?.directory === storageDirectory(directory) ? undefined : \"not_deletable\"\n })\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { canonicalContent } from "./revision";
|
|
4
|
+
const partSchema = z.object({ type: z.enum(["text", "tool", "reasoning"]) }).passthrough();
|
|
5
|
+
export const assistantDataSchema = z.object({
|
|
6
|
+
time: z.object({ completed: z.number().finite().positive() }).passthrough(),
|
|
7
|
+
content: z.array(partSchema).max(100_001),
|
|
8
|
+
}).passthrough();
|
|
9
|
+
export function revision(content) {
|
|
10
|
+
return createHash("sha256").update(canonicalContent(content)).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
export function previewContent(data) {
|
|
13
|
+
const parsed = assistantDataSchema.safeParse(data);
|
|
14
|
+
if (!parsed.success || parsed.data.content.some(part => part.type === "tool"
|
|
15
|
+
&& !["completed", "error"].includes(String(part.state?.status)))) {
|
|
16
|
+
return { status: "blocked", reason: "not_deletable" };
|
|
17
|
+
}
|
|
18
|
+
const parts = parsed.data.content.flatMap((part, index) => part.type === "text" ? [] : [{
|
|
19
|
+
index, type: part.type, bytes: Buffer.byteLength(JSON.stringify(part)),
|
|
20
|
+
}]);
|
|
21
|
+
return { status: "preview", revision: revision(parsed.data.content), liveMutation: false, parts };
|
|
22
|
+
}
|
|
23
|
+
export function planPrune(data, request) {
|
|
24
|
+
const preview = previewContent(data);
|
|
25
|
+
if (preview.status === "blocked")
|
|
26
|
+
return preview;
|
|
27
|
+
if (preview.revision !== request.revision)
|
|
28
|
+
return { status: "blocked", reason: "conflict" };
|
|
29
|
+
const allowed = new Set(preview.parts.map(part => part.index));
|
|
30
|
+
if (request.indexes.some(index => !allowed.has(index)))
|
|
31
|
+
return { status: "blocked", reason: "not_deletable" };
|
|
32
|
+
const parsed = assistantDataSchema.parse(data);
|
|
33
|
+
const selected = new Set(request.indexes);
|
|
34
|
+
return { status: "planned", content: parsed.content.filter((_, index) => !selected.has(index)) };
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=planner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"planner.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/planner.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from \"node:crypto\"\nimport { z } from \"zod\"\nimport { canonicalContent } from \"./revision\"\nimport type { PruneRequest } from \"./contract\"\n\nconst partSchema = z.object({ type: z.enum([\"text\", \"tool\", \"reasoning\"]) }).passthrough()\nexport const assistantDataSchema = z.object({\n time: z.object({ completed: z.number().finite().positive() }).passthrough(),\n content: z.array(partSchema).max(100_001),\n}).passthrough()\n\nexport function revision(content: unknown): string {\n return createHash(\"sha256\").update(canonicalContent(content)).digest(\"hex\")\n}\n\nexport function previewContent(data: unknown) {\n const parsed = assistantDataSchema.safeParse(data)\n if (!parsed.success || parsed.data.content.some(part => part.type === \"tool\"\n && ![\"completed\", \"error\"].includes(String((part.state as { status?: unknown } | undefined)?.status)))) {\n return { status: \"blocked\", reason: \"not_deletable\" } as const\n }\n const parts = parsed.data.content.flatMap((part, index) => part.type === \"text\" ? [] : [{\n index, type: part.type as \"tool\" | \"reasoning\", bytes: Buffer.byteLength(JSON.stringify(part)),\n }])\n return { status: \"preview\", revision: revision(parsed.data.content), liveMutation: false, parts } as const\n}\n\nexport function planPrune(data: unknown, request: PruneRequest) {\n const preview = previewContent(data)\n if (preview.status === \"blocked\") return preview\n if (preview.revision !== request.revision) return { status: \"blocked\", reason: \"conflict\" } as const\n const allowed = new Set(preview.parts.map(part => part.index))\n if (request.indexes.some(index => !allowed.has(index))) return { status: \"blocked\", reason: \"not_deletable\" } as const\n const parsed = assistantDataSchema.parse(data)\n const selected = new Set(request.indexes)\n return { status: \"planned\", content: parsed.content.filter((_, index) => !selected.has(index)) } as const\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
3
|
+
import { messageTargetSchema, pruneRequestSchema, pruningRpcDefinition } from "./contract";
|
|
4
|
+
import { previewContent } from "./planner";
|
|
5
|
+
import { readPruningPreview } from "./preview-store";
|
|
6
|
+
import { pruneBoundMessage } from "./service";
|
|
7
|
+
import { pruningDatabasePath } from "./database-path";
|
|
8
|
+
export const SessionPruningRpc = Rpc.define(pruningRpcDefinition);
|
|
9
|
+
// Native local-plugin entry point. Only a user's pruning RPC changes content.
|
|
10
|
+
export default Plugin.define({
|
|
11
|
+
id: "codenomad-session-pruning",
|
|
12
|
+
async setup(ctx) {
|
|
13
|
+
const registration = await ctx.rpc.register(SessionPruningRpc, {
|
|
14
|
+
preview: async (input) => {
|
|
15
|
+
const target = messageTargetSchema.parse(input);
|
|
16
|
+
const session = await ctx.session.get({ sessionID: target.sessionID });
|
|
17
|
+
// Location membership must be checked again inside the plugin: clients
|
|
18
|
+
// other than CodeNomad may invoke this RPC directly.
|
|
19
|
+
if (session.location.directory !== ctx.location.directory)
|
|
20
|
+
return { status: "blocked", reason: "not_deletable" };
|
|
21
|
+
// Plugin Context does not expose session.message in beta-19398. Read
|
|
22
|
+
// the explicit DB in query-only mode, including pre-compaction history.
|
|
23
|
+
const data = await readPruningPreview(pruningDatabasePath(ctx.options.databasePath, ctx.app.channel), target, session.location.directory);
|
|
24
|
+
const preview = data ? previewContent(data) : { status: "blocked", reason: "unavailable" };
|
|
25
|
+
return preview.status === "preview"
|
|
26
|
+
? { ...preview, liveMutation: true }
|
|
27
|
+
: preview;
|
|
28
|
+
},
|
|
29
|
+
prune: async (input, call) => {
|
|
30
|
+
pruneRequestSchema.parse(input);
|
|
31
|
+
const result = await pruneBoundMessage(ctx, input, call.signal);
|
|
32
|
+
if (result.status === "pruned") {
|
|
33
|
+
// Retrying the same input returns its atomic receipt and re-emits the
|
|
34
|
+
// notification. An emission failure cannot undo a committed write.
|
|
35
|
+
await registration.events.emit("pruned", {
|
|
36
|
+
sessionID: input.sessionID, messageID: input.messageID, revision: result.revision,
|
|
37
|
+
}).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return () => registration.dispose();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAA;AAC1C,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAC1F,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAC1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAC7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAA;AAEjE,8EAA8E;AAC9E,eAAe,MAAM,CAAC,MAAM,CAAC;IAC3B,EAAE,EAAE,2BAA2B;IAC/B,KAAK,CAAC,KAAK,CAAC,GAAG;QACb,MAAM,YAAY,GAAG,MAAM,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,iBAAiB,EAAE;YAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;gBACvB,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;gBAC/C,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAA;gBACtE,uEAAuE;gBACvE,qDAAqD;gBACrD,IAAI,OAAO,CAAC,QAAQ,CAAC,SAAS,KAAK,GAAG,CAAC,QAAQ,CAAC,SAAS;oBAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,EAAW,CAAA;gBACzH,qEAAqE;gBACrE,wEAAwE;gBACxE,MAAM,IAAI,GAAG,MAAM,kBAAkB,CAAC,mBAAmB,CAAC,GAAG,CAAC,OAAO,CAAC,YAAY,EAAE,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAA;gBACzI,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAW,CAAA;gBACnG,OAAO,OAAO,CAAC,MAAM,KAAK,SAAS;oBACjC,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE;oBACpC,CAAC,CAAC,OAAO,CAAA;YACb,CAAC;YACD,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;gBAC3B,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;gBAC/B,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;gBAC/D,IAAI,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;oBAC/B,sEAAsE;oBACtE,mEAAmE;oBACnE,MAAM,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE;wBACvC,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ;qBAClF,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;gBACpB,CAAC;gBACD,OAAO,MAAM,CAAA;YACf,CAAC;SACF,CAAC,CAAA;QACF,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,CAAA;IACrC,CAAC;CACF,CAAC,CAAA","sourcesContent":["import { Plugin } from \"@opencode/plugin\"\nimport { Rpc } from \"@opencode/plugin/rpc\"\nimport { messageTargetSchema, pruneRequestSchema, pruningRpcDefinition } from \"./contract\"\nimport { previewContent } from \"./planner\"\nimport { readPruningPreview } from \"./preview-store\"\nimport { pruneBoundMessage } from \"./service\"\nimport { pruningDatabasePath } from \"./database-path\"\n\nexport const SessionPruningRpc = Rpc.define(pruningRpcDefinition)\n\n// Native local-plugin entry point. Only a user's pruning RPC changes content.\nexport default Plugin.define({\n id: \"codenomad-session-pruning\",\n async setup(ctx) {\n const registration = await ctx.rpc.register(SessionPruningRpc, {\n preview: async (input) => {\n const target = messageTargetSchema.parse(input)\n const session = await ctx.session.get({ sessionID: target.sessionID })\n // Location membership must be checked again inside the plugin: clients\n // other than CodeNomad may invoke this RPC directly.\n if (session.location.directory !== ctx.location.directory) return { status: \"blocked\", reason: \"not_deletable\" } as const\n // Plugin Context does not expose session.message in beta-19398. Read\n // the explicit DB in query-only mode, including pre-compaction history.\n const data = await readPruningPreview(pruningDatabasePath(ctx.options.databasePath, ctx.app.channel), target, session.location.directory)\n const preview = data ? previewContent(data) : { status: \"blocked\", reason: \"unavailable\" } as const\n return preview.status === \"preview\"\n ? { ...preview, liveMutation: true }\n : preview\n },\n prune: async (input, call) => {\n pruneRequestSchema.parse(input)\n const result = await pruneBoundMessage(ctx, input, call.signal)\n if (result.status === \"pruned\") {\n // Retrying the same input returns its atomic receipt and re-emits the\n // notification. An emission failure cannot undo a committed write.\n await registration.events.emit(\"pruned\", {\n sessionID: input.sessionID, messageID: input.messageID, revision: result.revision,\n }).catch(() => {})\n }\n return result\n },\n })\n return () => registration.dispose()\n },\n})\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const PRESENCE_INTERVAL_MS = 2_000;
|
|
4
|
+
export const PRESENCE_EXPIRY_MS = 15_000;
|
|
5
|
+
export async function hasPresence(directory, now = Date.now()) {
|
|
6
|
+
const names = await readdir(directory).catch(() => []);
|
|
7
|
+
const active = await Promise.all(names.filter(name => /^[\da-f-]+\.lease$/.test(name)).map(async (name) => {
|
|
8
|
+
const entry = await stat(path.join(directory, name)).catch(() => undefined);
|
|
9
|
+
return Boolean(entry?.isFile() && now - entry.mtimeMs < PRESENCE_EXPIRY_MS);
|
|
10
|
+
}));
|
|
11
|
+
return active.some(Boolean);
|
|
12
|
+
}
|
|
13
|
+
// Serial reconciliation prevents overlapping setup/dispose and resurrection on unload.
|
|
14
|
+
export async function followPresence(directory, register, onError = console.error) {
|
|
15
|
+
let dispose;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let pending;
|
|
18
|
+
const reconcile = () => {
|
|
19
|
+
if (pending)
|
|
20
|
+
return pending;
|
|
21
|
+
pending = (async () => {
|
|
22
|
+
const active = !stopped && await hasPresence(directory);
|
|
23
|
+
if (active && !dispose && !stopped)
|
|
24
|
+
dispose = await register();
|
|
25
|
+
if ((!active || stopped) && dispose) {
|
|
26
|
+
await dispose();
|
|
27
|
+
dispose = undefined;
|
|
28
|
+
}
|
|
29
|
+
})().finally(() => { pending = undefined; });
|
|
30
|
+
return pending;
|
|
31
|
+
};
|
|
32
|
+
await reconcile();
|
|
33
|
+
const timer = setInterval(() => void reconcile().catch(onError), PRESENCE_INTERVAL_MS);
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
return async () => {
|
|
36
|
+
stopped = true;
|
|
37
|
+
clearInterval(timer);
|
|
38
|
+
await pending?.catch(onError);
|
|
39
|
+
await reconcile();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=presence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presence.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/presence.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAChD,OAAO,IAAI,MAAM,WAAW,CAAA;AAE5B,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,CAAA;AACzC,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAA;AAExC,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,SAAiB,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IACnE,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAA;IACtD,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,EAAC,IAAI,EAAC,EAAE;QACtG,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC3E,OAAO,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAG,GAAG,KAAK,CAAC,OAAO,GAAG,kBAAkB,CAAC,CAAA;IAC7E,CAAC,CAAC,CAAC,CAAA;IACH,OAAO,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;AAC7B,CAAC;AAED,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,SAAiB,EACjB,QAAmD,EACnD,UAAoC,OAAO,CAAC,KAAK;IAEjD,IAAI,OAAiD,CAAA;IACrD,IAAI,OAAO,GAAG,KAAK,CAAA;IACnB,IAAI,OAAkC,CAAA;IACtC,MAAM,SAAS,GAAG,GAAG,EAAE;QACrB,IAAI,OAAO;YAAE,OAAO,OAAO,CAAA;QAC3B,OAAO,GAAG,CAAC,KAAK,IAAI,EAAE;YACpB,MAAM,MAAM,GAAG,CAAC,OAAO,IAAI,MAAM,WAAW,CAAC,SAAS,CAAC,CAAA;YACvD,IAAI,MAAM,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO;gBAAE,OAAO,GAAG,MAAM,QAAQ,EAAE,CAAA;YAC9D,IAAI,CAAC,CAAC,MAAM,IAAI,OAAO,CAAC,IAAI,OAAO,EAAE,CAAC;gBACpC,MAAM,OAAO,EAAE,CAAA;gBACf,OAAO,GAAG,SAAS,CAAA;YACrB,CAAC;QACH,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,OAAO,GAAG,SAAS,CAAA,CAAC,CAAC,CAAC,CAAA;QAC3C,OAAO,OAAO,CAAA;IAChB,CAAC,CAAA;IACD,MAAM,SAAS,EAAE,CAAA;IACjB,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,KAAK,SAAS,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,oBAAoB,CAAC,CAAA;IACtF,KAAK,CAAC,KAAK,EAAE,EAAE,CAAA;IACf,OAAO,KAAK,IAAI,EAAE;QAChB,OAAO,GAAG,IAAI,CAAA;QACd,aAAa,CAAC,KAAK,CAAC,CAAA;QACpB,MAAM,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;QAC7B,MAAM,SAAS,EAAE,CAAA;IACnB,CAAC,CAAA;AACH,CAAC","sourcesContent":["import { readdir, stat } from \"node:fs/promises\"\nimport path from \"node:path\"\n\nexport const PRESENCE_INTERVAL_MS = 2_000\nexport const PRESENCE_EXPIRY_MS = 15_000\n\nexport async function hasPresence(directory: string, now = Date.now()): Promise<boolean> {\n const names = await readdir(directory).catch(() => [])\n const active = await Promise.all(names.filter(name => /^[\\da-f-]+\\.lease$/.test(name)).map(async name => {\n const entry = await stat(path.join(directory, name)).catch(() => undefined)\n return Boolean(entry?.isFile() && now - entry.mtimeMs < PRESENCE_EXPIRY_MS)\n }))\n return active.some(Boolean)\n}\n\n// Serial reconciliation prevents overlapping setup/dispose and resurrection on unload.\nexport async function followPresence(\n directory: string,\n register: () => Promise<() => void | Promise<void>>,\n onError: (error: unknown) => void = console.error,\n) {\n let dispose: (() => void | Promise<void>) | undefined\n let stopped = false\n let pending: Promise<void> | undefined\n const reconcile = () => {\n if (pending) return pending\n pending = (async () => {\n const active = !stopped && await hasPresence(directory)\n if (active && !dispose && !stopped) dispose = await register()\n if ((!active || stopped) && dispose) {\n await dispose()\n dispose = undefined\n }\n })().finally(() => { pending = undefined })\n return pending\n }\n await reconcile()\n const timer = setInterval(() => void reconcile().catch(onError), PRESENCE_INTERVAL_MS)\n timer.unref?.()\n return async () => {\n stopped = true\n clearInterval(timer)\n await pending?.catch(onError)\n await reconcile()\n }\n}\n"]}
|
|
@@ -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
|