agents-can-communicate 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -44
- package/bin/acc-update-worker.mjs +2 -1
- package/bin/entrypoints/hook-output.mjs +2 -0
- package/docs/ARCHITECTURE.md +19 -0
- package/docs/CLI.md +11 -1
- package/docs/GETTING_STARTED.md +4 -3
- package/docs/UPGRADING.md +50 -0
- package/docs/WHY_ACC.md +32 -9
- package/docs/index.md +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +19 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
- package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
- package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
- package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
- package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
- package/package.json +5 -4
|
@@ -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
|
+
}
|
package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs
ADDED
|
@@ -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) {
|
|
@@ -74,8 +74,22 @@ export async function renameEntry(from, to, { platform = process.platform,
|
|
|
74
74
|
}, error => BUSY.has(error.code), { deadlineAt, sleep });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// A name inside a directory another process is deleting - a waiting writer's
|
|
78
|
+
// rmdir of a lock its owner emptied - answers lstat with EPERM until the
|
|
79
|
+
// deletion is done (measured on windows-latest: lstat of writer.lock\owner.json
|
|
80
|
+
// while eight writers elected one, 2026-10-06, and the waiting writer failed
|
|
81
|
+
// instead of looking again). The name is asked again within the caller's
|
|
82
|
+
// deadline: what it settles to is the answer, and an EPERM that outlasts the
|
|
83
|
+
// deadline is kept.
|
|
84
|
+
async function lstatSettled(file, lstat, { deadlineAt, sleep }) {
|
|
85
|
+
try {
|
|
86
|
+
return await retrying(() => lstat(file, { bigint: true }), error => error.code === "EPERM",
|
|
87
|
+
{ deadlineAt, sleep });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === "ENOENT") return null;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
79
93
|
|
|
80
94
|
const refusedLink = file => Object.assign(
|
|
81
95
|
new Error(`ELOOP: too many symbolic links encountered, open '${file}'`),
|
|
@@ -99,32 +113,39 @@ export async function openNoFollow(file, flags, { mode, platform = process.platf
|
|
|
99
113
|
open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
|
|
100
114
|
if (!isWindows(platform)) return open(file, flags | constants.O_NOFOLLOW, mode);
|
|
101
115
|
for (let attempt = 1; ; attempt += 1) {
|
|
102
|
-
const before = await
|
|
103
|
-
if (error.code === "ENOENT") return null;
|
|
104
|
-
throw error;
|
|
105
|
-
});
|
|
116
|
+
const before = await lstatSettled(file, lstat, { deadlineAt, sleep });
|
|
106
117
|
if (before?.isSymbolicLink()) throw refusedLink(file);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
let handle;
|
|
119
|
+
try {
|
|
120
|
+
// A scanner that opened the file without read sharing refuses every
|
|
121
|
+
// reader with EBUSY until it lets go.
|
|
122
|
+
handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
|
|
123
|
+
{ deadlineAt, sleep });
|
|
124
|
+
} catch (error) {
|
|
111
125
|
// A name another process is deleting, or a lock changing hands around
|
|
112
126
|
// it, refuses the open with EPERM, and the name is gone right after
|
|
113
127
|
// (measured on windows-latest, for a name present before the open and
|
|
114
128
|
// for one absent before it): that is ENOENT, as Linux says it. A name
|
|
115
|
-
// that
|
|
116
|
-
//
|
|
129
|
+
// that names another file by then changed hands - a lock released and
|
|
130
|
+
// taken again while the open was refused (measured on windows-latest,
|
|
131
|
+
// 2026-10-06) - and is opened afresh, as a swap after the open is. A
|
|
132
|
+
// name still naming what it named keeps its EPERM, and so does a create
|
|
133
|
+
// on a name that was free, which is a refused create.
|
|
117
134
|
const refusedCreate = before === null && (flags & constants.O_CREAT) !== 0;
|
|
118
|
-
if (error.code !== "EPERM" || refusedCreate
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
if (error.code !== "EPERM" || refusedCreate) throw error;
|
|
136
|
+
const now = await lstatSettled(file, lstat, { deadlineAt, sleep }).catch(() => undefined);
|
|
137
|
+
if (now === null) {
|
|
138
|
+
throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
|
|
139
|
+
{ cause: error }), { code: "ENOENT", syscall: "open", path: file });
|
|
140
|
+
}
|
|
141
|
+
const changedHands = now !== undefined
|
|
142
|
+
&& (before === null || now.dev !== before.dev || now.ino !== before.ino);
|
|
143
|
+
if (changedHands && attempt < SWAPS) continue;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
122
146
|
try {
|
|
123
147
|
const opened = await handle.stat({ bigint: true });
|
|
124
|
-
const named = await
|
|
125
|
-
if (error.code === "ENOENT") return null;
|
|
126
|
-
throw error;
|
|
127
|
-
});
|
|
148
|
+
const named = await lstatSettled(file, lstat, { deadlineAt, sleep });
|
|
128
149
|
if (named?.isSymbolicLink()) throw refusedLink(file);
|
|
129
150
|
if (named !== null && named.dev === opened.dev && named.ino === opened.ino) return handle;
|
|
130
151
|
if (attempt >= SWAPS) throw refusedLink(file);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { doomedDirectories } from "./doomed-directory.mjs";
|
|
3
|
+
import { isWindows, syncEntry } from "./portable-fs.mjs";
|
|
4
|
+
import { assertManagedDirectory } from "./safe-directory.mjs";
|
|
5
|
+
|
|
6
|
+
// A decided prune finishes its applied prefix even after its deadline. On
|
|
7
|
+
// Windows the final idle-journal file flush commits earlier NTFS renames.
|
|
8
|
+
export async function syncPruneDirectories(root, directories, { platform } = {}) {
|
|
9
|
+
if (isWindows(platform)) return;
|
|
10
|
+
for (const directory of directories) {
|
|
11
|
+
try { await assertManagedDirectory(root, directory); }
|
|
12
|
+
catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
13
|
+
await syncEntry(directory, directory, { platform });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Empty-publication journals are physical indexed prunes. Process death loses
|
|
18
|
+
// their in-memory affected-directory set; conservatively fence every possible
|
|
19
|
+
// primary/marker parent before idle permits a current negative cache. No new
|
|
20
|
+
// journal format or per-record publication is needed.
|
|
21
|
+
export async function recoverPruneDurability(paths, { root, platform } = {}) {
|
|
22
|
+
if (isWindows(platform)) return;
|
|
23
|
+
const directories = [
|
|
24
|
+
...["message", "receipt"].flatMap(kind => [path.join(paths.state, kind),
|
|
25
|
+
path.join(paths.retained, "state", kind)]),
|
|
26
|
+
...await doomedDirectories(root), root,
|
|
27
|
+
];
|
|
28
|
+
await syncPruneDirectories(root, directories, { platform });
|
|
29
|
+
}
|
|
@@ -7,6 +7,7 @@ import { listDirectoryEntries, readJsonIfPresent } from "./atomic-json.mjs";
|
|
|
7
7
|
import { condemn, detachDoomed, discard, discardLeftovers, expired } from "./doomed-directory.mjs";
|
|
8
8
|
import { assertSequence, raiseEventFloor, readEventFloor } from "./event-floor.mjs";
|
|
9
9
|
import { statePath } from "./record-id.mjs";
|
|
10
|
+
import { reclaimIndexPages } from "./index-reclaim.mjs";
|
|
10
11
|
|
|
11
12
|
// Shared with the stage sweep that calls this, so one pass cannot spend two
|
|
12
13
|
// budgets. Exported for the caller that wants to reclaim without a bound.
|
|
@@ -148,8 +149,14 @@ export async function reclaimRetired(paths,
|
|
|
148
149
|
|
|
149
150
|
const removed = await discard(doomed, root, limit - spent, deadlineAt);
|
|
150
151
|
reclaimed += removed.spent;
|
|
152
|
+
spent += removed.spent;
|
|
153
|
+
const indexes = journals.drained && markers.drained && removed.drained
|
|
154
|
+
? await reclaimIndexPages(paths, { root, limit: limit - spent, deadlineAt })
|
|
155
|
+
: { reclaimed: 0, remaining: false };
|
|
156
|
+
reclaimed += indexes.reclaimed;
|
|
151
157
|
return { reclaimed,
|
|
152
|
-
remaining: !journals.drained || !markers.drained || !removed.drained
|
|
158
|
+
remaining: !journals.drained || !markers.drained || !removed.drained
|
|
159
|
+
|| (indexes.remaining && !indexes.deferred) };
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
/**
|
|
@@ -172,7 +179,7 @@ export async function reclaimRetired(paths,
|
|
|
172
179
|
* @returns {Promise<{ reclaimed: number, skipped: number, remaining: boolean }>}
|
|
173
180
|
*/
|
|
174
181
|
export async function reclaimStateRecords(paths, entries,
|
|
175
|
-
{ root, limit = RECLAIM_BUDGET, deadlineAt } = {}) {
|
|
182
|
+
{ root, limit = RECLAIM_BUDGET, deadlineAt, onCondemned } = {}) {
|
|
176
183
|
if (expired(deadlineAt)) return { reclaimed: 0, skipped: 0, remaining: entries.length > 0 };
|
|
177
184
|
let spent = 0;
|
|
178
185
|
let skipped = 0;
|
|
@@ -202,10 +209,13 @@ export async function reclaimStateRecords(paths, entries,
|
|
|
202
209
|
continue;
|
|
203
210
|
}
|
|
204
211
|
await condemn(filePath, doomed);
|
|
212
|
+
await onCondemned?.(filePath, doomed);
|
|
205
213
|
spent += 1;
|
|
206
214
|
condemned += 1;
|
|
207
215
|
try {
|
|
208
|
-
|
|
216
|
+
const markers = path.join(paths.retained, "state", entry.kind, entry.id);
|
|
217
|
+
await condemn(markers, doomed);
|
|
218
|
+
await onCondemned?.(markers, doomed);
|
|
209
219
|
spent += 1;
|
|
210
220
|
// Counted only when it moved. Most records own no marker directory at
|
|
211
221
|
// all, and counting one anyway made `remaining` compare what was
|
|
@@ -18,8 +18,11 @@ import { withWriterMutex } from "./writer-mutex.mjs";
|
|
|
18
18
|
* trimmedThrough: string|null }} RepairReport */
|
|
19
19
|
|
|
20
20
|
const report = ({ repaired = [], blocked = [], corrupt = [], swept = 0, staged = 0,
|
|
21
|
-
partials = 0, retired = 0, trimmedThrough = null }) => ({
|
|
21
|
+
partials = 0, retired = 0, trimmedThrough = null, migrationRequired = false }) => ({
|
|
22
22
|
healthy: blocked.length === 0 && corrupt.length === 0,
|
|
23
|
+
// An older contract, not an unreadable file: the identity read and named its
|
|
24
|
+
// version. The reader names the migration rather than calling it ambiguous.
|
|
25
|
+
...(migrationRequired ? { migrationRequired: true } : {}),
|
|
23
26
|
repaired: [...repaired].sort(),
|
|
24
27
|
blocked: [...blocked].sort(),
|
|
25
28
|
corrupt: [...corrupt].sort(),
|
|
@@ -75,7 +78,8 @@ async function inspect(root) {
|
|
|
75
78
|
identity = await readStoreIdentity(paths);
|
|
76
79
|
} catch (error) {
|
|
77
80
|
blocked.push(path.join(root, "protocol.json"));
|
|
78
|
-
return { paths, identity: null, pending, blocked, corrupt, reason: error.message
|
|
81
|
+
return { paths, identity: null, pending, blocked, corrupt, reason: error.message,
|
|
82
|
+
migrationRequired: error?.details?.reasonCode === "store_migration_required" };
|
|
79
83
|
}
|
|
80
84
|
if (identity === null) blocked.push(path.join(root, "protocol.json"));
|
|
81
85
|
|
|
@@ -123,6 +127,7 @@ export async function diagnoseFilesystemStore({ root }) {
|
|
|
123
127
|
return report({
|
|
124
128
|
blocked: state.blocked,
|
|
125
129
|
corrupt: state.corrupt,
|
|
130
|
+
migrationRequired: state.migrationRequired === true,
|
|
126
131
|
repaired: state.pending.map(entry => entry.transactionId),
|
|
127
132
|
...await countStaging(state.paths, root),
|
|
128
133
|
});
|
|
@@ -133,7 +138,8 @@ export async function repairFilesystemStore({ root, clock }) {
|
|
|
133
138
|
if (state.blocked.length > 0 || state.corrupt.length > 0) {
|
|
134
139
|
// Fail closed. Completing a journal on top of state we cannot even read
|
|
135
140
|
// would turn an ambiguous store into a confidently wrong one.
|
|
136
|
-
return report({ blocked: state.blocked, corrupt: state.corrupt
|
|
141
|
+
return report({ blocked: state.blocked, corrupt: state.corrupt,
|
|
142
|
+
migrationRequired: state.migrationRequired === true });
|
|
137
143
|
}
|
|
138
144
|
// No early return for an empty journal any more: a store with nothing to roll
|
|
139
145
|
// forward is exactly the store whose staging directory needs reclaiming.
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { assertPortableId, validateRecord } from "@agents-can-communicate/protocol";
|
|
3
3
|
import { readJsonIfPresent } from "./atomic-json.mjs";
|
|
4
|
-
import { requireStoreIdentity } from "./identity.mjs";
|
|
4
|
+
import { readMigrationIdentity, requireStoreIdentity } from "./identity.mjs";
|
|
5
5
|
import { assertStateBinding, statePath } from "./record-id.mjs";
|
|
6
6
|
import { ephemeralIsDeleted, stateGenerationIsDeleted } from "./retention.mjs";
|
|
7
7
|
import { storePaths } from "./store.mjs";
|
|
8
8
|
|
|
9
|
-
/** Inspect ownership without opening, creating, recovering, or locking the store.
|
|
10
|
-
|
|
9
|
+
/** Inspect ownership without opening, creating, recovering, or locking the store.
|
|
10
|
+
* Legacy identity inspection is read-only metadata for runtime hold classification. */
|
|
11
|
+
export async function readSessionRecord({ root, workspaceId, sessionId, allowLegacyIdentity = false }) {
|
|
11
12
|
assertPortableId(sessionId, "session id");
|
|
12
13
|
const paths = storePaths(root);
|
|
13
|
-
await
|
|
14
|
+
if (allowLegacyIdentity === true) await readMigrationIdentity(paths, workspaceId);
|
|
15
|
+
else await requireStoreIdentity(paths, { workspaceId, create: false });
|
|
14
16
|
const file = statePath(paths, "session", sessionId);
|
|
15
17
|
const durable = await readJsonIfPresent(file, root);
|
|
16
18
|
if (durable !== null) {
|