agents-can-communicate 0.9.1 → 0.10.1

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.
Files changed (71) hide show
  1. package/README.md +46 -44
  2. package/bin/acc-update-worker.mjs +2 -1
  3. package/bin/entrypoints/hook-output.mjs +2 -0
  4. package/docs/ARCHITECTURE.md +26 -2
  5. package/docs/CLI.md +11 -1
  6. package/docs/GETTING_STARTED.md +4 -3
  7. package/docs/UPGRADING.md +62 -3
  8. package/docs/WHY_ACC.md +32 -9
  9. package/docs/index.md +7 -5
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
  18. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
  24. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
  26. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
  27. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  28. package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
  29. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +97 -10
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +4 -0
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
  36. package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
  37. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
  39. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
  40. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
  41. package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
  42. package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
  43. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
  44. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
  47. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  51. package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
  52. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  61. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
  62. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
  63. package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
  64. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
  65. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
  66. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
  67. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
  68. package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
  69. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
  70. package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
  71. package/package.json +5 -4
@@ -0,0 +1,144 @@
1
+ import { AccError, EXIT, TRANSACTION_INDEXES, assertIndexTuple, indexKeysFor }
2
+ from "@agents-can-communicate/protocol";
3
+ import { assertPublicationDeadline } from "./deadline.mjs";
4
+ import { createIndexIO } from "./index-io.mjs";
5
+ import { createIndexTree } from "./index-tree.mjs";
6
+ import { encodeIndexPage, IndexCacheUnavailable } from "./index-pages.mjs";
7
+
8
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
9
+ const matches = (kind, record, index, tuple) => indexKeysFor(kind, record)
10
+ .some(key => key.index === index && same(key.tuple, tuple));
11
+ const references = page => page.type === "branch" ? page.children.map(([, hash]) => hash)
12
+ : page.type === "leaf" ? [...page.entries.map(entry => entry.ids), page.next].filter(Boolean)
13
+ : [page.next].filter(Boolean);
14
+
15
+ export function createIndexCache({ paths, root, workspaceId, publishOptions, loadPrimary }) {
16
+ let diagnostic = [];
17
+ const note = (code, reason) => { diagnostic = [{ code, reason }]; };
18
+ const tools = deadlineAt => {
19
+ const io = createIndexIO({ paths, root, publishOptions: { ...publishOptions, deadlineAt } });
20
+ const bounded = Object.fromEntries(Object.entries(io).map(([name, operation]) => [name, (...args) => {
21
+ assertPublicationDeadline(deadlineAt); return operation(...args);
22
+ }]));
23
+ return { io: bounded, tree: createIndexTree(bounded) };
24
+ };
25
+ async function valid(io, active) {
26
+ const manifest = await io.readManifest();
27
+ if (manifest === null) return null;
28
+ if (active.state !== "idle" || manifest.workspaceId !== workspaceId
29
+ || manifest.journalGeneration !== active.generation) throw new IndexCacheUnavailable("stale_manifest");
30
+ // Descendants are validated on their selected route. A root fault cannot
31
+ // be carried across an unrelated writer by simply changing its epoch.
32
+ for (const hash of Object.values(manifest.roots)) if (hash !== null) await io.readPage(hash);
33
+ return manifest;
34
+ }
35
+ async function phase(name) {
36
+ try { await publishOptions.failAt?.(name); }
37
+ catch (error) { throw new IndexCacheUnavailable("cache_publication_failed", error); }
38
+ }
39
+ async function publishManifest(io, manifest) {
40
+ await phase("after-index-pages");
41
+ await phase("before-index-manifest");
42
+ await io.writeManifest(manifest);
43
+ }
44
+ async function rebuild(active, deadlineAt, { required = false } = {}) {
45
+ // Authoritative reads stay outside every cache-error catch.
46
+ const primaries = await loadPrimary({ deadlineAt });
47
+ const deltas = Object.fromEntries(Object.keys(TRANSACTION_INDEXES).map(index => [index, []]));
48
+ for (const entry of primaries.values()) {
49
+ assertPublicationDeadline(deadlineAt);
50
+ if (entry.record.workspaceId !== workspaceId) {
51
+ throw new AccError(EXIT.DATA, "indexed primary belongs to a different workspace");
52
+ }
53
+ for (const key of indexKeysFor(entry.kind, entry.record)) deltas[key.index].push({ ...key, id: entry.id, operation: "put" });
54
+ }
55
+ const pages = new Map();
56
+ const tree = createIndexTree({ readPage: async hash => pages.get(hash), writePage: async page => {
57
+ assertPublicationDeadline(deadlineAt);
58
+ const { hash } = encodeIndexPage(page); pages.set(hash, page); return hash;
59
+ } });
60
+ const roots = {};
61
+ for (const index of Object.keys(deltas)) roots[index] = await tree.apply(null, deltas[index]);
62
+ const manifest = { indexVersion: 1, workspaceId, journalGeneration: active.generation, roots };
63
+ const { io } = tools(deadlineAt);
64
+ try {
65
+ // Publish only the final reachable tree, children before parents. The
66
+ // path copies made during a rebuild need never reach the filesystem.
67
+ const published = new Set();
68
+ async function publish(hash) {
69
+ if (hash === null || published.has(hash)) return;
70
+ const page = pages.get(hash);
71
+ for (const child of references(page)) await publish(child);
72
+ await io.writePage(page); published.add(hash);
73
+ }
74
+ for (const hash of Object.values(roots)) await publish(hash);
75
+ await publishManifest(io, manifest);
76
+ note("index_rebuilt", "verified_primary");
77
+ } catch (error) {
78
+ if (!(error instanceof IndexCacheUnavailable)) throw error;
79
+ note("index_unavailable", error.reason);
80
+ if (required) throw error;
81
+ }
82
+ return { manifest, tree };
83
+ }
84
+ async function lookup(index, tuple, { active, staged, deadlineAt, verify }) {
85
+ assertIndexTuple(index, tuple);
86
+ let tree, manifest;
87
+ try {
88
+ const selected = tools(deadlineAt);
89
+ tree = selected.tree;
90
+ manifest = await valid(selected.io, active);
91
+ if (manifest === null) throw new IndexCacheUnavailable("missing_manifest");
92
+ return await select(await tree.lookup(manifest.roots[index], tuple));
93
+ } catch (error) {
94
+ if (!(error instanceof IndexCacheUnavailable)) throw error;
95
+ }
96
+ ({ manifest, tree } = await rebuild(active, deadlineAt));
97
+ return select(await tree.lookup(manifest.roots[index], tuple));
98
+
99
+ async function select(ids) {
100
+ const selected = new Set(ids), kind = TRANSACTION_INDEXES[index].kind;
101
+ for (const entry of staged.values()) {
102
+ if (entry.kind !== kind) continue;
103
+ selected.delete(entry.id);
104
+ if (entry.removed !== true && matches(kind, entry.record, index, tuple)) selected.add(entry.id);
105
+ }
106
+ const result = [];
107
+ // Match eager listJsonFiles ordering, including prefix IDs such as a/a-b.
108
+ for (const id of [...selected].sort((a, b) => `${a}.json`.localeCompare(`${b}.json`))) {
109
+ assertPublicationDeadline(deadlineAt);
110
+ if (await verify(id)) result.push(id);
111
+ }
112
+ return result;
113
+ }
114
+ }
115
+ function prepareDeltas(loaded, staged) {
116
+ const deltas = [];
117
+ for (const [id, next] of staged) {
118
+ const previous = loaded.get(id);
119
+ const oldKeys = previous == null ? [] : indexKeysFor(previous.kind, previous.record);
120
+ const newKeys = next.removed === true ? [] : indexKeysFor(next.kind, next.record);
121
+ for (const key of oldKeys) if (!newKeys.some(found => same(found, key))) deltas.push({ ...key, id: next.id, operation: "remove" });
122
+ for (const key of newKeys) if (!oldKeys.some(found => same(found, key))) deltas.push({ ...key, id: next.id, operation: "put" });
123
+ }
124
+ return deltas;
125
+ }
126
+ async function commit({ before, after, deltas, deadlineAt }) {
127
+ try {
128
+ await phase("after-primary-commit");
129
+ const { io, tree } = tools(deadlineAt), manifest = await valid(io, before);
130
+ if (manifest === null) return; // An unrelated writer never constructs a cache.
131
+ const roots = { ...manifest.roots };
132
+ for (const index of Object.keys(roots)) {
133
+ roots[index] = await tree.apply(roots[index], deltas.filter(delta => delta.index === index));
134
+ }
135
+ await publishManifest(io, { ...manifest, roots, journalGeneration: after.generation });
136
+ } catch (error) {
137
+ if (!(error instanceof IndexCacheUnavailable)
138
+ && !(error.code === EXIT.CONFLICT && Date.now() >= deadlineAt)) throw error;
139
+ note("index_unavailable", error instanceof IndexCacheUnavailable ? error.reason : "deadline_expired");
140
+ }
141
+ }
142
+ return Object.freeze({ lookup, prepareDeltas, commit, rebuild,
143
+ diagnostics: () => diagnostic.map(item => ({ ...item })) });
144
+ }
@@ -0,0 +1,65 @@
1
+ import path from "node:path";
2
+ import { assertPortableId } from "@agents-can-communicate/protocol";
3
+ import { publishAtomic } from "./atomic-json.mjs";
4
+ import { withRegularNoFollow } from "./safe-file.mjs";
5
+ import { assertPageHash, decodeIndexPage, encodeIndexPage, INDEX_PAGE_BYTES, IndexCacheUnavailable }
6
+ from "./index-pages.mjs";
7
+
8
+ function manifestShape(value) {
9
+ const keys = object => object && !Array.isArray(object) ? Object.keys(object).sort().join(",") : "";
10
+ if (keys(value) !== "indexVersion,journalGeneration,roots,workspaceId"
11
+ || value.indexVersion !== 1 || !/^[0-9]{16}$/.test(value.journalGeneration)
12
+ || keys(value.roots) !== "messageByClientKey,receiptsByMessage") {
13
+ throw new IndexCacheUnavailable("invalid_manifest");
14
+ }
15
+ assertPortableId(value.workspaceId, "index workspace ID");
16
+ for (const hash of Object.values(value.roots)) if (hash !== null) assertPageHash(hash);
17
+ return value;
18
+ }
19
+ export function createIndexIO({ paths, root, publishOptions }) {
20
+ const directory = path.join(root, "indexes", "v1");
21
+ const options = { ...publishOptions, root, tmpDir: paths.tmp, stageDir: paths.stage, durability: "none" };
22
+ const cache = async operation => {
23
+ try { return await operation(); }
24
+ catch (error) {
25
+ if (error instanceof IndexCacheUnavailable) throw error;
26
+ throw new IndexCacheUnavailable(error.code === "ENOENT" ? "missing_page" : "unsafe_cache_io", error);
27
+ }
28
+ };
29
+ const readBytes = async file => {
30
+ const bytes = await withRegularNoFollow(file, root, "r", (handle, stat) =>
31
+ stat.size > INDEX_PAGE_BYTES ? null : handle.readFile());
32
+ if (bytes === null) throw new IndexCacheUnavailable("page_byte_limit");
33
+ return bytes;
34
+ };
35
+ return Object.freeze({
36
+ readPage: hash => cache(async () => decodeIndexPage(assertPageHash(hash),
37
+ await readBytes(path.join(directory, "pages", hash + ".json")))),
38
+ writePage: page => cache(async () => {
39
+ const { hash, bytes } = encodeIndexPage(page);
40
+ const file = path.join(directory, "pages", hash + ".json");
41
+ let damaged = false;
42
+ try { damaged = !(await readBytes(file)).equals(bytes); }
43
+ catch (error) {
44
+ if (error instanceof IndexCacheUnavailable && error.reason === "page_byte_limit") damaged = true;
45
+ else if (error.code !== "ENOENT") throw error;
46
+ }
47
+ // Valid pages stay immutable. A safely opened but damaged cache file is
48
+ // replaced with the verified bytes for its existing content address.
49
+ await publishAtomic(file, bytes, { ...options, replace: damaged });
50
+ return hash;
51
+ }),
52
+ readManifest: () => cache(async () => {
53
+ let bytes;
54
+ try { bytes = await readBytes(path.join(directory, "cache.json")); }
55
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
56
+ try { return manifestShape(JSON.parse(bytes.toString("utf8"))); }
57
+ catch (error) { throw new IndexCacheUnavailable("invalid_manifest", error); }
58
+ }),
59
+ writeManifest: manifest => cache(async () => {
60
+ manifestShape(manifest);
61
+ await publishAtomic(path.join(directory, "cache.json"),
62
+ Buffer.from(JSON.stringify(manifest) + "\n"), { ...options, replace: true });
63
+ }),
64
+ });
65
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import { assertPortableId } from "@agents-can-communicate/protocol";
3
+
4
+ export const INDEX_PAGE_BYTES = 65_536;
5
+ export const INDEX_PAGE_ENTRIES = 32;
6
+ const HASH = /^[a-f0-9]{64}$/;
7
+ export class IndexCacheUnavailable extends Error {
8
+ constructor(reason, cause) {
9
+ super(`transaction index unavailable: ${reason}`, { cause });
10
+ this.name = "IndexCacheUnavailable";
11
+ this.reason = reason;
12
+ }
13
+ }
14
+ const refuse = reason => { throw new IndexCacheUnavailable(reason); };
15
+ export function assertPageHash(hash) {
16
+ if (typeof hash !== "string" || !HASH.test(hash)) refuse("invalid_reference");
17
+ return hash;
18
+ }
19
+ function closed(value, keys) {
20
+ if (value === null || typeof value !== "object" || Array.isArray(value)
21
+ || Object.keys(value).sort().join(",") !== [...keys].sort().join(",")) refuse("invalid_page_shape");
22
+ }
23
+ function unique(values) {
24
+ if (new Set(values).size !== values.length) refuse("duplicate_page_entry");
25
+ }
26
+ export function canonicalTuple(tuple) {
27
+ if (!Array.isArray(tuple) || ![2, 3].includes(tuple.length)) refuse("invalid_tuple");
28
+ try { tuple.forEach(id => assertPortableId(id, "index tuple member")); }
29
+ catch (error) { throw new IndexCacheUnavailable("invalid_tuple", error); }
30
+ return JSON.stringify(tuple);
31
+ }
32
+ function limited(values, limit = INDEX_PAGE_ENTRIES) {
33
+ if (!Array.isArray(values) || values.length === 0 || values.length > limit) refuse("page_entry_limit");
34
+ }
35
+ export function validateIndexPage(page) {
36
+ if (page?.type === "branch") {
37
+ closed(page, ["type", "children"]); limited(page.children, 16);
38
+ const routes = [];
39
+ for (const child of page.children) {
40
+ if (!Array.isArray(child) || child.length !== 2 || !/^[a-f0-9]$/.test(child[0])) refuse("invalid_child");
41
+ assertPageHash(child[1]); routes.push(child[0]);
42
+ }
43
+ unique(routes);
44
+ if (routes.join("") !== [...routes].sort().join("")) refuse("noncanonical_routes");
45
+ } else if (page?.type === "leaf") {
46
+ closed(page, ["type", "entries", "next"]); limited(page.entries);
47
+ if (page.next !== null) assertPageHash(page.next);
48
+ const keys = [];
49
+ for (const entry of page.entries) {
50
+ closed(entry, ["digest", "tuple", "ids"]);
51
+ assertPageHash(entry.digest); assertPageHash(entry.ids); keys.push(canonicalTuple(entry.tuple));
52
+ }
53
+ unique(keys);
54
+ if (keys.some((key, i) => i > 0 && keys[i - 1].localeCompare(key) >= 0)) refuse("noncanonical_keys");
55
+ } else if (page?.type === "group") {
56
+ closed(page, ["type", "ids", "next"]); limited(page.ids);
57
+ if (page.next !== null) assertPageHash(page.next);
58
+ try { page.ids.forEach(id => assertPortableId(id, "index record ID")); }
59
+ catch (error) { throw new IndexCacheUnavailable("invalid_record_id", error); }
60
+ unique(page.ids);
61
+ if (page.ids.some((id, i) => i > 0 && page.ids[i - 1].localeCompare(id) >= 0)) refuse("noncanonical_ids");
62
+ } else refuse("unknown_page_type");
63
+ return page;
64
+ }
65
+ export function encodeIndexPage(page) {
66
+ validateIndexPage(page);
67
+ // Fixed field order makes insertion order irrelevant to content addressing.
68
+ const canonical = page.type === "branch" ? { type: page.type, children: page.children }
69
+ : page.type === "group" ? { type: page.type, ids: page.ids, next: page.next }
70
+ : { type: page.type, entries: page.entries.map(({ digest, tuple, ids }) => ({ digest, tuple, ids })), next: page.next };
71
+ const bytes = Buffer.from(JSON.stringify(canonical) + "\n");
72
+ if (bytes.length > INDEX_PAGE_BYTES) refuse("page_byte_limit");
73
+ return { bytes, hash: createHash("sha256").update(bytes).digest("hex") };
74
+ }
75
+ export function decodeIndexPage(hash, bytes) {
76
+ assertPageHash(hash);
77
+ if (!Buffer.isBuffer(bytes) || bytes.length > INDEX_PAGE_BYTES) refuse("page_byte_limit");
78
+ let page;
79
+ try { page = JSON.parse(bytes.toString("utf8")); }
80
+ catch (error) { throw new IndexCacheUnavailable("invalid_page_json", error); }
81
+ const encoded = encodeIndexPage(page);
82
+ if (!encoded.bytes.equals(bytes)) refuse("noncanonical_bytes");
83
+ if (encoded.hash !== hash) refuse("page_hash_mismatch");
84
+ return page;
85
+ }
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { publishAtomic } from "./atomic-json.mjs";
4
+ import { withRegularNoFollow } from "./safe-file.mjs";
5
+
6
+ const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
7
+ const digest = bytes => createHash("sha256").update(bytes).digest("hex");
8
+ const file = root => path.join(root, "indexes/v1/reclaim.json");
9
+ const fresh = () => ({ version: 1, pending: [], visited: [], cursor: null, done: false });
10
+ const valid = state => state && Object.keys(state).sort().join(",") === "cursor,done,pending,version,visited"
11
+ && state.version === 1 && typeof state.done === "boolean"
12
+ && [state.pending, state.visited].every(items => Array.isArray(items) && items.every(hash))
13
+ && (state.cursor === null || /^[a-f0-9]{64}\.json$/.test(state.cursor))
14
+ && (!state.done || state.pending.length === 0);
15
+
16
+ // This is optional, checksummed bookkeeping, never primary authority. Atomic
17
+ // replacement means process death leaves the previous complete checkpoint;
18
+ // lost/torn bytes restart marking before any further deletion. No fsync is due.
19
+ export async function readReclaimProgress(root) {
20
+ try {
21
+ const bytes = await withRegularNoFollow(file(root), root, "r", (handle, stat) =>
22
+ stat.size > 64 * 1024 * 1024 ? null : handle.readFile());
23
+ if (bytes === null) return fresh();
24
+ const value = JSON.parse(bytes.toString("utf8"));
25
+ if (!valid(value.state) || value.sha256 !== digest(JSON.stringify(value.state))) return fresh();
26
+ return value.state.done ? fresh() : value.state;
27
+ } catch { return fresh(); }
28
+ }
29
+
30
+ export async function writeReclaimProgress(paths, root, state, deadlineAt) {
31
+ const body = JSON.stringify(state);
32
+ await publishAtomic(file(root), Buffer.from(JSON.stringify({ state, sha256: digest(body) }) + "\n"), {
33
+ root, tmpDir: paths.tmp, stageDir: paths.stage, replace: true, durability: "none", deadlineAt,
34
+ });
35
+ }
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import { idleJournal, readActiveJournal } from "./active-journal.mjs";
3
+ import { listDirectoryEntries, nameCommittedLater } from "./atomic-json.mjs";
4
+ import { condemn, detachDoomed, discard, expired } from "./doomed-directory.mjs";
5
+ import { createIndexIO } from "./index-io.mjs";
6
+ import { IndexCacheUnavailable } from "./index-pages.mjs";
7
+ import { readReclaimProgress, writeReclaimProgress } from "./index-reclaim-progress.mjs";
8
+ import { readStoreIdentity } from "./identity.mjs";
9
+ import { journalEntry, writeJournalEntry } from "./journal.mjs";
10
+ import { syncPruneDirectories } from "./prune-durability.mjs";
11
+ import { assertManagedDirectory } from "./safe-directory.mjs";
12
+
13
+ const children = page => page.type === "branch" ? page.children.map(([, hash]) => hash)
14
+ : page.type === "leaf" ? [...page.entries.map(entry => entry.ids), page.next].filter(Boolean)
15
+ : [page.next].filter(Boolean);
16
+
17
+ // The caller holds the writer mutex. Persist both mark and sweep progress: a
18
+ // live graph or a directory larger than one pass must eventually finish. Roots
19
+ // added by intervening writers are marked before sweeping resumes. Previously
20
+ // marked roots stay in the conservative live superset until the next cycle.
21
+ export async function reclaimIndexPages(paths, { root, roots, limit = 512, deadlineAt } = {}) {
22
+ let spent = 0, reclaimed = 0;
23
+ const result = (remaining, deferred = false) => ({ reclaimed, remaining, spent, deferred });
24
+ if (expired(deadlineAt)) return result(true, true);
25
+ const directory = path.join(root, "indexes", "v1", "pages");
26
+ try { await assertManagedDirectory(root, directory); }
27
+ catch (error) { return result(error.code !== "ENOENT", error.code !== "ENOENT"); }
28
+ if (limit < 4) return result(true);
29
+ const io = createIndexIO({ paths, root, publishOptions: {} });
30
+ if (roots === undefined) {
31
+ let manifest;
32
+ try { manifest = await io.readManifest(); }
33
+ catch (error) { if (error instanceof IndexCacheUnavailable) return result(true, true); throw error; }
34
+ if (manifest === null) return result(true, true);
35
+ // Primary authority faults stay outside cache-error handling.
36
+ const active = await readActiveJournal(paths, root), identity = await readStoreIdentity(paths);
37
+ if (active.state !== "idle" || manifest.journalGeneration !== active.generation
38
+ || manifest.workspaceId !== identity?.workspaceId) return result(true, true);
39
+ roots = manifest.roots;
40
+ }
41
+ const progress = await readReclaimProgress(root);
42
+ spent += 1;
43
+ // Reserve the checkpoint publication within the same budget as page work.
44
+ const budget = limit - 1;
45
+ const reachable = new Set(progress.visited), pending = progress.pending;
46
+ for (const hash of Object.values(roots).filter(Boolean)) {
47
+ if (!reachable.has(hash) && !pending.includes(hash)) pending.push(hash);
48
+ }
49
+ const checkpoint = async (remaining) => {
50
+ progress.visited = [...reachable];
51
+ progress.done = !remaining;
52
+ try {
53
+ await writeReclaimProgress(paths, root, progress, deadlineAt);
54
+ spent += 1;
55
+ return result(remaining);
56
+ } catch { return result(true, true); }
57
+ };
58
+ try {
59
+ while (pending.length > 0) {
60
+ const hash = pending.pop();
61
+ if (reachable.has(hash)) continue;
62
+ if (spent >= budget || expired(deadlineAt)) {
63
+ pending.push(hash);
64
+ return checkpoint(true);
65
+ }
66
+ spent += 1;
67
+ pending.push(...children(await io.readPage(hash)));
68
+ reachable.add(hash);
69
+ }
70
+ } catch (error) {
71
+ if (error instanceof IndexCacheUnavailable) {
72
+ // A former root can disappear while the cache rebuilds. Do not let an
73
+ // unreadable old stack poison every later pass; restart from authority.
74
+ pending.length = 0;
75
+ reachable.clear();
76
+ progress.cursor = null;
77
+ await checkpoint(false);
78
+ return result(true, true);
79
+ }
80
+ throw error;
81
+ }
82
+ let entries;
83
+ try { entries = await listDirectoryEntries(directory, { root }); }
84
+ catch { return result(true, true); } // Cache paths alone are optional maintenance.
85
+ let doomed, reserved = 0, remaining = false;
86
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
87
+ for (const entry of entries) {
88
+ if (!/^[a-f0-9]{64}\.json$/.test(entry.name) || (progress.cursor !== null && entry.name <= progress.cursor)) continue;
89
+ if (spent + reserved >= budget || expired(deadlineAt)) { remaining = true; break; }
90
+ spent += 1;
91
+ if (!entry.isFile() || reachable.has(entry.name.slice(0, -5))) { progress.cursor = entry.name; continue; }
92
+ if (spent + reserved + 2 > budget) { remaining = true; break; }
93
+ doomed ??= await detachDoomed(root);
94
+ await assertManagedDirectory(root, directory);
95
+ await condemn(path.join(directory, entry.name), doomed);
96
+ spent += 1; reserved += 1;
97
+ progress.cursor = entry.name;
98
+ }
99
+ if (doomed !== undefined) {
100
+ const removed = await discard(doomed, root, budget - spent, deadlineAt);
101
+ reclaimed += removed.spent; spent += removed.spent;
102
+ remaining ||= !removed.drained;
103
+ }
104
+ return checkpoint(remaining);
105
+ }
106
+
107
+ async function discardCache(root, collect) {
108
+ const directory = path.join(root, "indexes", "v1");
109
+ // Only a validated cache directory may move. Damage/unsafe cache paths stay
110
+ // untouched, and the changed durable epoch already makes their roots stale.
111
+ try {
112
+ await assertManagedDirectory(root, directory);
113
+ const doomed = await detachDoomed(root);
114
+ await condemn(directory, doomed);
115
+ collect(directory, doomed);
116
+ } catch { /* Cache loss or refusal cannot fail a completed primary prune. */ }
117
+ }
118
+
119
+ // Existing empty-publication journals fence physical retirement without
120
+ // introducing a new authoritative marker or a per-message flush.
121
+ export async function withIndexedPrune(paths, options, operation) {
122
+ const { ids, firstSequence, root, deadlineAt, ...publish } = options;
123
+ const entry = journalEntry(ids.next("transaction"), firstSequence, [], options.clock.now());
124
+ await writeJournalEntry(paths, { ...publish, root, deadlineAt }, entry);
125
+ const directories = new Set([root]);
126
+ const collect = (file, doomed) => { directories.add(path.dirname(file)); directories.add(doomed); };
127
+ try {
128
+ await options.failAt?.("after-indexed-prune-activated");
129
+ await discardCache(root, collect);
130
+ return await operation(async (file, doomed) => {
131
+ collect(file, doomed);
132
+ await options.failAt?.("after-indexed-prune-move");
133
+ });
134
+ } finally {
135
+ // A decided/applied prefix must finish its durability even after its
136
+ // caller's deadline. POSIX flushes names; the final Windows journal flush
137
+ // commits every earlier NTFS metadata rename.
138
+ await syncPruneDirectories(root, directories, options);
139
+ await options.failAt?.("before-indexed-prune-idle");
140
+ await idleJournal(paths, { ...publish, root, durability: nameCommittedLater(options.platform) }, entry.transactionId);
141
+ }
142
+ }
@@ -0,0 +1,142 @@
1
+ import { createHash } from "node:crypto";
2
+ import { assertPortableId } from "@agents-can-communicate/protocol";
3
+ import { assertPageHash, canonicalTuple, IndexCacheUnavailable } from "./index-pages.mjs";
4
+
5
+ const compare = (a, b) => a.localeCompare(b);
6
+ const keyOf = entry => canonicalTuple(entry.tuple);
7
+ const digest = tuple => createHash("sha256").update(canonicalTuple(tuple)).digest("hex");
8
+ const fault = reason => { throw new IndexCacheUnavailable(reason); };
9
+ export function createIndexTree({ readPage, writePage, digestTuple = digest }) {
10
+ async function read(hash, seen) {
11
+ assertPageHash(hash);
12
+ if (seen.has(hash)) fault("page_cycle");
13
+ seen.add(hash);
14
+ return readPage(hash);
15
+ }
16
+ async function groupLookup(root) {
17
+ const ids = [], seen = new Set();
18
+ while (root !== null) {
19
+ const page = await read(root, seen);
20
+ if (page.type !== "group") fault("invalid_group_reference");
21
+ for (const id of page.ids) {
22
+ if (ids.length && compare(ids.at(-1), id) >= 0) fault("duplicate_or_unordered_group");
23
+ ids.push(id);
24
+ }
25
+ root = page.next;
26
+ }
27
+ return ids;
28
+ }
29
+ async function groupUpdate(root, id, operation, seen = new Set()) {
30
+ if (root === null) return operation === "remove" ? null : writePage({ type: "group", ids: [id], next: null });
31
+ const page = await read(root, seen);
32
+ if (page.type !== "group") fault("invalid_group_reference");
33
+ const position = page.ids.findIndex(found => compare(found, id) >= 0);
34
+ if (position === -1 && page.next !== null) {
35
+ const next = await groupUpdate(page.next, id, operation, seen);
36
+ return next === page.next ? root : writePage({ ...page, next });
37
+ }
38
+ const ids = [...page.ids], at = position === -1 ? ids.length : position;
39
+ if (ids[at] === id) {
40
+ if (operation === "put") return root;
41
+ ids.splice(at, 1);
42
+ } else {
43
+ if (operation === "remove") return root;
44
+ ids.splice(at, 0, id);
45
+ }
46
+ if (ids.length === 0) return page.next;
47
+ if (ids.length <= 32) return writePage({ ...page, ids });
48
+ const next = await writePage({ type: "group", ids: ids.slice(16), next: page.next });
49
+ return writePage({ type: "group", ids: ids.slice(0, 16), next });
50
+ }
51
+ function checkNode(page, depth, prefix) {
52
+ if (page.type === "branch") {
53
+ if (depth >= 64) fault("radix_depth_exceeded");
54
+ } else if (page.type === "leaf") {
55
+ if (page.next !== null && depth !== 64) fault("unexpected_collision_page");
56
+ for (const entry of page.entries) {
57
+ if (!entry.digest.startsWith(prefix) || entry.digest !== digestTuple(entry.tuple)) fault("misrouted_tuple");
58
+ }
59
+ } else fault("invalid_tree_reference");
60
+ }
61
+ async function lookup(root, tuple) {
62
+ const key = canonicalTuple(tuple), hash = assertPageHash(digestTuple(tuple)), seen = new Set();
63
+ let depth = 0, prefix = "", idsRoot = null;
64
+ while (root !== null) {
65
+ const page = await read(root, seen); checkNode(page, depth, prefix);
66
+ if (page.type === "branch") {
67
+ const route = hash[depth++]; prefix += route;
68
+ root = page.children.find(([digit]) => digit === route)?.[1] ?? null;
69
+ } else {
70
+ const found = page.entries.find(entry => keyOf(entry) === key);
71
+ if (found) {
72
+ if (idsRoot !== null) fault("duplicate_collision_key");
73
+ idsRoot = found.ids;
74
+ }
75
+ root = page.next;
76
+ }
77
+ }
78
+ return idsRoot === null ? [] : groupLookup(idsRoot);
79
+ }
80
+ const leaf = (entries, next = null) => writePage({ type: "leaf",
81
+ entries: [...entries].sort((a, b) => compare(keyOf(a), keyOf(b))), next });
82
+ async function split(entries, depth) {
83
+ if (entries.length <= 32) return leaf(entries);
84
+ if (depth === 64) {
85
+ let next = null;
86
+ for (let end = entries.length; end > 0; end -= 32) {
87
+ next = await leaf(entries.slice(Math.max(0, end - 32), end), next);
88
+ }
89
+ return next;
90
+ }
91
+ const routes = new Map();
92
+ for (const entry of entries) {
93
+ const route = entry.digest[depth];
94
+ if (!routes.has(route)) routes.set(route, []);
95
+ routes.get(route).push(entry);
96
+ }
97
+ const children = [];
98
+ for (const route of [...routes.keys()].sort()) children.push([route, await split(routes.get(route), depth + 1)]);
99
+ return writePage({ type: "branch", children });
100
+ }
101
+ async function update(root, tuple, id, operation, depth = 0, prefix = "", seen = new Set()) {
102
+ const hash = assertPageHash(digestTuple(tuple)), key = canonicalTuple(tuple);
103
+ if (root === null) {
104
+ if (operation === "remove") return null;
105
+ return leaf([{ digest: hash, tuple, ids: await groupUpdate(null, id, operation) }]);
106
+ }
107
+ const page = await read(root, seen); checkNode(page, depth, prefix);
108
+ if (page.type === "branch") {
109
+ const route = hash[depth], old = page.children.find(([digit]) => digit === route)?.[1] ?? null;
110
+ const next = await update(old, tuple, id, operation, depth + 1, prefix + route, seen);
111
+ if (old === next) return root;
112
+ const children = page.children.filter(([digit]) => digit !== route);
113
+ if (next !== null) children.push([route, next]);
114
+ children.sort(([a], [b]) => compare(a, b));
115
+ return children.length === 0 ? null : writePage({ type: "branch", children });
116
+ }
117
+ const entries = [...page.entries], at = entries.findIndex(entry => keyOf(entry) === key);
118
+ if (at !== -1) {
119
+ const ids = await groupUpdate(entries[at].ids, id, operation);
120
+ if (ids === entries[at].ids) return root;
121
+ if (ids === null) entries.splice(at, 1);
122
+ else entries[at] = { ...entries[at], ids };
123
+ return entries.length === 0 ? page.next : leaf(entries, page.next);
124
+ }
125
+ if (page.next !== null) {
126
+ const next = await update(page.next, tuple, id, operation, depth, prefix, seen);
127
+ return next === page.next ? root : leaf(entries, next);
128
+ }
129
+ if (operation === "remove") return root;
130
+ entries.push({ digest: hash, tuple, ids: await groupUpdate(null, id, operation) });
131
+ return split(entries, depth);
132
+ }
133
+ async function apply(root, deltas) {
134
+ for (const { tuple, id, operation } of deltas) {
135
+ assertPortableId(id, "index record ID");
136
+ if (!["put", "remove"].includes(operation)) fault("invalid_delta");
137
+ root = await update(root, tuple, id, operation);
138
+ }
139
+ return root;
140
+ }
141
+ return Object.freeze({ lookup, apply });
142
+ }
@@ -5,3 +5,4 @@ export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identi
5
5
  export { withWriterMutex } from "./writer-mutex.mjs";
6
6
  export { readSessionRecord } from "./session-read.mjs";
7
7
  export { openNoFollow } from "./portable-fs.mjs";
8
+ export { migrateFilesystemStore } from "./store-migration.mjs";
@@ -5,6 +5,7 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
5
5
  import { activateJournal, idleJournal, readActiveJournal } from "./active-journal.mjs";
6
6
  import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent, retainFile }
7
7
  from "./atomic-json.mjs";
8
+ import { recoverPruneDurability } from "./prune-durability.mjs";
8
9
 
9
10
  export const JOURNAL_VERSION = 2;
10
11
 
@@ -133,6 +134,7 @@ export async function readJournalCeiling(paths, root) {
133
134
  // twice changes nothing and a genuine conflict is never papered over.
134
135
  export async function rollForward(paths, options, entry) {
135
136
  const published = [];
137
+ if (entry.publications.length === 0) await recoverPruneDurability(paths, options);
136
138
  for (const publication of entry.publications) {
137
139
  const destination = publicationDestination(options.root, publication.path);
138
140
  if (publication.retainedPath !== null) {