@staix/agent-mem 0.13.0 → 0.15.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/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -2
- package/package.json +3 -2
- package/src/cli-agent-reads.ts +27 -13
- package/src/cli.ts +20 -5
- package/src/commands-table.ts +34 -6
- package/src/memory/catalog.ts +4 -4
- package/src/memory/commands.ts +4 -0
- package/src/memory/doctor.ts +13 -3
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/migrate.ts +1 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +4 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +46 -8
- package/src/memory/sync.ts +141 -5
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/** Durable evidence and candidate sets. Activation/advance alone commits a selector; sync owns transport admission. */
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { constants, closeSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync, type Stats } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { canonicalJSON, fsyncDirectory, hashJSON } from "./generation.ts";
|
|
6
|
+
import { acquireMutation, assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { entryId, type Entry } from "./store.ts";
|
|
9
|
+
import { prunePayloadHash } from "./prune.ts";
|
|
10
|
+
import { shardSyncRef, type ShardSyncRealm } from "./shard-sync-format.ts";
|
|
11
|
+
import { verifyPublicPruneWitness, type VerifiedPublicPruneWitness } from "./public-prune-witness.ts";
|
|
12
|
+
|
|
13
|
+
import { PUBLIC_PRUNE_INPUT_MAX_BYTES, PUBLIC_PRUNE_REPLAY_MAX_BYTES, PUBLIC_PRUNE_MAX_SETS, PublicPruneInputError, validatePublicPruneInputSet, type PublicPruneInputSetV1 } from "./public-prune-input-chain.ts";
|
|
14
|
+
export { PUBLIC_PRUNE_INPUT_MAX_BYTES, PUBLIC_PRUNE_REPLAY_MAX_BYTES, PUBLIC_PRUNE_MAX_SETS, PublicPruneInputError, validatePublicPruneInputSet, readPublicPruneInputSetChain, type PublicPruneInputSetV1 } from "./public-prune-input-chain.ts";
|
|
15
|
+
export const PUBLIC_PRUNE_GRAPH_MAX_BYTES = 80 * 1024 * 1024;
|
|
16
|
+
export const PUBLIC_PRUNE_GRAPH_MAX_OBJECTS = 256;
|
|
17
|
+
const OBJECT_MAX = 1024 * 1024 + 64, ARCHIVE_FILES_MAX = 16_384;
|
|
18
|
+
type Source = { remoteHash: string; ref: string; commit: string; manifestPath: string; manifestBlob: string };
|
|
19
|
+
export interface PublicPruneInputV1 {
|
|
20
|
+
schema: "agent-mem.public-prune-input/v1"; realm: ShardSyncRealm; receivingGeneration: string;
|
|
21
|
+
selectorRevision: string; witnessHash: string; source: Source;
|
|
22
|
+
}
|
|
23
|
+
export interface PublicPruneBinding { realm: ShardSyncRealm; receivingGeneration: string; selectorRevision: string; inputSetHash: string | null }
|
|
24
|
+
/** Complete Git object bytes, including the uncompressed '<type> <size>\0' header. */
|
|
25
|
+
export interface PublicPruneObject { oid: string; bytes: Uint8Array }
|
|
26
|
+
export interface PublicPruneInputOptions {
|
|
27
|
+
dir: string; binding: PublicPruneBinding; current: () => PublicPruneBinding;
|
|
28
|
+
/** null prepares an explicit empty activation-set candidate, without activating it. */
|
|
29
|
+
input: { source: Source; witnessHash: string; objects: readonly PublicPruneObject[] } | null;
|
|
30
|
+
}
|
|
31
|
+
declare const planBrand: unique symbol;
|
|
32
|
+
export interface PublicPruneInputPlan { readonly [planBrand]: true }
|
|
33
|
+
export interface PublicPruneInputRegistration { inputHash?: string; inputSetHash: string; previous: string | null }
|
|
34
|
+
export interface PublicPruneReplayInput {
|
|
35
|
+
dir: string; realm: ShardSyncRealm; receivingGeneration: string; inputSetHash: string;
|
|
36
|
+
nativeTargets?: readonly string[]; ordinary?: readonly Entry[];
|
|
37
|
+
}
|
|
38
|
+
export interface PublicPruneProjection {
|
|
39
|
+
targets: Set<string>; rowHashes: Map<string, string>; authoredPrunes: number; inputs: string[]; commitments: string[];
|
|
40
|
+
}
|
|
41
|
+
export type PublicPrunePersistPhase = `${"object" | "input" | "set"}-${"temp" | "fsync" | "installed"}`;
|
|
42
|
+
const fail = (code: PublicPruneInputError["code"] = "unavailable"): never => { throw new PublicPruneInputError(code); };
|
|
43
|
+
function guard(ok: unknown, code?: PublicPruneInputError["code"]): asserts ok { if (!ok) fail(code); }
|
|
44
|
+
function protectedCall<T>(work: () => T): T { try { return work(); } catch (error) { if (error instanceof PublicPruneInputError) throw error; return fail(); } }
|
|
45
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
46
|
+
const exact = (v: Record<string, unknown>, keys: string[]) => Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
|
|
47
|
+
const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
|
|
48
|
+
const sha1 = (v: Uint8Array) => createHash("sha1").update(v).digest("hex");
|
|
49
|
+
function selectedRealm(v: unknown): ShardSyncRealm {
|
|
50
|
+
guard(object(v));
|
|
51
|
+
if (v.scope === "user") { guard(exact(v, ["scope"])); return { scope: "user" }; }
|
|
52
|
+
guard(exact(v, ["scope", "key"]) && v.scope === "project" && typeof v.key === "string" && v.key.length > 0 && Buffer.byteLength(v.key) <= PUBLIC_PRUNE_INPUT_MAX_BYTES);
|
|
53
|
+
return { scope: "project", key: v.key };
|
|
54
|
+
}
|
|
55
|
+
function boundedJSON(v: unknown): void { guard(Buffer.byteLength(canonicalJSON(v)) <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource"); }
|
|
56
|
+
function source(v: unknown, realm: ShardSyncRealm, witnessHash: string): Source {
|
|
57
|
+
guard(object(v) && exact(v, ["remoteHash", "ref", "commit", "manifestPath", "manifestBlob"]));
|
|
58
|
+
guard(hex(v.remoteHash, 64) && hex(v.commit, 40) && hex(v.manifestBlob, 40) && v.ref === shardSyncRef(realm));
|
|
59
|
+
guard(v.manifestPath === `prune-witnesses/${witnessHash}.json`);
|
|
60
|
+
return { remoteHash: v.remoteHash, ref: v.ref, commit: v.commit, manifestPath: v.manifestPath as string, manifestBlob: v.manifestBlob };
|
|
61
|
+
}
|
|
62
|
+
export function validatePublicPruneInput(v: unknown): PublicPruneInputV1 {
|
|
63
|
+
return protectedCall(() => {
|
|
64
|
+
guard(object(v) && exact(v, ["schema", "realm", "receivingGeneration", "selectorRevision", "witnessHash", "source"])); boundedJSON(v);
|
|
65
|
+
guard(v.schema === "agent-mem.public-prune-input/v1" && hex(v.receivingGeneration, 32) && (hex(v.selectorRevision, 64) || v.selectorRevision === "absent") && hex(v.witnessHash, 64));
|
|
66
|
+
const realm = selectedRealm(v.realm);
|
|
67
|
+
return { schema: v.schema, realm, receivingGeneration: v.receivingGeneration, selectorRevision: v.selectorRevision, witnessHash: v.witnessHash, source: source(v.source, realm, v.witnessHash) };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function binding(v: PublicPruneBinding): PublicPruneBinding {
|
|
71
|
+
guard(object(v) && exact(v, ["realm", "receivingGeneration", "selectorRevision", "inputSetHash"]));
|
|
72
|
+
guard(hex(v.receivingGeneration, 32) && (hex(v.selectorRevision, 64) || v.selectorRevision === "absent") && (v.inputSetHash === null || hex(v.inputSetHash, 64)));
|
|
73
|
+
return { realm: selectedRealm(v.realm), receivingGeneration: v.receivingGeneration, selectorRevision: v.selectorRevision, inputSetHash: v.inputSetHash };
|
|
74
|
+
}
|
|
75
|
+
function json(bytes: Buffer): unknown {
|
|
76
|
+
guard(bytes.length > 0 && bytes.length <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource");
|
|
77
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes), value: unknown = JSON.parse(text), canonical = canonicalJSON(value);
|
|
78
|
+
guard(text === canonical || text === canonical + "\n"); return value;
|
|
79
|
+
}
|
|
80
|
+
interface GitObject { type: "commit" | "tree" | "blob"; body: Buffer }
|
|
81
|
+
function gitObject(oid: string, bytes: Buffer): GitObject {
|
|
82
|
+
guard(hex(oid, 40) && bytes.length <= OBJECT_MAX, "resource"); guard(sha1(bytes) === oid);
|
|
83
|
+
const end = bytes.indexOf(0); guard(end > 0 && end < 40);
|
|
84
|
+
const header = bytes.subarray(0, end).toString("utf8"), match = /^(commit|tree|blob) (0|[1-9][0-9]*)$/.exec(header);
|
|
85
|
+
guard(match && match[0] === header && Number(match[2]) === bytes.length - end - 1);
|
|
86
|
+
const body = bytes.subarray(end + 1);
|
|
87
|
+
guard(match[1] !== "commit" || body.length <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource");
|
|
88
|
+
return { type: match[1] as GitObject["type"], body };
|
|
89
|
+
}
|
|
90
|
+
function treeEntries(body: Buffer): Map<string, { mode: string; oid: string }> {
|
|
91
|
+
const entries = new Map<string, { mode: string; oid: string }>();
|
|
92
|
+
for (let offset = 0; offset < body.length;) {
|
|
93
|
+
const space = body.indexOf(32, offset), end = body.indexOf(0, space + 1);
|
|
94
|
+
guard(space > offset && end > space + 1 && end + 21 <= body.length);
|
|
95
|
+
const mode = body.subarray(offset, space).toString("utf8"), name = new TextDecoder("utf-8", { fatal: true }).decode(body.subarray(space + 1, end));
|
|
96
|
+
guard(["40000", "100644"].includes(mode) && name !== "." && name !== ".." && !name.includes("/") && !entries.has(name));
|
|
97
|
+
entries.set(name, { mode, oid: body.subarray(end + 1, end + 21).toString("hex") }); offset = end + 21;
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
/** Exact path closure only. Sibling object bodies are neither requested nor retained. */
|
|
102
|
+
function graph(input: PublicPruneInputV1, load: (oid: string) => Buffer): { proof: VerifiedPublicPruneWitness; used: Set<string> } {
|
|
103
|
+
const used = new Set<string>(), cache = new Map<string, GitObject>(), trees = new Map<string, ReturnType<typeof treeEntries>>(); let total = 0;
|
|
104
|
+
const get = (oid: string, type: GitObject["type"]): GitObject => {
|
|
105
|
+
let item = cache.get(oid);
|
|
106
|
+
if (!item) {
|
|
107
|
+
const bytes = load(oid); total += bytes.length; guard(total <= PUBLIC_PRUNE_GRAPH_MAX_BYTES && used.size < PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, "resource");
|
|
108
|
+
item = gitObject(oid, bytes); used.add(oid); cache.set(oid, item);
|
|
109
|
+
}
|
|
110
|
+
guard(item.type === type); return item;
|
|
111
|
+
};
|
|
112
|
+
const commit = get(input.source.commit, "commit").body, end = commit.indexOf(10); guard(end > 0);
|
|
113
|
+
const first = commit.subarray(0, end).toString("utf8");
|
|
114
|
+
const root = /^tree ([a-f0-9]{40})$/.exec(first); guard(root && root[0] === first);
|
|
115
|
+
const path = (name: string): { oid: string; body: Buffer } => {
|
|
116
|
+
const parts = name.split("/"); let oid = root[1]!;
|
|
117
|
+
for (let i = 0; i < parts.length; i++) {
|
|
118
|
+
let entries = trees.get(oid); if (!entries) { entries = treeEntries(get(oid, "tree").body); trees.set(oid, entries); }
|
|
119
|
+
const entry = entries.get(parts[i]!); guard(entry);
|
|
120
|
+
const last = i === parts.length - 1; guard(entry.mode === (last ? "100644" : "40000")); oid = entry.oid;
|
|
121
|
+
}
|
|
122
|
+
return { oid, body: get(oid, "blob").body };
|
|
123
|
+
};
|
|
124
|
+
const expected = { schema: "agent-mem.shard-sync/v2", ...input.realm };
|
|
125
|
+
guard(canonicalJSON(json(path("shard.json").body)) === canonicalJSON(expected));
|
|
126
|
+
const manifest = path(input.source.manifestPath); guard(manifest.oid === input.source.manifestBlob);
|
|
127
|
+
const raw = json(manifest.body); guard(object(raw) && Array.isArray(raw.witnesses) && raw.witnesses.length <= 64 && object(raw.pruneOccurrence));
|
|
128
|
+
const refs = [raw.pruneOccurrence, ...raw.witnesses], packs = new Map<string, { pack: string; blob: string; bytes: Buffer }>();
|
|
129
|
+
for (const ref of refs) {
|
|
130
|
+
guard(object(ref) && typeof ref.pack === "string" && ref.pack.length === 140 && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.exec(ref.pack)?.[0] === ref.pack && hex(ref.blob, 40));
|
|
131
|
+
if (packs.has(ref.pack)) { guard(packs.get(ref.pack)!.blob === ref.blob); continue; }
|
|
132
|
+
const found = path(ref.pack); guard(found.oid === ref.blob); packs.set(ref.pack, { pack: ref.pack, blob: ref.blob, bytes: found.body });
|
|
133
|
+
}
|
|
134
|
+
const proof = verifyPublicPruneWitness(raw, { realm: input.realm, packs: [...packs.values()] });
|
|
135
|
+
guard(proof.status === "verified" && proof.hash === input.witnessHash);
|
|
136
|
+
return { proof, used };
|
|
137
|
+
}
|
|
138
|
+
interface Prepared { dir: string; dev: number; ino: number; binding: PublicPruneBinding; current: () => PublicPruneBinding; input?: PublicPruneInputV1; objects: Map<string, Buffer> }
|
|
139
|
+
const plans = new WeakMap<PublicPruneInputPlan, Prepared>();
|
|
140
|
+
export function preparePublicPruneInput(options: PublicPruneInputOptions): PublicPruneInputPlan {
|
|
141
|
+
return protectedCall(() => {
|
|
142
|
+
const expected = binding(options.binding); guard(canonicalJSON(binding(options.current())) === canonicalJSON(expected), "binding");
|
|
143
|
+
const objects = new Map<string, Buffer>(); let input: PublicPruneInputV1 | undefined;
|
|
144
|
+
if (options.input !== null) {
|
|
145
|
+
const value = options.input; guard(value && Array.isArray(value.objects)); guard(value.objects.length <= PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, "resource");
|
|
146
|
+
let total = 0;
|
|
147
|
+
for (const item of value.objects) {
|
|
148
|
+
guard(object(item) && exact(item, ["oid", "bytes"]) && hex(item.oid, 40) && item.bytes instanceof Uint8Array);
|
|
149
|
+
total += item.bytes.byteLength; guard(item.bytes.byteLength <= OBJECT_MAX && total <= PUBLIC_PRUNE_GRAPH_MAX_BYTES, "resource"); guard(!objects.has(item.oid));
|
|
150
|
+
objects.set(item.oid, Buffer.from(item.bytes));
|
|
151
|
+
}
|
|
152
|
+
input = validatePublicPruneInput({ schema: "agent-mem.public-prune-input/v1", realm: expected.realm, receivingGeneration: expected.receivingGeneration,
|
|
153
|
+
selectorRevision: expected.selectorRevision, witnessHash: value.witnessHash, source: value.source });
|
|
154
|
+
const checked = graph(input, oid => { const bytes = objects.get(oid); guard(bytes); return bytes; });
|
|
155
|
+
guard(checked.used.size === objects.size); // never retain whole sibling subtrees or unrelated private bodies
|
|
156
|
+
}
|
|
157
|
+
guard(canonicalJSON(binding(options.current())) === canonicalJSON(expected), "binding");
|
|
158
|
+
const token = Object.freeze({}) as PublicPruneInputPlan;
|
|
159
|
+
const dir = rootDir(options.dir), stat = lstatSync(dir);
|
|
160
|
+
plans.set(token, { dir, dev: stat.dev, ino: stat.ino, binding: expected, current: options.current, input, objects }); return token;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
type Kind = "object" | "input" | "set";
|
|
164
|
+
const folders: Record<Kind, string> = { object: "public-prune-objects", input: "public-prune-inputs", set: "public-prune-input-sets" };
|
|
165
|
+
function filename(kind: Kind, id: string): string { guard(hex(id, kind === "object" ? 40 : 64)); return `${id}.${kind === "object" ? "git" : "json"}`; }
|
|
166
|
+
function privateStat(stat: Stats, directory = false, links = 1): void {
|
|
167
|
+
guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links));
|
|
168
|
+
guard(stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
|
|
169
|
+
}
|
|
170
|
+
function rootDir(dir: string): string { privateStat(lstatSync(dir), true); return realPath(dir); }
|
|
171
|
+
function folder(dir: string, kind: Kind, create = false): string {
|
|
172
|
+
const name = join(dir, folders[kind]), stat = lstatSync(name, { throwIfNoEntry: false });
|
|
173
|
+
if (!stat) { guard(create); mkdirSync(name, { mode: 0o700 }); fsyncDirectory(dir); }
|
|
174
|
+
privateStat(lstatSync(name), true); return name;
|
|
175
|
+
}
|
|
176
|
+
function read(dir: string, kind: Kind, id: string, observed?: (file: string, stat: Stats) => void): Buffer {
|
|
177
|
+
const file = join(folder(dir, kind), filename(kind, id)), before = lstatSync(file); privateStat(before);
|
|
178
|
+
guard(before.size > 0 && before.size <= (kind === "object" ? OBJECT_MAX : PUBLIC_PRUNE_INPUT_MAX_BYTES), "resource");
|
|
179
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
180
|
+
try {
|
|
181
|
+
const opened = fstatSync(fd); privateStat(opened); guard(opened.dev === before.dev && opened.ino === before.ino);
|
|
182
|
+
const bytes = readFileSync(fd), after = fstatSync(fd), named = lstatSync(file); privateStat(after); privateStat(named);
|
|
183
|
+
guard(bytes.length === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && named.dev === before.dev && named.ino === before.ino); observed?.(file, before); return bytes;
|
|
184
|
+
} finally { closeSync(fd); }
|
|
185
|
+
}
|
|
186
|
+
type Loader = (kind: Kind, id: string) => Buffer;
|
|
187
|
+
function replay(input: Omit<PublicPruneReplayInput, "dir">, load: Loader): PublicPruneProjection {
|
|
188
|
+
const selected = selectedRealm(input.realm); guard(hex(input.receivingGeneration, 32) && hex(input.inputSetHash, 64));
|
|
189
|
+
const cache = new Map<string, Buffer>(); let total = 0;
|
|
190
|
+
const data: Loader = (kind, id) => {
|
|
191
|
+
const key = `${kind}:${id}`; let bytes = cache.get(key);
|
|
192
|
+
if (!bytes) { bytes = load(kind, id); total += bytes.length; guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, "resource"); cache.set(key, bytes); } return bytes;
|
|
193
|
+
};
|
|
194
|
+
const sets = new Set<string>(); let id: string | null = input.inputSetHash, later: Set<string> | undefined, members: string[] = [];
|
|
195
|
+
while (id !== null) {
|
|
196
|
+
guard(!sets.has(id)); guard(sets.size < PUBLIC_PRUNE_MAX_SETS, "resource"); sets.add(id);
|
|
197
|
+
const set = validatePublicPruneInputSet(json(data("set", id))); guard(hashJSON(set) === id && set.receivingGeneration === input.receivingGeneration);
|
|
198
|
+
if (later) guard(set.inputs.every(hash => later!.has(hash))); else members = set.inputs;
|
|
199
|
+
later = new Set(set.inputs); id = set.previous;
|
|
200
|
+
}
|
|
201
|
+
const targets = new Set<string>(), rowHashes = new Map<string, string>(), identities = new Map<string, string>();
|
|
202
|
+
for (const member of members) {
|
|
203
|
+
const descriptor = validatePublicPruneInput(json(data("input", member)));
|
|
204
|
+
guard(hashJSON(descriptor) === member && descriptor.receivingGeneration === input.receivingGeneration && canonicalJSON(descriptor.realm) === canonicalJSON(selected));
|
|
205
|
+
const { proof } = graph(descriptor, oid => data("object", oid)), prune = proof.manifest.prune, hash = prunePayloadHash(prune), key = `${canonicalJSON(selected)}:${prune.id}`;
|
|
206
|
+
guard(!identities.has(key) || identities.get(key) === hash); identities.set(key, hash);
|
|
207
|
+
for (const ref of proof.manifest.witnesses) { guard(!rowHashes.has(ref.id) || rowHashes.get(ref.id) === ref.rowHash); rowHashes.set(ref.id, ref.rowHash); }
|
|
208
|
+
for (const target of prune.targets) targets.add(target.id);
|
|
209
|
+
}
|
|
210
|
+
for (const id of input.nativeTargets ?? []) { guard(hex(id, 32)); targets.add(id); }
|
|
211
|
+
for (const row of input.ordinary ?? []) {
|
|
212
|
+
const { id, ...body } = row; guard(hex(id, 32) && row.schema === "agent-hub.memory/v1" && entryId(body) === id);
|
|
213
|
+
guard(!rowHashes.has(id) || rowHashes.get(id) === hashJSON(row));
|
|
214
|
+
}
|
|
215
|
+
return { targets, rowHashes, authoredPrunes: identities.size, inputs: [...members], commitments: [...sets] };
|
|
216
|
+
}
|
|
217
|
+
export function reducePublicPruneInputs(input: PublicPruneReplayInput): PublicPruneProjection {
|
|
218
|
+
return protectedCall(() => { const dir = rootDir(input.dir); return replay(input, (kind, id) => read(dir, kind, id)); });
|
|
219
|
+
}
|
|
220
|
+
/** Reclaim only private staging names under the real writer fence. Validate the
|
|
221
|
+
* complete inventory first; a final name is never discarded or repaired. */
|
|
222
|
+
function recoverLinkedTemps(dir: string, mutation: MutationHandle): void {
|
|
223
|
+
assertMutationHandle(mutation, dir);
|
|
224
|
+
const actions: { base: string; baseStat: Stats; temp: string; stat: Stats; final?: string }[] = [];
|
|
225
|
+
const pendingName = (name: string) => name.length === 41 && /^\.pending-[a-f0-9]{32}$/.exec(name)?.[0] === name;
|
|
226
|
+
for (const kind of Object.keys(folders) as Kind[]) {
|
|
227
|
+
const base = join(dir, folders[kind]); if (!lstatSync(base, { throwIfNoEntry: false })) continue;
|
|
228
|
+
folder(dir, kind); const baseStat = lstatSync(base), names = readdirSync(base); guard(names.length <= ARCHIVE_FILES_MAX, "resource");
|
|
229
|
+
const inodes = new Map<string, { name: string; stat: Stats }[]>();
|
|
230
|
+
for (const name of names) {
|
|
231
|
+
const pending = pendingName(name), id = name.slice(0, kind === "object" ? 40 : 64);
|
|
232
|
+
guard(pending || filename(kind, id) === name);
|
|
233
|
+
const stat = lstatSync(join(base, name)); guard(stat.nlink === 1 || stat.nlink === 2); privateStat(stat, false, stat.nlink);
|
|
234
|
+
const key = `${stat.dev}:${stat.ino}`, entries = inodes.get(key) ?? []; entries.push({ name, stat }); inodes.set(key, entries);
|
|
235
|
+
}
|
|
236
|
+
for (const entries of inodes.values()) {
|
|
237
|
+
const pending = entries.find(item => pendingName(item.name));
|
|
238
|
+
if (entries[0]!.stat.nlink === 1) {
|
|
239
|
+
guard(entries.length === 1);
|
|
240
|
+
if (pending) actions.push({ base, baseStat, temp: join(base, pending.name), stat: pending.stat });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
guard(pending && entries.length === 2); const final = entries.find(item => item !== pending); guard(final && final.stat.nlink === 2);
|
|
244
|
+
const id = final.name.slice(0, kind === "object" ? 40 : 64); guard(filename(kind, id) === final.name);
|
|
245
|
+
const file = join(base, final.name), temp = join(base, pending.name), fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
246
|
+
try {
|
|
247
|
+
const opened = fstatSync(fd); privateStat(opened, false, 2);
|
|
248
|
+
guard(opened.dev === final.stat.dev && opened.ino === final.stat.ino && opened.size > 0 && opened.size <= (kind === "object" ? OBJECT_MAX : PUBLIC_PRUNE_INPUT_MAX_BYTES), "resource");
|
|
249
|
+
const bytes = readFileSync(fd); guard(bytes.length === opened.size);
|
|
250
|
+
if (kind === "object") gitObject(id, bytes);
|
|
251
|
+
else {
|
|
252
|
+
const value = kind === "input" ? validatePublicPruneInput(json(bytes)) : validatePublicPruneInputSet(json(bytes));
|
|
253
|
+
guard(hashJSON(value) === id);
|
|
254
|
+
}
|
|
255
|
+
for (const name of [file, temp]) { const named = lstatSync(name); privateStat(named, false, 2); guard(named.dev === opened.dev && named.ino === opened.ino && named.size === opened.size && named.mtimeMs === opened.mtimeMs); }
|
|
256
|
+
actions.push({ base, baseStat, temp, stat: opened, final: file });
|
|
257
|
+
} finally { closeSync(fd); }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Single-link temps can be arbitrarily incomplete, including sparse crash files.
|
|
261
|
+
// They carry no membership and are neither read into memory nor charged as proof.
|
|
262
|
+
for (const action of actions) {
|
|
263
|
+
assertMutationHandle(mutation, dir);
|
|
264
|
+
const parent = lstatSync(action.base); privateStat(parent, true); guard(parent.dev === action.baseStat.dev && parent.ino === action.baseStat.ino);
|
|
265
|
+
const links = action.final ? 2 : 1, fd = openSync(action.temp, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
266
|
+
try {
|
|
267
|
+
const opened = fstatSync(fd); privateStat(opened, false, links);
|
|
268
|
+
guard(opened.dev === action.stat.dev && opened.ino === action.stat.ino && opened.size === action.stat.size && opened.mtimeMs === action.stat.mtimeMs && opened.ctimeMs === action.stat.ctimeMs);
|
|
269
|
+
for (const name of action.final ? [action.final, action.temp] : [action.temp]) {
|
|
270
|
+
const named = lstatSync(name); privateStat(named, false, links);
|
|
271
|
+
guard(named.dev === opened.dev && named.ino === opened.ino && named.size === opened.size && named.mtimeMs === opened.mtimeMs && named.ctimeMs === opened.ctimeMs);
|
|
272
|
+
}
|
|
273
|
+
assertMutationHandle(mutation, dir); unlinkSync(action.temp); fsyncDirectory(action.base);
|
|
274
|
+
} finally { closeSync(fd); }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function capacity(dir: string, writes: Map<string, { kind: Kind; id: string; bytes: Buffer }>): void {
|
|
278
|
+
let total = 0, count = 0;
|
|
279
|
+
for (const kind of Object.keys(folders) as Kind[]) {
|
|
280
|
+
const name = join(dir, folders[kind]); if (!lstatSync(name, { throwIfNoEntry: false })) continue;
|
|
281
|
+
folder(dir, kind);
|
|
282
|
+
for (const entry of readdirSync(name)) {
|
|
283
|
+
const stat = lstatSync(join(name, entry)); privateStat(stat); total += stat.size; count++;
|
|
284
|
+
const candidate = [...writes.values()].find(value => value.kind === kind && filename(kind, value.id) === entry);
|
|
285
|
+
if (candidate) { guard(read(dir, kind, candidate.id).equals(candidate.bytes)); writes.delete(`${kind}:${candidate.id}`); }
|
|
286
|
+
guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && count <= ARCHIVE_FILES_MAX, "resource");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const value of writes.values()) { total += value.bytes.length; count++; }
|
|
290
|
+
guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && count <= ARCHIVE_FILES_MAX, "resource");
|
|
291
|
+
}
|
|
292
|
+
function write(dir: string, kind: Kind, id: string, bytes: Buffer, observe?: (phase: PublicPrunePersistPhase) => void): void {
|
|
293
|
+
const base = folder(dir, kind, true), file = join(base, filename(kind, id));
|
|
294
|
+
if (lstatSync(file, { throwIfNoEntry: false })) { guard(read(dir, kind, id).equals(bytes)); return; }
|
|
295
|
+
const temp = join(base, `.pending-${randomBytes(16).toString("hex")}`), fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
296
|
+
try {
|
|
297
|
+
let offset = 0; while (offset < bytes.length) { const written = writeSync(fd, bytes, offset, bytes.length - offset); guard(written > 0); offset += written; }
|
|
298
|
+
observe?.(`${kind}-temp`); fsyncSync(fd); observe?.(`${kind}-fsync`);
|
|
299
|
+
linkSync(temp, file); fsyncDirectory(base); observe?.(`${kind}-installed`);
|
|
300
|
+
} finally { closeSync(fd); unlinkSync(temp); fsyncDirectory(base); }
|
|
301
|
+
}
|
|
302
|
+
/** Persist evidence under the existing writer fence. A returned hash is still only a candidate commitment. */
|
|
303
|
+
export function persistPublicPruneInput(plan: PublicPruneInputPlan, options: { observe?: (phase: PublicPrunePersistPhase) => void; mutation?: MutationHandle } = {}): PublicPruneInputRegistration {
|
|
304
|
+
return protectedCall(() => {
|
|
305
|
+
const prepared = plans.get(plan); guard(prepared); const expected = prepared.binding;
|
|
306
|
+
const dir = rootDir(prepared.dir);
|
|
307
|
+
const current = () => {
|
|
308
|
+
const stat = lstatSync(dir); privateStat(stat, true);
|
|
309
|
+
guard(stat.dev === prepared.dev && stat.ino === prepared.ino && canonicalJSON(binding(prepared.current())) === canonicalJSON(expected), "binding");
|
|
310
|
+
}; current();
|
|
311
|
+
// The shared SQLite lock is an ownership fence, never an archive or membership source.
|
|
312
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"]) { const stat = lstatSync(join(dir, "mutation-lock.db" + suffix), { throwIfNoEntry: false }); if (stat) privateStat(stat); }
|
|
313
|
+
const release = options.mutation ?? acquireMutation(dir); guard(release); assertMutationHandle(release, dir); try {
|
|
314
|
+
current(); assertMutationHandle(release, dir); recoverLinkedTemps(dir, release); current(); const previous = expected.inputSetHash;
|
|
315
|
+
const members = previous ? replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, (kind, id) => read(dir, kind, id)).inputs : [];
|
|
316
|
+
const inputHash = prepared.input && hashJSON(prepared.input), includes = inputHash !== undefined && members.includes(inputHash);
|
|
317
|
+
const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration,
|
|
318
|
+
previous, inputs: [...new Set([...members, ...(inputHash ? [inputHash] : [])])].sort() };
|
|
319
|
+
validatePublicPruneInputSet(set);
|
|
320
|
+
const inputSetHash = previous && (!inputHash || includes) ? previous : hashJSON(set);
|
|
321
|
+
const writes = new Map<string, { kind: Kind; id: string; bytes: Buffer }>();
|
|
322
|
+
for (const [oid, bytes] of prepared.objects) writes.set(`object:${oid}`, { kind: "object", id: oid, bytes });
|
|
323
|
+
if (prepared.input && inputHash) writes.set(`input:${inputHash}`, { kind: "input", id: inputHash, bytes: Buffer.from(canonicalJSON(prepared.input) + "\n") });
|
|
324
|
+
if (inputSetHash !== previous) writes.set(`set:${inputSetHash}`, { kind: "set", id: inputSetHash, bytes: Buffer.from(canonicalJSON(set) + "\n") });
|
|
325
|
+
// Preflight the complete future committed view and byte budget before the first archive write.
|
|
326
|
+
replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash }, (kind, id) => writes.get(`${kind}:${id}`)?.bytes ?? read(dir, kind, id));
|
|
327
|
+
capacity(dir, writes); current();
|
|
328
|
+
for (const value of writes.values()) { assertMutationHandle(release, dir); write(dir, value.kind, value.id, value.bytes, options.observe); current(); assertMutationHandle(release, dir); }
|
|
329
|
+
return { ...(inputHash ? { inputHash } : {}), inputSetHash, previous };
|
|
330
|
+
} finally { if (!options.mutation) release(); }
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** In-process verified-byte frontier only. Never serialized or used as membership.
|
|
335
|
+
* Immutable source changes/deletion force replay/refusal; every new handle replays. */
|
|
336
|
+
export function createPublicPruneReplayCache(input: Omit<PublicPruneReplayInput, "nativeTargets" | "ordinary">): () => PublicPruneProjection {
|
|
337
|
+
let cached: PublicPruneProjection | undefined; const stamps = new Map<string, string>();
|
|
338
|
+
const stamp = (stat: Stats) => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.uid, stat.mode, stat.nlink].join(":");
|
|
339
|
+
const root = rootDir(input.dir), original = lstatSync(root);
|
|
340
|
+
return () => protectedCall(() => {
|
|
341
|
+
const named = lstatSync(root); privateStat(named, true); guard(named.dev === original.dev && named.ino === original.ino);
|
|
342
|
+
const current = () => {
|
|
343
|
+
for (const parent of new Set([...stamps.keys()].map(file => dirname(file)))) privateStat(lstatSync(parent), true);
|
|
344
|
+
return [...stamps].every(([file, expected]) => { const stat = lstatSync(file); privateStat(stat); return stamp(stat) === expected; });
|
|
345
|
+
};
|
|
346
|
+
if (!cached || !current()) {
|
|
347
|
+
stamps.clear(); cached = replay(input, (kind, id) => read(root, kind, id, (file, stat) => stamps.set(file, stamp(stat))));
|
|
348
|
+
guard(current());
|
|
349
|
+
}
|
|
350
|
+
return { targets: new Set(cached.targets), rowHashes: new Map(cached.rowHashes), authoredPrunes: cached.authoredPrunes, inputs: [...cached.inputs], commitments: [...cached.commitments] };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Reads only the declared immutable descriptor, never discovers membership by scanning. */
|
|
355
|
+
export function readPublicPruneInputDescriptor(input: { dir: string; hash: string }): PublicPruneInputV1 {
|
|
356
|
+
return protectedCall(() => {
|
|
357
|
+
guard(hex(input.hash, 64)); const dir = rootDir(input.dir);
|
|
358
|
+
const value = validatePublicPruneInput(json(read(dir, "input", input.hash))); guard(hashJSON(value) === input.hash); return value;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
declare const batchBrand: unique symbol;
|
|
363
|
+
export interface PublicPruneInputBatch { readonly [batchBrand]: true }
|
|
364
|
+
interface PreparedBatch {
|
|
365
|
+
dir: string; dev: number; ino: number; binding: PublicPruneBinding; current: () => PublicPruneBinding;
|
|
366
|
+
inputs: Prepared[]; mutation: MutationHandle;
|
|
367
|
+
}
|
|
368
|
+
type ArchiveWrite = { kind: Kind; id: string; bytes: Buffer };
|
|
369
|
+
const batches = new WeakMap<PublicPruneInputBatch, PreparedBatch>();
|
|
370
|
+
function batchCurrent(batch: PreparedBatch, mutation: MutationHandle): void {
|
|
371
|
+
assertMutationHandle(mutation, batch.dir);
|
|
372
|
+
const dir = rootDir(batch.dir), stat = lstatSync(dir);
|
|
373
|
+
guard(stat.dev === batch.dev && stat.ino === batch.ino && canonicalJSON(binding(batch.current())) === canonicalJSON(batch.binding), "binding");
|
|
374
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
375
|
+
const lock = lstatSync(join(dir, "mutation-lock.db" + suffix), { throwIfNoEntry: false }); if (lock) privateStat(lock);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Reconstruct the entire future view, with one aggregate archive budget. No effects. */
|
|
379
|
+
function batchOverlay(batch: PreparedBatch): { writes: Map<string, ArchiveWrite>; result: PublicPruneInputRegistration } {
|
|
380
|
+
const expected = batch.binding, writes = new Map<string, ArchiveWrite>(); let pendingBytes = 0;
|
|
381
|
+
const add = (kind: Kind, id: string, bytes: Buffer) => {
|
|
382
|
+
const key = `${kind}:${id}`, existing = writes.get(key);
|
|
383
|
+
if (existing) { guard(existing.bytes.equals(bytes)); return; }
|
|
384
|
+
pendingBytes += bytes.length; guard(pendingBytes <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && writes.size < ARCHIVE_FILES_MAX, "resource");
|
|
385
|
+
writes.set(key, { kind, id, bytes });
|
|
386
|
+
};
|
|
387
|
+
const load: Loader = (kind, id) => writes.get(`${kind}:${id}`)?.bytes ?? read(batch.dir, kind, id);
|
|
388
|
+
let previous = expected.inputSetHash, lastInputHash: string | undefined;
|
|
389
|
+
let members = previous ? replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, load).inputs : [];
|
|
390
|
+
for (const prepared of batch.inputs) {
|
|
391
|
+
guard(prepared.input); const input = prepared.input, inputHash = hashJSON(input); lastInputHash = inputHash;
|
|
392
|
+
// Every descriptor stays bound to the actual initial selector revision, never a simulated callback.
|
|
393
|
+
guard(canonicalJSON(prepared.binding) === canonicalJSON(expected));
|
|
394
|
+
const checked = graph(input, oid => { const bytes = prepared.objects.get(oid); guard(bytes); return bytes; });
|
|
395
|
+
guard(checked.used.size === prepared.objects.size);
|
|
396
|
+
for (const [oid, bytes] of prepared.objects) add("object", oid, bytes);
|
|
397
|
+
add("input", inputHash, Buffer.from(canonicalJSON(input) + "\n"));
|
|
398
|
+
if (!members.includes(inputHash)) {
|
|
399
|
+
members = [...members, inputHash].sort();
|
|
400
|
+
const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration, previous, inputs: members };
|
|
401
|
+
validatePublicPruneInputSet(set); previous = hashJSON(set); add("set", previous, Buffer.from(canonicalJSON(set) + "\n"));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (previous === null) {
|
|
405
|
+
const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration, previous: null, inputs: [] };
|
|
406
|
+
previous = hashJSON(set); add("set", previous, Buffer.from(canonicalJSON(set) + "\n"));
|
|
407
|
+
}
|
|
408
|
+
replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, load);
|
|
409
|
+
// capacity removes already-complete candidates only from its private working copy.
|
|
410
|
+
capacity(batch.dir, writes);
|
|
411
|
+
return { writes, result: { inputSetHash: previous, previous: expected.inputSetHash, ...(lastInputHash ? { inputHash: lastInputHash } : {}) } };
|
|
412
|
+
}
|
|
413
|
+
/** Preflight all future candidates before transport publication. This writes no
|
|
414
|
+
* evidence, but may reclaim strict unregistered crash temps under the supplied fence. */
|
|
415
|
+
export function preparePublicPruneInputBatch(options: {
|
|
416
|
+
dir: string; binding: PublicPruneBinding; current: () => PublicPruneBinding; inputs: NonNullable<PublicPruneInputOptions["input"]>[];
|
|
417
|
+
}, mutation: MutationHandle): PublicPruneInputBatch {
|
|
418
|
+
return protectedCall(() => {
|
|
419
|
+
const expected = binding(options.binding), dir = rootDir(options.dir), stat = lstatSync(dir);
|
|
420
|
+
const batch: PreparedBatch = { dir, dev: stat.dev, ino: stat.ino, binding: expected, current: options.current, inputs: [], mutation };
|
|
421
|
+
batchCurrent(batch, mutation); guard(Array.isArray(options.inputs));
|
|
422
|
+
// Bound before graph snapshots accumulate; no lifetime selected member is silently omitted.
|
|
423
|
+
guard(options.inputs.length <= PUBLIC_PRUNE_MAX_SETS, "resource");
|
|
424
|
+
let graphBytes = 0; const shared = new Map<string, Buffer>();
|
|
425
|
+
for (const input of options.inputs) {
|
|
426
|
+
const plan = preparePublicPruneInput({ dir, binding: expected, current: options.current, input }), prepared = plans.get(plan)!;
|
|
427
|
+
// Keep one byte copy of identical object bodies across every prepared graph.
|
|
428
|
+
for (const [oid, bytes] of prepared.objects) {
|
|
429
|
+
const existing = shared.get(oid);
|
|
430
|
+
if (existing) { guard(existing.equals(bytes)); prepared.objects.set(oid, existing); }
|
|
431
|
+
else { graphBytes += bytes.length; guard(graphBytes <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, "resource"); shared.set(oid, bytes); }
|
|
432
|
+
}
|
|
433
|
+
batch.inputs.push(prepared); batchCurrent(batch, mutation);
|
|
434
|
+
}
|
|
435
|
+
recoverLinkedTemps(dir, mutation); batchCurrent(batch, mutation);
|
|
436
|
+
batchOverlay(batch); batchCurrent(batch, mutation);
|
|
437
|
+
const token = Object.freeze({}) as PublicPruneInputBatch; batches.set(token, batch); return token;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** Durable create-once archive install; the root selector remains the caller's separate commit point. */
|
|
441
|
+
export function persistPublicPruneInputBatch(batch: PublicPruneInputBatch, options?: {
|
|
442
|
+
observe?: (phase: PublicPrunePersistPhase) => void; mutation: MutationHandle;
|
|
443
|
+
}): PublicPruneInputRegistration {
|
|
444
|
+
return protectedCall(() => {
|
|
445
|
+
const prepared = batches.get(batch); guard(prepared); const mutation = options?.mutation ?? prepared.mutation;
|
|
446
|
+
batchCurrent(prepared, mutation); recoverLinkedTemps(prepared.dir, mutation); batchCurrent(prepared, mutation);
|
|
447
|
+
const { writes, result } = batchOverlay(prepared); batchCurrent(prepared, mutation);
|
|
448
|
+
for (const item of writes.values()) {
|
|
449
|
+
batchCurrent(prepared, mutation); write(prepared.dir, item.kind, item.id, item.bytes, options?.observe); batchCurrent(prepared, mutation);
|
|
450
|
+
}
|
|
451
|
+
// Validate installed bytes too, including exact retries and interrupted prior attempts.
|
|
452
|
+
replay({ realm: prepared.binding.realm, receivingGeneration: prepared.binding.receivingGeneration, inputSetHash: result.inputSetHash }, (kind, id) => read(prepared.dir, kind, id));
|
|
453
|
+
batchCurrent(prepared, mutation); return result;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Root selector commitment only; this module never writes or activates a profile. */
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, type Stats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON, hashJSON, PROFILE_LOCK_TIMEOUT_MS } from "./generation.ts";
|
|
5
|
+
import { realPath } from "./platform.ts";
|
|
6
|
+
|
|
7
|
+
export const PUBLIC_PRUNE_INTENT = "public-prune-view.intent.json";
|
|
8
|
+
const MAX_BYTES = 65_536;
|
|
9
|
+
export interface PublicPruneViewV1 {
|
|
10
|
+
schema: "agent-mem.public-prune-view/v1";
|
|
11
|
+
receivingGeneration: string;
|
|
12
|
+
realms: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
const unavailable = (): never => { throw new Error("public prune view unavailable"); };
|
|
15
|
+
function guard(ok: unknown): asserts ok { if (!ok) unavailable(); }
|
|
16
|
+
function protectedCall<T>(work: () => T): T { try { return work(); } catch { return unavailable(); } }
|
|
17
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value)
|
|
18
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
19
|
+
const hex = (value: unknown, size: number): value is string => typeof value === "string" && value.length === size && /^[a-f0-9]+$/.test(value);
|
|
20
|
+
function realmKey(value: string): boolean {
|
|
21
|
+
if (!value.isWellFormed()) return false;
|
|
22
|
+
if (value === "user") return true;
|
|
23
|
+
if (!value.startsWith("project:")) return false;
|
|
24
|
+
const key = value.slice("project:".length);
|
|
25
|
+
return key.length > 0 && key === key.trim() && !/\p{Cc}/u.test(key);
|
|
26
|
+
}
|
|
27
|
+
export function validatePublicPruneView(input: unknown): PublicPruneViewV1 {
|
|
28
|
+
return protectedCall(() => {
|
|
29
|
+
guard(object(input) && Reflect.ownKeys(input).length === 3 && ["schema", "receivingGeneration", "realms"].every(key => Object.hasOwn(input, key)));
|
|
30
|
+
guard(input.schema === "agent-mem.public-prune-view/v1" && hex(input.receivingGeneration, 32) && object(input.realms));
|
|
31
|
+
const keys = Object.keys(input.realms).sort();
|
|
32
|
+
guard(Reflect.ownKeys(input.realms).length === keys.length && keys.length <= Math.floor(MAX_BYTES / 64));
|
|
33
|
+
const realms: Record<string, string> = {}; let minimumBytes = 0;
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
minimumBytes += Buffer.byteLength(key, "utf8") + 64; guard(minimumBytes <= MAX_BYTES);
|
|
36
|
+
const hash = input.realms[key]; guard(realmKey(key) && hex(hash, 64));
|
|
37
|
+
Object.defineProperty(realms, key, { value: hash, enumerable: true, writable: true, configurable: true });
|
|
38
|
+
}
|
|
39
|
+
const value: PublicPruneViewV1 = { schema: input.schema, receivingGeneration: input.receivingGeneration, realms };
|
|
40
|
+
guard(Buffer.byteLength(canonicalJSON(value), "utf8") <= MAX_BYTES);
|
|
41
|
+
return value;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function publicPruneViewHash(view: PublicPruneViewV1): string { return hashJSON(validatePublicPruneView(view)); }
|
|
45
|
+
/** A v2 reader may wait for a short cooperating writer, never authorize or repair one.
|
|
46
|
+
* No liveness helper, network call, PID exemption or mutation occurs on this path. */
|
|
47
|
+
export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number): void {
|
|
48
|
+
protectedCall(() => {
|
|
49
|
+
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
50
|
+
const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
|
|
51
|
+
if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
|
|
52
|
+
const small = (file: string, limit: number): Record<string, unknown> => {
|
|
53
|
+
const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
|
|
54
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
55
|
+
try {
|
|
56
|
+
const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
|
|
57
|
+
const bytes = Buffer.alloc(opened.size + 1); let count = 0;
|
|
58
|
+
while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
|
|
59
|
+
const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
|
|
60
|
+
const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
|
|
61
|
+
} finally { closeSync(fd); }
|
|
62
|
+
};
|
|
63
|
+
const profileFile = join(home, "profile.json");
|
|
64
|
+
if (!lstatSync(profileFile, { throwIfNoEntry: false })) return; // Implicit shared-v1 has no selector marker.
|
|
65
|
+
const profile = small(profileFile, MAX_BYTES);
|
|
66
|
+
if (profile.schema !== "agent-mem.profile/v2") return; // v1 admission/intent behavior is unchanged.
|
|
67
|
+
for (;;) {
|
|
68
|
+
try {
|
|
69
|
+
const intent = lstatSync(intentFile, { throwIfNoEntry: false }), lock = lstatSync(lockFile, { throwIfNoEntry: false });
|
|
70
|
+
if (!intent && !lock) return;
|
|
71
|
+
if (lock) {
|
|
72
|
+
privateStat(lock);
|
|
73
|
+
if (lock.size) {
|
|
74
|
+
const owner = small(lockFile, 4096);
|
|
75
|
+
guard(Reflect.ownKeys(owner).length === 3 && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0 && hex(owner.signature, 64) && hex(owner.token, 32));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (intent?.nlink === 2) {
|
|
79
|
+
// Create-once publication briefly links the complete staging inode to its final
|
|
80
|
+
// name before fsync/unlink. Wait under an owned complete writer lock, without
|
|
81
|
+
// reading or trusting that not-yet-independent intent as published evidence.
|
|
82
|
+
guard(lock && lock.size > 0 && intent.isFile() && !intent.isSymbolicLink()
|
|
83
|
+
&& intent.uid === process.getuid?.() && (intent.mode & 0o077) === 0
|
|
84
|
+
&& intent.size > 0 && intent.size <= 1024 * 1024);
|
|
85
|
+
} else if (intent) {
|
|
86
|
+
const pending = small(intentFile, 1024 * 1024);
|
|
87
|
+
guard(pending.schema === "agent-mem.public-prune-advance/v1" && pending.home === realPath(home) && pending.generation === profile.generation);
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; // Publication disappeared normally; retry the bounded observation.
|
|
91
|
+
}
|
|
92
|
+
const remaining = deadline - Date.now(); guard(remaining > 0); Bun.sleepSync(Math.min(5, remaining));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Any unresolved intent blocks reads. Never remove it, including malformed bytes. */
|
|
97
|
+
export function assertNoPublicPruneIntent(home: string, absoluteDeadline?: number): void {
|
|
98
|
+
protectedCall(() => { waitForPublicPruneWriter(home, absoluteDeadline); guard(!lstatSync(join(home, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false })); });
|
|
99
|
+
}
|
|
100
|
+
function privateStat(stat: Stats, directory = false): void {
|
|
101
|
+
guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1));
|
|
102
|
+
guard(stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
|
|
103
|
+
}
|
|
104
|
+
function same(left: Stats, right: Stats): boolean {
|
|
105
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
106
|
+
}
|
|
107
|
+
/** Trusted activation bootstrap reads the artifact while intent fences ordinary readers. */
|
|
108
|
+
export function readPublicPruneViewForActivation(home: string, hash: string, generation: string): PublicPruneViewV1 {
|
|
109
|
+
return protectedCall(() => {
|
|
110
|
+
guard(hex(hash, 64) && hex(generation, 32));
|
|
111
|
+
const original = lstatSync(home); privateStat(original, true);
|
|
112
|
+
const root = realPath(home), rootStat = lstatSync(root); privateStat(rootStat, true); guard(rootStat.dev === original.dev && rootStat.ino === original.ino);
|
|
113
|
+
const directory = join(root, "public-prune-input-sets"), dirStat = lstatSync(directory); privateStat(dirStat, true);
|
|
114
|
+
const file = join(directory, `${hash}.json`), before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= MAX_BYTES);
|
|
115
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
116
|
+
try {
|
|
117
|
+
const opened = fstatSync(fd); privateStat(opened); guard(same(before, opened));
|
|
118
|
+
const bytes = Buffer.alloc(before.size + 1); let count = 0;
|
|
119
|
+
while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (read === 0) break; count += read; }
|
|
120
|
+
guard(count === before.size);
|
|
121
|
+
const after = fstatSync(fd), named = lstatSync(file); privateStat(after); privateStat(named); guard(same(before, after) && same(after, named));
|
|
122
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count));
|
|
123
|
+
const view = validatePublicPruneView(JSON.parse(text)), canonical = canonicalJSON(view);
|
|
124
|
+
guard(text === canonical || text === canonical + "\n");
|
|
125
|
+
guard(view.receivingGeneration === generation && hashJSON(view) === hash);
|
|
126
|
+
const currentRoot = lstatSync(root), currentDir = lstatSync(directory), currentHome = lstatSync(home);
|
|
127
|
+
privateStat(currentRoot, true); privateStat(currentDir, true); privateStat(currentHome, true);
|
|
128
|
+
guard(currentRoot.dev === rootStat.dev && currentRoot.ino === rootStat.ino && currentHome.dev === original.dev && currentHome.ino === original.ino
|
|
129
|
+
&& currentDir.dev === dirStat.dev && currentDir.ino === dirStat.ino);
|
|
130
|
+
return view;
|
|
131
|
+
} finally { closeSync(fd); }
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readPublicPruneView(home: string, hash: string, generation: string, absoluteDeadline?: number): PublicPruneViewV1 {
|
|
136
|
+
assertNoPublicPruneIntent(home, absoluteDeadline);
|
|
137
|
+
const result = readPublicPruneViewForActivation(home, hash, generation);
|
|
138
|
+
assertNoPublicPruneIntent(home, absoluteDeadline); return result;
|
|
139
|
+
}
|