@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,66 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { installPruningPresence } from "./pruning-installation";
|
|
7
|
+
const execute = promisify(execFile);
|
|
8
|
+
function hostPaths(env) {
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
return {
|
|
11
|
+
config: env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "opencode"),
|
|
12
|
+
data: path.join(env.XDG_DATA_HOME || path.join(home, ".local", "share"), "codenomad"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function wslPaths(distro, env) {
|
|
16
|
+
const keys = ["OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"];
|
|
17
|
+
const { stdout } = await execute("wsl.exe", ["--distribution", distro, "--exec", "env",
|
|
18
|
+
...keys.filter(key => env[key] !== undefined).map(key => `${key}=${env[key]}`),
|
|
19
|
+
"sh", "-c", 'printf "%s\\n%s\\n" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" "${XDG_DATA_HOME:-$HOME/.local/share}/codenomad"',
|
|
20
|
+
], { windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024 });
|
|
21
|
+
const [config, data] = stdout.trim().split(/\r?\n/);
|
|
22
|
+
if (!config?.startsWith("/") || !data?.startsWith("/"))
|
|
23
|
+
throw new Error("Could not resolve WSL plugin directories");
|
|
24
|
+
const unc = (directory) => `\\\\wsl.localhost\\${distro}${directory.replaceAll("/", "\\")}`;
|
|
25
|
+
return { config: unc(config), data: unc(data), nativeData: data };
|
|
26
|
+
}
|
|
27
|
+
// One backend owns one lease per daemon namespace, shared by all its windows.
|
|
28
|
+
// Other profiles/backends own independent leases; none owns or stops OpenCode.
|
|
29
|
+
export class PruningLifecycle {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.installations = new Map();
|
|
32
|
+
this.stopped = false;
|
|
33
|
+
}
|
|
34
|
+
async start(launch, environment = {}) {
|
|
35
|
+
if (this.stopped)
|
|
36
|
+
return;
|
|
37
|
+
const paths = launch?.kind === "wsl" ? await wslPaths(launch.distro, environment) : hostPaths({ ...process.env, ...environment });
|
|
38
|
+
if (this.stopped)
|
|
39
|
+
return;
|
|
40
|
+
const key = paths.config;
|
|
41
|
+
if (!this.installations.has(key)) {
|
|
42
|
+
const installation = (async () => {
|
|
43
|
+
const bundle = await readFile(new URL("../plugins/session-pruning/plugin.mjs", import.meta.url))
|
|
44
|
+
.catch((error) => {
|
|
45
|
+
if (error.code !== "ENOENT")
|
|
46
|
+
throw error;
|
|
47
|
+
return readFile(new URL("../../dist/plugins/session-pruning/plugin.mjs", import.meta.url));
|
|
48
|
+
});
|
|
49
|
+
return installPruningPresence(bundle, paths);
|
|
50
|
+
})();
|
|
51
|
+
this.installations.set(key, installation);
|
|
52
|
+
void installation.catch(() => { if (this.installations.get(key) === installation)
|
|
53
|
+
this.installations.delete(key); });
|
|
54
|
+
}
|
|
55
|
+
await this.installations.get(key);
|
|
56
|
+
}
|
|
57
|
+
async stop() {
|
|
58
|
+
this.stopped = true;
|
|
59
|
+
await Promise.all([...this.installations.values()].map(async (installation) => {
|
|
60
|
+
const dispose = await installation.catch(() => undefined);
|
|
61
|
+
await dispose?.();
|
|
62
|
+
}));
|
|
63
|
+
this.installations.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=pruning-lifecycle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pruning-lifecycle.js","sourceRoot":"","sources":["../../src/opencode/pruning-lifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["import { execFile } from \"node:child_process\"\nimport { readFile } from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { promisify } from \"node:util\"\nimport type { ServiceLaunchSpec } from \"../workspaces/spawn\"\nimport { installPruningPresence, type PruningPaths } from \"./pruning-installation\"\n\nconst execute = promisify(execFile)\n\nfunction hostPaths(env: NodeJS.ProcessEnv): PruningPaths {\n const home = os.homedir()\n return {\n config: env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, \".config\"), \"opencode\"),\n data: path.join(env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"codenomad\"),\n }\n}\n\nasync function wslPaths(distro: string, env: NodeJS.ProcessEnv): Promise<PruningPaths> {\n const keys = [\"OPENCODE_CONFIG_DIR\", \"XDG_CONFIG_HOME\", \"XDG_DATA_HOME\"]\n const { stdout } = await execute(\"wsl.exe\", [\"--distribution\", distro, \"--exec\", \"env\",\n ...keys.filter(key => env[key] !== undefined).map(key => `${key}=${env[key]}`),\n \"sh\", \"-c\", 'printf \"%s\\\\n%s\\\\n\" \"${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}\" \"${XDG_DATA_HOME:-$HOME/.local/share}/codenomad\"',\n ], { windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024 })\n const [config, data] = stdout.trim().split(/\\r?\\n/)\n if (!config?.startsWith(\"/\") || !data?.startsWith(\"/\")) throw new Error(\"Could not resolve WSL plugin directories\")\n const unc = (directory: string) => `\\\\\\\\wsl.localhost\\\\${distro}${directory.replaceAll(\"/\", \"\\\\\")}`\n return { config: unc(config), data: unc(data), nativeData: data }\n}\n\n// One backend owns one lease per daemon namespace, shared by all its windows.\n// Other profiles/backends own independent leases; none owns or stops OpenCode.\nexport class PruningLifecycle {\n private readonly installations = new Map<string, Promise<() => Promise<void>>>()\n private stopped = false\n\n async start(launch?: ServiceLaunchSpec, environment: NodeJS.ProcessEnv = {}): Promise<void> {\n if (this.stopped) return\n const paths = launch?.kind === \"wsl\" ? await wslPaths(launch.distro, environment) : hostPaths({ ...process.env, ...environment })\n if (this.stopped) return\n const key = paths.config\n if (!this.installations.has(key)) {\n const installation = (async () => {\n const bundle = await readFile(new URL(\"../plugins/session-pruning/plugin.mjs\", import.meta.url))\n .catch((error: NodeJS.ErrnoException) => {\n if (error.code !== \"ENOENT\") throw error\n return readFile(new URL(\"../../dist/plugins/session-pruning/plugin.mjs\", import.meta.url))\n })\n return installPruningPresence(bundle, paths)\n })()\n this.installations.set(key, installation)\n void installation.catch(() => { if (this.installations.get(key) === installation) this.installations.delete(key) })\n }\n await this.installations.get(key)\n }\n\n async stop(): Promise<void> {\n this.stopped = true\n await Promise.all([...this.installations.values()].map(async installation => {\n const dispose = await installation.catch(() => undefined)\n await dispose?.()\n }))\n this.installations.clear()\n }\n}\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { storageDirectory } from "./storage-path";
|
|
2
|
+
// This is an internal storage contract, NOT a public OpenCode lock API.
|
|
3
|
+
// Validate the actual storage/claim state; runtime version labels are not gates.
|
|
4
|
+
export const PRUNING_PLUGIN_ID = "codenomad-session-pruning";
|
|
5
|
+
export const storageKey = (key) => `plugin:${Array.from(PRUNING_PLUGIN_ID)
|
|
6
|
+
.map(char => char.charCodeAt(0).toString(16).padStart(4, "0")).join("")}:${key}`;
|
|
7
|
+
export function validateClaimFence(db, sessionID, identity) {
|
|
8
|
+
if (!db.isTransaction)
|
|
9
|
+
throw new Error("Claim fence requires an acquired write transaction");
|
|
10
|
+
if (db.prepare("PRAGMA database_list").all().length !== 1)
|
|
11
|
+
return "unsupported_storage";
|
|
12
|
+
if (db.prepare("SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1").get())
|
|
13
|
+
return "unsupported_storage";
|
|
14
|
+
const marker = db.prepare("SELECT value FROM kv WHERE key=?").get(identity.key);
|
|
15
|
+
if (marker?.value !== JSON.stringify(identity.nonce))
|
|
16
|
+
return "unsupported_storage";
|
|
17
|
+
const session = db.prepare("SELECT directory,project_id,workspace_id,time_suspended,time_compacting,revert FROM session_v2 WHERE id=?").get(sessionID);
|
|
18
|
+
if (!session || session.directory !== storageDirectory(identity.directory)
|
|
19
|
+
|| session.project_id !== identity.projectID || session.workspace_id !== (identity.workspaceID ?? null))
|
|
20
|
+
return "not_deletable";
|
|
21
|
+
// Native execution must COMMIT its claim before it can enter the runner.
|
|
22
|
+
// BEGIN IMMEDIATE excludes that commit until our synchronous transaction ends.
|
|
23
|
+
// If it committed first, we observe its non-null claim and refuse the write.
|
|
24
|
+
// Never set/release the claim ourselves: it also drives crash recovery.
|
|
25
|
+
if (session.time_suspended !== null || session.time_compacting !== null || session.revert !== null)
|
|
26
|
+
return "maintenance_required";
|
|
27
|
+
const sequence = db.prepare("SELECT owner_id FROM event_sequence WHERE aggregate_id=?").get(sessionID);
|
|
28
|
+
if (!sequence || sequence.owner_id !== null
|
|
29
|
+
|| db.prepare("SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1").get(sessionID))
|
|
30
|
+
return "unsupported_storage";
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=claim-fence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim-fence.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/claim-fence.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,wEAAwE;AACxE,iFAAiF;AACjF,MAAM,CAAC,MAAM,iBAAiB,GAAG,2BAA2B,CAAA;AAC5D,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,UAAU,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC;KAC/E,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,GAAG,EAAE,CAAA;AAYlF,MAAM,UAAU,kBAAkB,CAAC,EAAgB,EAAE,SAAiB,EAAE,QAAyB;IAC/F,IAAI,CAAC,EAAE,CAAC,aAAa;QAAE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAA;IAC5F,IAAI,EAAE,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,GAAG,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,qBAA8B,CAAA;IAChG,IAAI,EAAE,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,GAAG,EAAE;QAAE,OAAO,qBAA8B,CAAA;IACvH,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;IAC/E,IAAI,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,qBAA8B,CAAA;IAC3F,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,2GAA2G,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACtJ,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,SAAS,KAAK,gBAAgB,CAAC,QAAQ,CAAC,SAAS,CAAC;WACrE,OAAO,CAAC,UAAU,KAAK,QAAQ,CAAC,SAAS,IAAI,OAAO,CAAC,YAAY,KAAK,CAAC,QAAQ,CAAC,WAAW,IAAI,IAAI,CAAC;QAAE,OAAO,eAAwB,CAAA;IAC1I,yEAAyE;IACzE,+EAA+E;IAC/E,6EAA6E;IAC7E,wEAAwE;IACxE,IAAI,OAAO,CAAC,cAAc,KAAK,IAAI,IAAI,OAAO,CAAC,eAAe,KAAK,IAAI,IAAI,OAAO,CAAC,MAAM,KAAK,IAAI;QAAE,OAAO,sBAA+B,CAAA;IAC1I,MAAM,QAAQ,GAAG,EAAE,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACtG,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,KAAK,IAAI;WACtC,EAAE,CAAC,OAAO,CAAC,kDAAkD,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC;QAAE,OAAO,qBAA8B,CAAA;IACzH,OAAO,SAAS,CAAA;AAClB,CAAC","sourcesContent":["import type { DatabaseSync } from \"node:sqlite\"\nimport { storageDirectory } from \"./storage-path\"\n\n// This is an internal storage contract, NOT a public OpenCode lock API.\n// Validate the actual storage/claim state; runtime version labels are not gates.\nexport const PRUNING_PLUGIN_ID = \"codenomad-session-pruning\"\nexport const storageKey = (key: string) => `plugin:${Array.from(PRUNING_PLUGIN_ID)\n .map(char => char.charCodeAt(0).toString(16).padStart(4, \"0\")).join(\"\")}:${key}`\n\nexport interface StorageIdentity {\n // A fresh ctx.storage challenge proves this connection reaches the daemon's\n // current DB, not a same-session snapshot or an accidentally configured file.\n key: string\n nonce: string\n directory: string\n projectID: string\n workspaceID?: string\n}\n\nexport function validateClaimFence(db: DatabaseSync, sessionID: string, identity: StorageIdentity) {\n if (!db.isTransaction) throw new Error(\"Claim fence requires an acquired write transaction\")\n if (db.prepare(\"PRAGMA database_list\").all().length !== 1) return \"unsupported_storage\" as const\n if (db.prepare(\"SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1\").get()) return \"unsupported_storage\" as const\n const marker = db.prepare(\"SELECT value FROM kv WHERE key=?\").get(identity.key)\n if (marker?.value !== JSON.stringify(identity.nonce)) return \"unsupported_storage\" as const\n const session = db.prepare(\"SELECT directory,project_id,workspace_id,time_suspended,time_compacting,revert FROM session_v2 WHERE id=?\").get(sessionID)\n if (!session || session.directory !== storageDirectory(identity.directory)\n || session.project_id !== identity.projectID || session.workspace_id !== (identity.workspaceID ?? null)) return \"not_deletable\" as const\n // Native execution must COMMIT its claim before it can enter the runner.\n // BEGIN IMMEDIATE excludes that commit until our synchronous transaction ends.\n // If it committed first, we observe its non-null claim and refuse the write.\n // Never set/release the claim ourselves: it also drives crash recovery.\n if (session.time_suspended !== null || session.time_compacting !== null || session.revert !== null) return \"maintenance_required\" as const\n const sequence = db.prepare(\"SELECT owner_id FROM event_sequence WHERE aggregate_id=?\").get(sessionID)\n if (!sequence || sequence.owner_id !== null\n || db.prepare(\"SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1\").get(sessionID)) return \"unsupported_storage\" as const\n return undefined\n}\n"]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { validateClaimFence } from "./claim-fence";
|
|
5
|
+
import { pruneTransaction } from "./transaction";
|
|
6
|
+
import { revision } from "./planner";
|
|
7
|
+
import { storageDirectory } from "./storage-path";
|
|
8
|
+
const identity = { directory: "/work", projectID: "p", key: "binding", nonce: "fresh" };
|
|
9
|
+
const content = [{ type: "reasoning", text: "remove" }, { type: "text", text: "retain" }];
|
|
10
|
+
const input = { sessionID: "s", messageID: "m", revision: revision(content), indexes: [0] };
|
|
11
|
+
function fixture() {
|
|
12
|
+
const db = new DatabaseSync(":memory:");
|
|
13
|
+
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);
|
|
14
|
+
INSERT INTO session_v2 VALUES('s','/work','p',NULL,NULL,NULL,NULL);
|
|
15
|
+
CREATE TABLE event_sequence(aggregate_id TEXT PRIMARY KEY,owner_id TEXT);
|
|
16
|
+
INSERT INTO event_sequence VALUES('s',NULL);
|
|
17
|
+
CREATE TABLE event(aggregate_id TEXT);
|
|
18
|
+
CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER);
|
|
19
|
+
INSERT INTO kv VALUES('binding','"fresh"',1,1);
|
|
20
|
+
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`);
|
|
21
|
+
db.prepare("INSERT INTO session_message VALUES('m','s','assistant',?)").run(JSON.stringify({ time: { completed: 1 }, content }));
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
const run = (db, binding = identity) => pruneTransaction(db, input, () => validateClaimFence(db, input.sessionID, binding), "receipt/");
|
|
25
|
+
test("fence requires ownership of the SQLite transaction", () => {
|
|
26
|
+
const db = fixture();
|
|
27
|
+
try {
|
|
28
|
+
assert.throws(() => validateClaimFence(db, "s", identity), /write transaction/);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
db.close();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
for (const [label, sql] of [
|
|
35
|
+
["active execution", "UPDATE session_v2 SET time_suspended=1"],
|
|
36
|
+
["compaction", "UPDATE session_v2 SET time_compacting=1"],
|
|
37
|
+
["staged revert", "UPDATE session_v2 SET revert='{}'"],
|
|
38
|
+
["moved session", "UPDATE session_v2 SET directory='/other'"],
|
|
39
|
+
["changed project", "UPDATE session_v2 SET project_id='other'"],
|
|
40
|
+
["changed workspace", "UPDATE session_v2 SET workspace_id='other'"],
|
|
41
|
+
["wrong database challenge", "UPDATE kv SET value='\"snapshot\"'"],
|
|
42
|
+
["retained event", "INSERT INTO event VALUES('s')"],
|
|
43
|
+
["remote aggregate owner", "UPDATE event_sequence SET owner_id='remote'"],
|
|
44
|
+
["unknown trigger", "CREATE TRIGGER writes AFTER UPDATE ON session_message BEGIN SELECT 1; END"],
|
|
45
|
+
])
|
|
46
|
+
test(`rejects ${label} without a content write or receipt`, () => {
|
|
47
|
+
const db = fixture();
|
|
48
|
+
try {
|
|
49
|
+
db.exec(sql);
|
|
50
|
+
const before = db.prepare("SELECT data FROM session_message").get().data;
|
|
51
|
+
assert.equal(run(db).status, "blocked");
|
|
52
|
+
assert.equal(db.prepare("SELECT data FROM session_message").get().data, before);
|
|
53
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 0);
|
|
54
|
+
assert.equal(db.isTransaction, false);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
test("missing native claim storage rolls back without a content write or receipt", () => {
|
|
61
|
+
const db = fixture();
|
|
62
|
+
try {
|
|
63
|
+
db.exec("ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim");
|
|
64
|
+
assert.equal(run(db).status, "blocked");
|
|
65
|
+
assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, content);
|
|
66
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 0);
|
|
67
|
+
assert.equal(db.isTransaction, false);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
db.close();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
test("commit and receipt are atomic, and repeated delivery never deletes a second block", () => {
|
|
74
|
+
const db = fixture();
|
|
75
|
+
try {
|
|
76
|
+
const result = run(db);
|
|
77
|
+
assert.equal(result.status, "pruned");
|
|
78
|
+
assert.deepEqual(run(db), result);
|
|
79
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 1);
|
|
80
|
+
assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, [content[1]]);
|
|
81
|
+
assert.equal(db.isTransaction, false);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test("a failed receipt insert rolls back the content change", () => {
|
|
88
|
+
const db = fixture();
|
|
89
|
+
try {
|
|
90
|
+
// Inject a storage failure without a trigger, which the fence rightly rejects.
|
|
91
|
+
db.exec("DROP TABLE kv; CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER CHECK(time_created < 2),time_updated INTEGER); INSERT INTO kv VALUES('binding','\"fresh\"',1,1)");
|
|
92
|
+
assert.throws(() => run(db));
|
|
93
|
+
assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, content);
|
|
94
|
+
assert.equal(db.isTransaction, false);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
db.close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
test("Windows directory encoding agrees with native SQLite storage", () => {
|
|
101
|
+
const directory = "C:\\work\\project";
|
|
102
|
+
assert.equal(storageDirectory(directory), process.platform === "win32" ? "C:/work/project" : directory);
|
|
103
|
+
});
|
|
104
|
+
//# sourceMappingURL=claim-fence.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim-fence.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/claim-fence.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 { validateClaimFence, type StorageIdentity } from \"./claim-fence\"\nimport { pruneTransaction } from \"./transaction\"\nimport { revision } from \"./planner\"\nimport { storageDirectory } from \"./storage-path\"\n\nconst identity: StorageIdentity = { directory: \"/work\", projectID: \"p\", key: \"binding\", nonce: \"fresh\" }\nconst content = [{ type: \"reasoning\", text: \"remove\" }, { type: \"text\", text: \"retain\" }]\nconst input = { sessionID: \"s\", messageID: \"m\", revision: revision(content), indexes: [0] }\nfunction fixture() {\n const db = new DatabaseSync(\":memory:\")\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 INSERT INTO session_v2 VALUES('s','/work','p',NULL,NULL,NULL,NULL);\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 INSERT INTO kv VALUES('binding','\"fresh\"',1,1);\n CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`)\n db.prepare(\"INSERT INTO session_message VALUES('m','s','assistant',?)\").run(JSON.stringify({ time: { completed: 1 }, content }))\n return db\n}\nconst run = (db: DatabaseSync, binding = identity) => pruneTransaction(db, input, () => validateClaimFence(db, input.sessionID, binding), \"receipt/\")\n\ntest(\"fence requires ownership of the SQLite transaction\", () => {\n const db = fixture()\n try { assert.throws(() => validateClaimFence(db, \"s\", identity), /write transaction/) }\n finally { db.close() }\n})\n\nfor (const [label, sql] of [\n [\"active execution\", \"UPDATE session_v2 SET time_suspended=1\"],\n [\"compaction\", \"UPDATE session_v2 SET time_compacting=1\"],\n [\"staged revert\", \"UPDATE session_v2 SET revert='{}'\"],\n [\"moved session\", \"UPDATE session_v2 SET directory='/other'\"],\n [\"changed project\", \"UPDATE session_v2 SET project_id='other'\"],\n [\"changed workspace\", \"UPDATE session_v2 SET workspace_id='other'\"],\n [\"wrong database challenge\", \"UPDATE kv SET value='\\\"snapshot\\\"'\"],\n [\"retained event\", \"INSERT INTO event VALUES('s')\"],\n [\"remote aggregate owner\", \"UPDATE event_sequence SET owner_id='remote'\"],\n [\"unknown trigger\", \"CREATE TRIGGER writes AFTER UPDATE ON session_message BEGIN SELECT 1; END\"],\n] as const) test(`rejects ${label} without a content write or receipt`, () => {\n const db = fixture()\n try {\n db.exec(sql)\n const before = db.prepare(\"SELECT data FROM session_message\").get()!.data\n assert.equal(run(db).status, \"blocked\")\n assert.equal(db.prepare(\"SELECT data FROM session_message\").get()!.data, before)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 0)\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"missing native claim storage rolls back without a content write or receipt\", () => {\n const db = fixture()\n try {\n db.exec(\"ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim\")\n assert.equal(run(db).status, \"blocked\")\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, content)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 0)\n assert.equal(db.isTransaction, false)\n }\n finally { db.close() }\n})\n\ntest(\"commit and receipt are atomic, and repeated delivery never deletes a second block\", () => {\n const db = fixture()\n try {\n const result = run(db)\n assert.equal(result.status, \"pruned\")\n assert.deepEqual(run(db), result)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 1)\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, [content[1]])\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"a failed receipt insert rolls back the content change\", () => {\n const db = fixture()\n try {\n // Inject a storage failure without a trigger, which the fence rightly rejects.\n db.exec(\"DROP TABLE kv; CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER CHECK(time_created < 2),time_updated INTEGER); INSERT INTO kv VALUES('binding','\\\"fresh\\\"',1,1)\")\n assert.throws(() => run(db))\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, content)\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"Windows directory encoding agrees with native SQLite storage\", () => {\n const directory = \"C:\\\\work\\\\project\"\n assert.equal(storageDirectory(directory), process.platform === \"win32\" ? \"C:/work/project\" : directory)\n})\n"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const PRUNING_RPC_ID = "codenomad.session-pruning";
|
|
3
|
+
export const PRUNING_EVENT = `rpc.${PRUNING_RPC_ID}.pruned`;
|
|
4
|
+
const id = z.string().min(1).max(256);
|
|
5
|
+
export const messageTargetSchema = z.object({ sessionID: id, messageID: id }).strict();
|
|
6
|
+
export const pruneRequestSchema = messageTargetSchema.extend({
|
|
7
|
+
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
8
|
+
indexes: z.array(z.number().int().min(0).max(100_000)).min(1).max(100_001)
|
|
9
|
+
.refine((values) => new Set(values).size === values.length),
|
|
10
|
+
}).strict();
|
|
11
|
+
export const pruningBlockedSchema = z.object({
|
|
12
|
+
status: z.literal("blocked"),
|
|
13
|
+
reason: z.enum(["unavailable", "maintenance_required", "conflict", "not_deletable", "unsupported_storage"]),
|
|
14
|
+
}).strict();
|
|
15
|
+
export const pruneResultSchema = z.union([
|
|
16
|
+
z.object({
|
|
17
|
+
status: z.literal("pruned"), messageID: id,
|
|
18
|
+
revision: z.string().regex(/^[a-f0-9]{64}$/), removedCount: z.number().int().positive(),
|
|
19
|
+
}).strict(),
|
|
20
|
+
pruningBlockedSchema,
|
|
21
|
+
]);
|
|
22
|
+
export const prunePreviewSchema = z.union([
|
|
23
|
+
z.object({
|
|
24
|
+
status: z.literal("preview"), revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
25
|
+
liveMutation: z.boolean(),
|
|
26
|
+
parts: z.array(z.object({ index: z.number().int().nonnegative(), type: z.enum(["tool", "reasoning"]), bytes: z.number().int().nonnegative() })),
|
|
27
|
+
}).strict(),
|
|
28
|
+
pruningBlockedSchema,
|
|
29
|
+
]);
|
|
30
|
+
export const prunedEventSchema = messageTargetSchema.extend({ revision: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
31
|
+
// Portable Standard Schema contract: no Core imports or native event impersonation.
|
|
32
|
+
export const pruningRpcDefinition = {
|
|
33
|
+
id: PRUNING_RPC_ID,
|
|
34
|
+
methods: {
|
|
35
|
+
preview: { input: messageTargetSchema, output: prunePreviewSchema },
|
|
36
|
+
prune: { input: pruneRequestSchema, output: pruneResultSchema },
|
|
37
|
+
},
|
|
38
|
+
events: { pruned: { schema: prunedEventSchema } },
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/contract.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\"\n\nexport const PRUNING_RPC_ID = \"codenomad.session-pruning\"\nexport const PRUNING_EVENT = `rpc.${PRUNING_RPC_ID}.pruned` as const\nconst id = z.string().min(1).max(256)\nexport const messageTargetSchema = z.object({ sessionID: id, messageID: id }).strict()\nexport const pruneRequestSchema = messageTargetSchema.extend({\n revision: z.string().regex(/^[a-f0-9]{64}$/),\n indexes: z.array(z.number().int().min(0).max(100_000)).min(1).max(100_001)\n .refine((values) => new Set(values).size === values.length),\n}).strict()\nexport type PruneRequest = z.infer<typeof pruneRequestSchema>\nexport const pruningBlockedSchema = z.object({\n status: z.literal(\"blocked\"),\n reason: z.enum([\"unavailable\", \"maintenance_required\", \"conflict\", \"not_deletable\", \"unsupported_storage\"]),\n}).strict()\nexport const pruneResultSchema = z.union([\n z.object({\n status: z.literal(\"pruned\"), messageID: id,\n revision: z.string().regex(/^[a-f0-9]{64}$/), removedCount: z.number().int().positive(),\n }).strict(),\n pruningBlockedSchema,\n])\nexport type PruneResult = z.infer<typeof pruneResultSchema>\nexport const prunePreviewSchema = z.union([\n z.object({\n status: z.literal(\"preview\"), revision: z.string().regex(/^[a-f0-9]{64}$/),\n liveMutation: z.boolean(),\n parts: z.array(z.object({ index: z.number().int().nonnegative(), type: z.enum([\"tool\", \"reasoning\"]), bytes: z.number().int().nonnegative() })),\n }).strict(),\n pruningBlockedSchema,\n])\nexport const prunedEventSchema = messageTargetSchema.extend({ revision: z.string().regex(/^[a-f0-9]{64}$/) }).strict()\n\n// Portable Standard Schema contract: no Core imports or native event impersonation.\nexport const pruningRpcDefinition = {\n id: PRUNING_RPC_ID,\n methods: {\n preview: { input: messageTargetSchema, output: prunePreviewSchema },\n prune: { input: pruneRequestSchema, output: pruneResultSchema },\n },\n events: { pruned: { schema: prunedEventSchema } },\n} as const\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Match OpenCode's daemon-side data-directory and channel filename conventions.
|
|
4
|
+
// The fresh storage challenge still verifies the chosen file before any write.
|
|
5
|
+
export function pruningDatabasePath(configured, channel, env = process.env, home = os.homedir()) {
|
|
6
|
+
if (configured !== undefined)
|
|
7
|
+
return configured;
|
|
8
|
+
const data = path.join(env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)
|
|
9
|
+
? env.XDG_DATA_HOME : path.join(home, ".local", "share"), "opencode");
|
|
10
|
+
if (env.OPENCODE_DB)
|
|
11
|
+
return env.OPENCODE_DB === ":memory:" || path.isAbsolute(env.OPENCODE_DB)
|
|
12
|
+
? env.OPENCODE_DB : path.join(data, env.OPENCODE_DB);
|
|
13
|
+
const shared = ["latest", "beta", "prod"].includes(channel)
|
|
14
|
+
|| env.OPENCODE_DISABLE_CHANNEL_DB === "1" || env.OPENCODE_DISABLE_CHANNEL_DB === "true";
|
|
15
|
+
return path.join(data, shared ? "opencode.db" : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`);
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=database-path.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-path.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/database-path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAE5B,gFAAgF;AAChF,+EAA+E;AAC/E,MAAM,UAAU,mBAAmB,CACjC,UAAmB,EACnB,OAAe,EACf,MAAyB,OAAO,CAAC,GAAG,EACpC,OAAe,EAAE,CAAC,OAAO,EAAE;IAE3B,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,UAAU,CAAA;IAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,aAAa,CAAC;QAC5E,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAA;IACvE,IAAI,GAAG,CAAC,WAAW;QAAE,OAAO,GAAG,CAAC,WAAW,KAAK,UAAU,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC;YAC5F,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,WAAW,CAAC,CAAA;IACtD,MAAM,MAAM,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;WACtD,GAAG,CAAC,2BAA2B,KAAK,GAAG,IAAI,GAAG,CAAC,2BAA2B,KAAK,MAAM,CAAA;IAC1F,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,YAAY,OAAO,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAA;AAC5G,CAAC","sourcesContent":["import os from \"node:os\"\nimport path from \"node:path\"\n\n// Match OpenCode's daemon-side data-directory and channel filename conventions.\n// The fresh storage challenge still verifies the chosen file before any write.\nexport function pruningDatabasePath(\n configured: unknown,\n channel: string,\n env: NodeJS.ProcessEnv = process.env,\n home: string = os.homedir(),\n): unknown {\n if (configured !== undefined) return configured\n const data = path.join(env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)\n ? env.XDG_DATA_HOME : path.join(home, \".local\", \"share\"), \"opencode\")\n if (env.OPENCODE_DB) return env.OPENCODE_DB === \":memory:\" || path.isAbsolute(env.OPENCODE_DB)\n ? env.OPENCODE_DB : path.join(data, env.OPENCODE_DB)\n const shared = [\"latest\", \"beta\", \"prod\"].includes(channel)\n || env.OPENCODE_DISABLE_CHANNEL_DB === \"1\" || env.OPENCODE_DISABLE_CHANNEL_DB === \"true\"\n return path.join(data, shared ? \"opencode.db\" : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, \"-\")}.db`)\n}\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pruningDatabasePath } from "./database-path";
|
|
5
|
+
test("local plugin resolves the daemon's normal database without per-project configuration", () => {
|
|
6
|
+
const home = path.resolve("synthetic-home");
|
|
7
|
+
const data = path.join(home, ".local", "share", "opencode");
|
|
8
|
+
for (const channel of ["beta", "latest", "prod"]) {
|
|
9
|
+
assert.equal(pruningDatabasePath(undefined, channel, {}, home), path.join(data, "opencode.db"));
|
|
10
|
+
}
|
|
11
|
+
assert.equal(pruningDatabasePath(undefined, "dev/feature", {}, home), path.join(data, "opencode-dev-feature.db"));
|
|
12
|
+
assert.equal(pruningDatabasePath(undefined, "dev", { OPENCODE_DISABLE_CHANNEL_DB: "true" }, home), path.join(data, "opencode.db"));
|
|
13
|
+
const xdg = path.join(home, "xdg");
|
|
14
|
+
assert.equal(pruningDatabasePath(undefined, "beta", { XDG_DATA_HOME: xdg }, home), path.join(xdg, "opencode", "opencode.db"));
|
|
15
|
+
assert.equal(pruningDatabasePath(undefined, "beta", { XDG_DATA_HOME: "relative" }, home), path.join(data, "opencode.db"));
|
|
16
|
+
assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: "custom.db" }, home), path.join(data, "custom.db"));
|
|
17
|
+
const explicit = path.join(home, "explicit.db");
|
|
18
|
+
assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: explicit }, home), explicit);
|
|
19
|
+
assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: ":memory:" }, home), ":memory:");
|
|
20
|
+
assert.equal(pruningDatabasePath(explicit, "beta", { OPENCODE_DB: "ignored.db" }, home), explicit);
|
|
21
|
+
});
|
|
22
|
+
//# sourceMappingURL=database-path.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-path.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/database-path.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD,IAAI,CAAC,sFAAsF,EAAE,GAAG,EAAE;IAChG,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;IAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,CAAC,CAAA;IAC3D,KAAK,MAAM,OAAO,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC;QACjD,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IACjG,CAAC;IACD,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,aAAa,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,yBAAyB,CAAC,CAAC,CAAA;IACjH,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,2BAA2B,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IAClI,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IAClC,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC,CAAA;IAC7H,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,UAAU,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IACzH,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAA;IACtH,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAC/C,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC,CAAA;IAC/F,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,IAAI,CAAC,EAAE,UAAU,CAAC,CAAA;IACnG,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,YAAY,EAAE,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC,CAAA;AACpG,CAAC,CAAC,CAAA","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport path from \"node:path\"\nimport { pruningDatabasePath } from \"./database-path\"\n\ntest(\"local plugin resolves the daemon's normal database without per-project configuration\", () => {\n const home = path.resolve(\"synthetic-home\")\n const data = path.join(home, \".local\", \"share\", \"opencode\")\n for (const channel of [\"beta\", \"latest\", \"prod\"]) {\n assert.equal(pruningDatabasePath(undefined, channel, {}, home), path.join(data, \"opencode.db\"))\n }\n assert.equal(pruningDatabasePath(undefined, \"dev/feature\", {}, home), path.join(data, \"opencode-dev-feature.db\"))\n assert.equal(pruningDatabasePath(undefined, \"dev\", { OPENCODE_DISABLE_CHANNEL_DB: \"true\" }, home), path.join(data, \"opencode.db\"))\n const xdg = path.join(home, \"xdg\")\n assert.equal(pruningDatabasePath(undefined, \"beta\", { XDG_DATA_HOME: xdg }, home), path.join(xdg, \"opencode\", \"opencode.db\"))\n assert.equal(pruningDatabasePath(undefined, \"beta\", { XDG_DATA_HOME: \"relative\" }, home), path.join(data, \"opencode.db\"))\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: \"custom.db\" }, home), path.join(data, \"custom.db\"))\n const explicit = path.join(home, \"explicit.db\")\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: explicit }, home), explicit)\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: \":memory:\" }, home), \":memory:\")\n assert.equal(pruningDatabasePath(explicit, \"beta\", { OPENCODE_DB: \"ignored.db\" }, home), explicit)\n})\n"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import plugin from "./plugin";
|
|
2
|
+
import { followPresence } from "./presence";
|
|
3
|
+
// The bundled factory is wrapped by a tiny auto-discovered native entry.
|
|
4
|
+
// No tools, commands or model hooks are registered, even while CodeNomad is open.
|
|
5
|
+
export function desktopPlugin(presenceDirectory) {
|
|
6
|
+
return {
|
|
7
|
+
...plugin,
|
|
8
|
+
setup: (ctx) => followPresence(presenceDirectory, async () => {
|
|
9
|
+
const cleanup = await plugin.setup(ctx);
|
|
10
|
+
return async () => { await cleanup?.(); };
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=desktop-plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-plugin.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/desktop-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAE3C,yEAAyE;AACzE,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,iBAAyB;IACrD,OAAO;QACL,GAAG,MAAM;QACT,KAAK,EAAE,CAAC,GAAuC,EAAE,EAAE,CAAC,cAAc,CAAC,iBAAiB,EAAE,KAAK,IAAI,EAAE;YAC/F,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACvC,OAAO,KAAK,IAAI,EAAE,GAAG,MAAM,OAAO,EAAE,EAAE,CAAA,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC;KACH,CAAA;AACH,CAAC","sourcesContent":["import plugin from \"./plugin\"\nimport { followPresence } from \"./presence\"\n\n// The bundled factory is wrapped by a tiny auto-discovered native entry.\n// No tools, commands or model hooks are registered, even while CodeNomad is open.\nexport function desktopPlugin(presenceDirectory: string) {\n return {\n ...plugin,\n setup: (ctx: Parameters<typeof plugin.setup>[0]) => followPresence(presenceDirectory, async () => {\n const cleanup = await plugin.setup(ctx)\n return async () => { await cleanup?.() }\n }),\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA","sourcesContent":["export { default } from \"./plugin\"\n"]}
|
|
@@ -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"]}
|