@staix/agent-mem 0.12.0 → 0.14.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 +70 -44
- package/package.json +5 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +219 -56
- package/src/commands-table.ts +10 -9
- package/src/index.ts +1 -0
- package/src/ingest.ts +21 -4
- package/src/mcp.ts +8 -8
- package/src/memory/candidates.ts +15 -13
- package/src/memory/capacity-split.ts +4 -1
- package/src/memory/capacity.ts +48 -4
- package/src/memory/catalog.ts +4 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +63 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +36 -7
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +31 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/prune.ts +28 -1
- 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 +42 -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 +51 -12
- package/src/memory/style.ts +3 -0
- package/src/memory/sync.ts +141 -5
- package/src/memory/vault.ts +31 -0
- package/src/setup.ts +59 -31
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Explicit person-only, forward-only v2 activation. No read/capture path calls this API.
|
|
2
|
+
* Checkpoints are fault seams after intent, shard marker, selector and intent removal.
|
|
3
|
+
* A caller resumes an interrupted operation from its immutable direct-profile journal. */
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
8
|
+
import { readShardDescriptor, resolveShardProfile, shardDir, shardKeyHash, userShardDir, SHARDED_V2_FIELDS } from "./shard.ts";
|
|
9
|
+
import { acquireMutation } from "./mutation.ts";
|
|
10
|
+
import { assertNoLiveReaders } from "./reader-lease.ts";
|
|
11
|
+
import { aliasLogPath } from "./aliases.ts";
|
|
12
|
+
import { realPath } from "./platform.ts";
|
|
13
|
+
import { buildPublicPruneIndex } from "./store.ts";
|
|
14
|
+
import { reducePublicPruneInputs } from "./public-prune-inputs.ts";
|
|
15
|
+
import { PUBLIC_PRUNE_INTENT, publicPruneViewHash, readPublicPruneViewForActivation, validatePublicPruneView, type PublicPruneViewV1 } from "./public-prune-view.ts";
|
|
16
|
+
|
|
17
|
+
export type PublicPruneActivationStage = "after-intent" | "after-shards" | "after-profile" | "after-remove";
|
|
18
|
+
export interface PublicPruneActivationOptions {
|
|
19
|
+
memoryDir: string; mode: "dry-run" | "apply" | "resume"; authorizedPerson: boolean; tty: boolean;
|
|
20
|
+
view?: PublicPruneViewV1; checkpoint?: (stage: PublicPruneActivationStage) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface PublicPruneActivationResult { mode: PublicPruneActivationOptions["mode"]; generation: string; active: boolean; alreadyActive?: boolean; realms: number; viewHash: string; maskBuildCheck: "deferred" | "verified" }
|
|
23
|
+
interface Realm { key: string; profile: BoundProfile; metadata: string; sourceHash: string; native: string[] }
|
|
24
|
+
interface Intent {
|
|
25
|
+
schema: "agent-mem.public-prune-activation/v1"; home: string; generation: string; aliases: string;
|
|
26
|
+
previousProfile: string; previousShards: string; finalProfile: string; finalShards: string; finalProfileHash: string; finalShardsHash: string;
|
|
27
|
+
view: PublicPruneViewV1; realms: Realm[];
|
|
28
|
+
}
|
|
29
|
+
// ponytail: one complete 1 MiB journal; a larger installation needs a versioned segmented intent.
|
|
30
|
+
const MAX = 1024 * 1024;
|
|
31
|
+
const fail = (): never => { throw new Error("public prune activation unavailable; inspect or resume the pending activation"); };
|
|
32
|
+
function guard(ok: unknown): asserts ok { if (!ok) fail(); }
|
|
33
|
+
const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
|
|
34
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
const exact = (v: Record<string, unknown>, keys: string[]) => Reflect.ownKeys(v).length === keys.length && keys.every(key => Object.hasOwn(v, key));
|
|
36
|
+
function owned(path: string, directory = false, links = 1) {
|
|
37
|
+
const stat = lstatSync(path); guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links)
|
|
38
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0); return stat;
|
|
39
|
+
}
|
|
40
|
+
function raw(path: string, optional = false, links = 1): string {
|
|
41
|
+
if (optional && !lstatSync(path, { throwIfNoEntry: false })) return "absent";
|
|
42
|
+
const stat = owned(path, false, links); guard((optional || stat.size > 0) && stat.size <= MAX);
|
|
43
|
+
const bytes = readFileSync(path); guard(bytes.length === stat.size); const next = owned(path, false, links);
|
|
44
|
+
guard(next.dev === stat.dev && next.ino === stat.ino && next.mtimeMs === stat.mtimeMs);
|
|
45
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
46
|
+
}
|
|
47
|
+
function readProfileBytes(home: string): string {
|
|
48
|
+
const file = join(home, "profile.json"); if (!lstatSync(file, { throwIfNoEntry: false })) return "absent";
|
|
49
|
+
const text = raw(file); guard(text !== "absent"); return text;
|
|
50
|
+
}
|
|
51
|
+
const optionalBytes = (file: string) => lstatSync(file, {throwIfNoEntry:false}) ? {exists:true,bytes:raw(file)} : {exists:false};
|
|
52
|
+
const aliases = (home: string) => { const stat = owned(home, true); return hashJSON({ aliases: optionalBytes(aliasLogPath(home)), machine: raw(join(home, "machine.json")), dev: stat.dev, ino: stat.ino }); };
|
|
53
|
+
function metadata(profile: BoundProfile): string {
|
|
54
|
+
const stat = owned(profile.dir, true);
|
|
55
|
+
return hashJSON({ dev: stat.dev, ino: stat.ino, files: ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json"].map(name => [name, name === "project.json" ? optionalBytes(join(profile.dir, name)) : {exists:true,bytes:raw(join(profile.dir, name))}]) });
|
|
56
|
+
}
|
|
57
|
+
function enumerate(home: string): { key: string; profile: BoundProfile }[] {
|
|
58
|
+
const result: { key: string; profile: BoundProfile }[] = [], projects = join(home, "projects");
|
|
59
|
+
if (lstatSync(projects, { throwIfNoEntry: false })) {
|
|
60
|
+
owned(projects, true); const names = readdirSync(projects).sort(); guard(names.length <= 1024);
|
|
61
|
+
for (const hash of names) {
|
|
62
|
+
guard(hex(hash, 16)); const dir = join(projects, hash); owned(dir, true);
|
|
63
|
+
if (!lstatSync(join(dir, "generation.json"), { throwIfNoEntry: false })) continue;
|
|
64
|
+
const descriptor = readShardDescriptor(home, hash); guard(descriptor && shardKeyHash(descriptor.key) === hash);
|
|
65
|
+
result.push({ key: `project:${descriptor.key}`, profile: resolveShardProfile(home, { project: descriptor.key }) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (lstatSync(join(userShardDir(home), "generation.json"), { throwIfNoEntry: false })) result.push({ key: "user", profile: resolveShardProfile(home, { user: true }) });
|
|
69
|
+
guard(result.length > 0); return result.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
function realmBinding(home: string, realms: Realm[]): void {
|
|
72
|
+
const expected = new Set(realms.map(realm => realm.profile.dir)), found = new Set<string>(), projects = join(home, "projects");
|
|
73
|
+
if (lstatSync(projects, { throwIfNoEntry: false })) for (const name of readdirSync(projects)) {
|
|
74
|
+
guard(hex(name, 16)); const dir = join(projects, name);
|
|
75
|
+
if (lstatSync(join(dir, "generation.json"), { throwIfNoEntry: false })) { owned(dir, true); found.add(dir); }
|
|
76
|
+
}
|
|
77
|
+
const user = userShardDir(home); if (lstatSync(join(user, "generation.json"), { throwIfNoEntry: false })) { owned(user, true); found.add(user); }
|
|
78
|
+
guard(found.size === expected.size && [...expected].every(dir => found.has(dir)));
|
|
79
|
+
for (const realm of realms) guard(metadata(realm.profile) === realm.metadata);
|
|
80
|
+
}
|
|
81
|
+
/** Atomic create-once publication, recovering only this exact complete staging pair. */
|
|
82
|
+
function immutable(file: string, text: string): void {
|
|
83
|
+
const base = dirname(file); if (!lstatSync(base, { throwIfNoEntry: false })) { mkdirSync(base, { mode: 0o700 }); fsyncDirectory(dirname(base)); } owned(base, true);
|
|
84
|
+
const bytes = Buffer.from(text), temp = file + ".pending-" + createHash("sha256").update(bytes).digest("hex");
|
|
85
|
+
if (lstatSync(file, { throwIfNoEntry: false })) {
|
|
86
|
+
const stat = lstatSync(file);
|
|
87
|
+
if (stat.nlink === 2) {
|
|
88
|
+
const pending = owned(temp, false, 2); owned(file, false, 2); guard(stat.dev === pending.dev && stat.ino === pending.ino && stat.size === bytes.length);
|
|
89
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
90
|
+
try {
|
|
91
|
+
const opened = fstatSync(fd); guard(opened.dev === stat.dev && opened.ino === stat.ino && opened.nlink === 2 && readFileSync(fd).equals(bytes));
|
|
92
|
+
for (const name of [file, temp]) { const named = owned(name, false, 2); guard(named.dev === opened.dev && named.ino === opened.ino && named.size === opened.size && named.mtimeMs === opened.mtimeMs); }
|
|
93
|
+
unlinkSync(temp); fsyncDirectory(base);
|
|
94
|
+
} finally { closeSync(fd); }
|
|
95
|
+
}
|
|
96
|
+
guard(raw(file) === text); return;
|
|
97
|
+
}
|
|
98
|
+
if (lstatSync(temp, { throwIfNoEntry: false })) {
|
|
99
|
+
owned(temp); // Staging is not membership; discard an incomplete crash-left prefix under the profile fence.
|
|
100
|
+
if (!readFileSync(temp).equals(bytes)) { unlinkSync(temp); fsyncDirectory(base); }
|
|
101
|
+
}
|
|
102
|
+
if (!lstatSync(temp, { throwIfNoEntry: false })) {
|
|
103
|
+
const fd = openSync(temp, "wx", 0o600); try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
104
|
+
} else { const fd = openSync(temp, constants.O_RDONLY | constants.O_NOFOLLOW); try { fsyncSync(fd); } finally { closeSync(fd); } }
|
|
105
|
+
linkSync(temp, file); fsyncDirectory(base); unlinkSync(temp); fsyncDirectory(base);
|
|
106
|
+
}
|
|
107
|
+
function syncIndex(profile: BoundProfile): void {
|
|
108
|
+
const file = join(profile.dir, "index-v2.db"); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
109
|
+
try { fsyncSync(fd); } finally { closeSync(fd); } fsyncDirectory(profile.dir);
|
|
110
|
+
}
|
|
111
|
+
function nextProfile(realm: Realm, intent: Intent): BoundProfile {
|
|
112
|
+
return { ...realm.profile, selectorRevision: hashJSON(JSON.parse(intent.finalProfile)), foreignPruneFormat: "agent-mem.public-prune-witness/v1", pruneInputSetHash: publicPruneViewHash(intent.view) };
|
|
113
|
+
}
|
|
114
|
+
/** Existing membership must remain in the new checked cumulative chain, not merely its mask. */
|
|
115
|
+
function validateAdvance(intent: Intent): void {
|
|
116
|
+
const previous = intent.previousProfile === "absent" ? undefined : JSON.parse(intent.previousProfile);
|
|
117
|
+
const old = previous?.schema === "agent-mem.profile/v2"
|
|
118
|
+
? readPublicPruneViewForActivation(intent.home, previous.pruneInputSetHash, intent.generation)
|
|
119
|
+
: { realms: {} as Record<string, string> };
|
|
120
|
+
guard(Object.keys(old.realms).every(key => Object.hasOwn(intent.view.realms, key)));
|
|
121
|
+
for (const [key, set] of Object.entries(intent.view.realms)) {
|
|
122
|
+
const realm = intent.realms.find(item => item.key === key); guard(realm);
|
|
123
|
+
const proof = reducePublicPruneInputs({ dir: realm.profile.dir, realm: key === "user" ? { scope: "user" } : { scope: "project", key: key.slice(8) }, receivingGeneration: intent.generation, inputSetHash: set });
|
|
124
|
+
const ancestor = old.realms[key]; guard(ancestor === undefined || proof.commitments.includes(ancestor));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function verify(intent: Intent, profileBytes: string, shardBytes: string): void {
|
|
128
|
+
guard(aliases(intent.home) === intent.aliases && readProfileBytes(intent.home) === profileBytes && raw(join(intent.home, "shards.json")) === shardBytes);
|
|
129
|
+
realmBinding(intent.home, intent.realms);
|
|
130
|
+
validateAdvance(intent);
|
|
131
|
+
for (const realm of intent.realms) {
|
|
132
|
+
const old = buildPublicPruneIndex(realm.profile), next = buildPublicPruneIndex(nextProfile(realm, intent));
|
|
133
|
+
guard(old.sourceHash === realm.sourceHash && next.sourceHash === realm.sourceHash && realm.native.every(id => old.targets.includes(id) && next.targets.includes(id)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function checkedIntent(value: unknown, home: string): Intent {
|
|
137
|
+
guard(object(value) && exact(value, ["schema", "home", "generation", "aliases", "previousProfile", "previousShards", "finalProfile", "finalShards", "finalProfileHash", "finalShardsHash", "view", "realms"]));
|
|
138
|
+
guard(value.schema === "agent-mem.public-prune-activation/v1" && value.home === home && hex(value.generation, 32) && hex(value.aliases, 64));
|
|
139
|
+
for (const key of ["previousProfile", "previousShards", "finalProfile", "finalShards"]) guard(typeof value[key] === "string" && Buffer.byteLength(value[key] as string) <= MAX);
|
|
140
|
+
const view = validatePublicPruneView(value.view); guard(view.receivingGeneration === value.generation && Array.isArray(value.realms) && value.realms.length > 0 && value.realms.length <= 1025);
|
|
141
|
+
const final = JSON.parse(value.finalProfile as string), shards = JSON.parse(value.finalShards as string);
|
|
142
|
+
const shardFields = new Set<string>(SHARDED_V2_FIELDS);
|
|
143
|
+
guard(object(shards) && Object.keys(shards).every(key => shardFields.has(key)));
|
|
144
|
+
guard(hex(value.finalProfileHash, 64) && hex(value.finalShardsHash, 64) && hashJSON(final) === value.finalProfileHash && hashJSON(shards) === value.finalShardsHash);
|
|
145
|
+
guard(canonicalJSON(final) + "\n" === value.finalProfile && canonicalJSON(shards) + "\n" === value.finalShards
|
|
146
|
+
&& object(final) && exact(final, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"])
|
|
147
|
+
&& final.schema === "agent-mem.profile/v2" && final.generation === value.generation && final.kind === "generation" && final.foreignPruneFormat === "agent-mem.public-prune-witness/v1" && final.pruneInputSetHash === publicPruneViewHash(view)
|
|
148
|
+
&& shards.schema === "agent-mem.shards/v2" && shards.phase === "active" && shards.generation === value.generation && shards.selectorRevision === hashJSON(final));
|
|
149
|
+
const previousProfile = value.previousProfile === "absent" ? undefined : JSON.parse(value.previousProfile as string), previousShards = JSON.parse(value.previousShards as string);
|
|
150
|
+
guard(previousProfile === undefined || object(previousProfile)
|
|
151
|
+
&& (previousProfile.schema === "agent-mem.profile/v1" && ["shared-v1", "generation"].includes(String(previousProfile.kind))
|
|
152
|
+
&& (previousProfile.kind === "generation" ? hex(previousProfile.generation, 32) : previousProfile.generation === undefined)
|
|
153
|
+
|| previousProfile.schema === "agent-mem.profile/v2" && previousProfile.kind === "generation" && previousProfile.generation === value.generation));
|
|
154
|
+
const { schema: _schema, selectorRevision: _revision, ...before } = previousShards;
|
|
155
|
+
const { schema: _nextSchema, selectorRevision: _nextRevision, ...after } = shards; guard(canonicalJSON(before) === canonicalJSON(after));
|
|
156
|
+
let previous = "";
|
|
157
|
+
for (const item of value.realms) {
|
|
158
|
+
guard(object(item) && exact(item, ["key", "profile", "metadata", "sourceHash", "native"]) && typeof item.key === "string" && item.key > previous && hex(item.metadata, 64) && hex(item.sourceHash, 64)); previous = item.key;
|
|
159
|
+
guard(object(item.profile)); const profile = item.profile;
|
|
160
|
+
guard(Object.keys(profile).every(key => ["kind", "home", "dir", "generation", "selectorRevision", "legacyHome", "legacyMainHome", "sharded", "shardKey", "shardUser", "foreignPruneFormat", "pruneInputSetHash"].includes(key)));
|
|
161
|
+
const key = item.key === "user" ? undefined : item.key.startsWith("project:") ? item.key.slice(8) : fail();
|
|
162
|
+
const dir = key === undefined ? userShardDir(home) : shardDir(home, key);
|
|
163
|
+
guard(profile.kind === "generation" && profile.home === home && profile.dir === dir && profile.generation === value.generation && profile.sharded === true
|
|
164
|
+
&& profile.selectorRevision === (previousProfile === undefined ? "absent" : hashJSON(previousProfile))
|
|
165
|
+
&& profile.legacyHome === join(dir, "legacy") && (profile.legacyMainHome === undefined || profile.legacyMainHome === join(dir, "legacy-main"))
|
|
166
|
+
&& (key === undefined ? profile.shardUser === true && profile.shardKey === undefined : profile.shardKey === key && profile.shardUser === undefined));
|
|
167
|
+
guard(Array.isArray(item.native) && item.native.every(id => hex(id, 32)) && new Set(item.native).size === item.native.length);
|
|
168
|
+
}
|
|
169
|
+
guard(Object.keys(view.realms).every(key => (value.realms as Realm[]).some(realm => realm.key === key)));
|
|
170
|
+
guard(Buffer.byteLength(canonicalJSON(value)) <= MAX); return { ...value, view } as unknown as Intent;
|
|
171
|
+
}
|
|
172
|
+
/** Dry-run reads proof only and defers native/v2 mask building. Apply/resume never roll markers backward. */
|
|
173
|
+
export async function activatePublicPruneView(options: PublicPruneActivationOptions): Promise<PublicPruneActivationResult> {
|
|
174
|
+
const advanced = lstatSync(join(options.memoryDir,PUBLIC_PRUNE_INTENT),{throwIfNoEntry:false});
|
|
175
|
+
if (options.mode === "resume" && advanced?.isFile()) {
|
|
176
|
+
const value=JSON.parse(raw(join(options.memoryDir,PUBLIC_PRUNE_INTENT),false,advanced.nlink));
|
|
177
|
+
if (value?.schema === "agent-mem.public-prune-advance/v1") {
|
|
178
|
+
guard(options.authorizedPerson === true && options.tty === true);
|
|
179
|
+
const {resumePublicPruneAdvance}=await import("./public-prune-advance.ts");
|
|
180
|
+
const result=await resumePublicPruneAdvance(options.memoryDir,options.checkpoint);
|
|
181
|
+
return {mode:options.mode,generation:value.generation,active:true,realms:Object.keys(value.view.realms).length,viewHash:result.viewHash,maskBuildCheck:"verified"};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
guard(options.authorizedPerson === true && options.tty === true && ["dry-run", "apply", "resume"].includes(options.mode));
|
|
186
|
+
const home = realPath(options.memoryDir); owned(home, true); const rootStat = lstatSync(home), intentFile = join(home, PUBLIC_PRUNE_INTENT);
|
|
187
|
+
const pending = lstatSync(intentFile, { throwIfNoEntry: false });
|
|
188
|
+
let intent: Intent;
|
|
189
|
+
if (pending) {
|
|
190
|
+
guard(options.mode === "resume"); const text = raw(intentFile, false, pending.nlink);
|
|
191
|
+
if (pending.nlink === 2) {
|
|
192
|
+
const temp = intentFile + ".pending-" + createHash("sha256").update(text).digest("hex"), pair = owned(temp, false, 2);
|
|
193
|
+
guard(pair.dev === pending.dev && pair.ino === pending.ino);
|
|
194
|
+
} else guard(pending.nlink === 1);
|
|
195
|
+
intent = checkedIntent(JSON.parse(text), home); guard(text === canonicalJSON(intent) + "\n");
|
|
196
|
+
guard(options.view === undefined || canonicalJSON(validatePublicPruneView(options.view)) === canonicalJSON(intent.view));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const selection = readSelector(home), previousProfile = readProfileBytes(home), previousShards = raw(join(home, "shards.json")), old = JSON.parse(previousShards);
|
|
200
|
+
guard(old.phase === "active" && hex(old.generation, 32));
|
|
201
|
+
const generation = old.generation, view = validatePublicPruneView(options.view ?? (selection.selector?.schema === "agent-mem.profile/v2"
|
|
202
|
+
? readPublicPruneViewForActivation(home, selection.selector.pruneInputSetHash, generation)
|
|
203
|
+
: { schema: "agent-mem.public-prune-view/v1", receivingGeneration: generation, realms: {} })); guard(view.receivingGeneration === generation);
|
|
204
|
+
const final = { schema: "agent-mem.profile/v2", kind: "generation", generation, foreignPruneFormat: "agent-mem.public-prune-witness/v1", pruneInputSetHash: publicPruneViewHash(view) };
|
|
205
|
+
const finalShards = { ...old, schema: "agent-mem.shards/v2", selectorRevision: hashJSON(final) };
|
|
206
|
+
const realms = enumerate(home).map(realm => ({ ...realm, metadata: metadata(realm.profile), sourceHash: "0".repeat(64), native: [] as string[] }));
|
|
207
|
+
intent = checkedIntent({ schema: "agent-mem.public-prune-activation/v1", home, generation, aliases: aliases(home), previousProfile, previousShards,
|
|
208
|
+
finalProfile: canonicalJSON(final) + "\n", finalShards: canonicalJSON(finalShards) + "\n", finalProfileHash: hashJSON(final), finalShardsHash: hashJSON(finalShards), view,
|
|
209
|
+
realms }, home);
|
|
210
|
+
}
|
|
211
|
+
validateAdvance(intent);
|
|
212
|
+
if (options.mode === "dry-run") {
|
|
213
|
+
guard(readProfileBytes(home) === intent.previousProfile && raw(join(home, "shards.json")) === intent.previousShards && aliases(home) === intent.aliases);
|
|
214
|
+
realmBinding(home, intent.realms);
|
|
215
|
+
return { mode: options.mode, generation: intent.generation, active: intent.previousProfile === intent.finalProfile && intent.previousShards === intent.finalShards, realms: intent.realms.length, viewHash: publicPruneViewHash(intent.view), maskBuildCheck: "deferred" };
|
|
216
|
+
}
|
|
217
|
+
if (!pending && intent.previousProfile !== "absent" && JSON.parse(intent.previousProfile).schema === "agent-mem.profile/v2") {
|
|
218
|
+
// Only the first reader-floor transition quiesces. Later views preserve native masks and grow foreign masks in place.
|
|
219
|
+
const alreadyActive = intent.previousProfile === intent.finalProfile && intent.previousShards === intent.finalShards;
|
|
220
|
+
if (!alreadyActive) {
|
|
221
|
+
const { advancePublicPruneView } = await import("./public-prune-advance.ts");
|
|
222
|
+
await advancePublicPruneView({ memoryDir: home, view: intent.view, expectedRevision: hashJSON(JSON.parse(intent.previousProfile)), checkpoint: options.checkpoint });
|
|
223
|
+
}
|
|
224
|
+
return { mode: options.mode, generation: intent.generation, active: true, ...(alreadyActive ? {alreadyActive:true} : {}), realms: intent.realms.length, viewHash: publicPruneViewHash(intent.view), maskBuildCheck: "verified" };
|
|
225
|
+
}
|
|
226
|
+
return withProfileLock(home, async () => {
|
|
227
|
+
const releases: (() => void)[] = [];
|
|
228
|
+
try {
|
|
229
|
+
guard(lstatSync(home).dev === rootStat.dev && lstatSync(home).ino === rootStat.ino);
|
|
230
|
+
const dirs = [...new Set([home, ...intent.realms.map(realm => realm.profile.dir)])].sort();
|
|
231
|
+
for (const dir of dirs) { assertNoLiveReaders(dir, {includeShards:true}); const release = acquireMutation(dir); guard(release); releases.push(release); }
|
|
232
|
+
validateAdvance(intent);
|
|
233
|
+
if (pending) immutable(intentFile, canonicalJSON(intent) + "\n");
|
|
234
|
+
if (!pending) {
|
|
235
|
+
guard(readProfileBytes(home) === intent.previousProfile && raw(join(home, "shards.json")) === intent.previousShards && aliases(home) === intent.aliases);
|
|
236
|
+
realmBinding(home, intent.realms);
|
|
237
|
+
intent = checkedIntent({ ...intent, realms: intent.realms.map(realm => {
|
|
238
|
+
const built = buildPublicPruneIndex(realm.profile); return { ...realm, sourceHash: built.sourceHash, native: [...built.targets].sort() };
|
|
239
|
+
}) }, home);
|
|
240
|
+
}
|
|
241
|
+
const viewHash = publicPruneViewHash(intent.view);
|
|
242
|
+
immutable(join(home, "public-prune-input-sets", viewHash + ".json"), canonicalJSON(intent.view) + "\n");
|
|
243
|
+
readPublicPruneViewForActivation(home, viewHash, intent.generation);
|
|
244
|
+
let profile = readProfileBytes(home), shards = raw(join(home, "shards.json"));
|
|
245
|
+
guard((profile === intent.previousProfile || profile === intent.finalProfile) && (shards === intent.previousShards || shards === intent.finalShards));
|
|
246
|
+
guard(profile !== intent.finalProfile || shards === intent.finalShards);
|
|
247
|
+
verify(intent, profile, shards); for (const realm of intent.realms) syncIndex(nextProfile(realm, intent));
|
|
248
|
+
if (!pending && profile === intent.finalProfile && shards === intent.finalShards) return { mode: options.mode, generation: intent.generation, active: true, alreadyActive: true, realms: intent.realms.length, viewHash, maskBuildCheck: "verified" };
|
|
249
|
+
immutable(intentFile, canonicalJSON(intent) + "\n");
|
|
250
|
+
await options.checkpoint?.("after-intent"); verify(intent, profile, shards);
|
|
251
|
+
if (shards !== intent.finalShards) { writeProfileJSON(join(home, "shards.json"), JSON.parse(intent.finalShards)); shards = intent.finalShards; }
|
|
252
|
+
await options.checkpoint?.("after-shards"); verify(intent, profile, shards);
|
|
253
|
+
if (profile !== intent.finalProfile) { writeProfileJSON(join(home, "profile.json"), JSON.parse(intent.finalProfile)); profile = intent.finalProfile; }
|
|
254
|
+
await options.checkpoint?.("after-profile"); verify(intent, profile, shards);
|
|
255
|
+
guard(raw(intentFile) === canonicalJSON(intent) + "\n"); unlinkSync(intentFile); fsyncDirectory(home);
|
|
256
|
+
await options.checkpoint?.("after-remove"); verify(intent, profile, shards);
|
|
257
|
+
return { mode: options.mode, generation: intent.generation, active: true, realms: intent.realms.length, viewHash, maskBuildCheck: "verified" };
|
|
258
|
+
} finally { for (const release of releases.reverse()) release(); }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Already-v2 monotone commit. Native rows stay unchanged; readers apply the proved growing mask on rebind. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants, closeSync, fsyncSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock } from "./generation.ts";
|
|
6
|
+
import { aliasLogPath, shardDir, userShardDir, resolveShardProfile, SHARDED_V2_FIELDS } from "./shard.ts";
|
|
7
|
+
import { acquireMutation, assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
8
|
+
import { createPublicPruneReplayCache, type PublicPruneProjection } from "./public-prune-inputs.ts";
|
|
9
|
+
import { PUBLIC_PRUNE_INTENT, publicPruneViewHash, readPublicPruneViewForActivation, validatePublicPruneView, type PublicPruneViewV1 } from "./public-prune-view.ts";
|
|
10
|
+
import { realPath } from "./platform.ts";
|
|
11
|
+
|
|
12
|
+
export type PublicPruneAdvanceStage = "after-intent" | "after-shards" | "after-profile" | "after-remove";
|
|
13
|
+
export interface PublicPruneAdvanceOptions {
|
|
14
|
+
memoryDir: string; view: PublicPruneViewV1; expectedRevision: string; mutations?: readonly MutationHandle[];
|
|
15
|
+
checkpoint?: (stage: PublicPruneAdvanceStage) => void | Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface PublicPruneAdvanceResult { revision: string; viewHash: string }
|
|
18
|
+
interface Binding { key: string; dir: string; dev: number; ino: number; metadata: string }
|
|
19
|
+
interface AdvanceIntent {
|
|
20
|
+
schema: "agent-mem.public-prune-advance/v1"; home: string; generation: string; expectedRevision: string;
|
|
21
|
+
previousProfile: string; previousShards: string; finalProfile: string; finalShards: string; finalProfileHash: string; finalShardsHash: string;
|
|
22
|
+
view: PublicPruneViewV1; bindings: Binding[]; root: { dev: number; ino: number; aliasesHash: string; machineHash: string };
|
|
23
|
+
}
|
|
24
|
+
const MAX = 1024 * 1024;
|
|
25
|
+
const fail = (): never => { throw new Error("public prune advance unavailable; resume the pending view operation"); };
|
|
26
|
+
function guard(ok: unknown): asserts ok { if (!ok) fail(); }
|
|
27
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
28
|
+
const exact = (v: Record<string, unknown>, keys: string[]) => Object.keys(v).length === keys.length && keys.every(key => Object.hasOwn(v,key));
|
|
29
|
+
const hex = (v: unknown, size: number): v is string => typeof v === "string" && v.length === size && /^[a-f0-9]+$/.test(v);
|
|
30
|
+
function owned(file: string, directory = false, links = 1) {
|
|
31
|
+
const stat = lstatSync(file); guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links)
|
|
32
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0); return stat;
|
|
33
|
+
}
|
|
34
|
+
function raw(file: string, links = 1): string {
|
|
35
|
+
const before = owned(file,false,links); guard(before.size > 0 && before.size <= MAX);
|
|
36
|
+
const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
const opened = fstatSync(fd), bytes = readFileSync(fd), afterFd = fstatSync(fd), after = owned(file,false,links);
|
|
39
|
+
guard(opened.isFile() && opened.nlink === links && opened.uid === before.uid && (opened.mode & 0o077) === 0
|
|
40
|
+
&& afterFd.ino === opened.ino && afterFd.dev === opened.dev && afterFd.size === opened.size && afterFd.mtimeMs === opened.mtimeMs && afterFd.ctimeMs === opened.ctimeMs
|
|
41
|
+
&& opened.ino === before.ino && opened.dev === before.dev && after.ino === before.ino && after.dev === before.dev
|
|
42
|
+
&& bytes.length === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs);
|
|
43
|
+
return new TextDecoder("utf-8",{fatal:true}).decode(bytes);
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
const optional = (file: string) => lstatSync(file,{throwIfNoEntry:false}) ? {exists:true,bytes:raw(file)} : {exists:false};
|
|
47
|
+
function metadata(dir: string): string {
|
|
48
|
+
return hashJSON(["project.json","generation.json","generation-seal.json","migration-sources.json","machine.json"].map(name=>[name,optional(join(dir,name))]));
|
|
49
|
+
}
|
|
50
|
+
function realmDir(home: string, key: string): string { return key === "user" ? userShardDir(home) : key.startsWith("project:") ? shardDir(home,key.slice(8)) : fail(); }
|
|
51
|
+
function immutable(file: string, bytes: string): void {
|
|
52
|
+
const base = dirname(file); if (!lstatSync(base,{throwIfNoEntry:false})) {mkdirSync(base,{mode:0o700});fsyncDirectory(dirname(base));} owned(base,true);
|
|
53
|
+
const temp = file+".pending-"+createHash("sha256").update(bytes).digest("hex");
|
|
54
|
+
if (lstatSync(file,{throwIfNoEntry:false})) {
|
|
55
|
+
const stat = lstatSync(file);
|
|
56
|
+
if (stat.nlink === 2) { const pending = owned(temp,false,2); owned(file,false,2); guard(pending.ino === stat.ino && pending.dev === stat.dev && raw(file,2) === bytes); unlinkSync(temp);fsyncDirectory(base); }
|
|
57
|
+
guard(raw(file) === bytes);return;
|
|
58
|
+
}
|
|
59
|
+
if (lstatSync(temp,{throwIfNoEntry:false})) { const staged=owned(temp); guard(staged.size<=MAX); if (!readFileSync(temp).equals(Buffer.from(bytes))) {unlinkSync(temp);fsyncDirectory(base);} }
|
|
60
|
+
if (!lstatSync(temp,{throwIfNoEntry:false})) {const fd=openSync(temp,"wx",0o600);try{writeFileSync(fd,bytes);fsyncSync(fd);}finally{closeSync(fd);}}
|
|
61
|
+
const stagedFd=openSync(temp,constants.O_RDONLY|constants.O_NOFOLLOW);try{fsyncSync(stagedFd);}finally{closeSync(stagedFd);}
|
|
62
|
+
linkSync(temp,file);fsyncDirectory(base);unlinkSync(temp);fsyncDirectory(base);
|
|
63
|
+
}
|
|
64
|
+
function replace(file: string, text: string): void {
|
|
65
|
+
const temp=file+".advance.tmp";
|
|
66
|
+
if (lstatSync(temp,{throwIfNoEntry:false})) {owned(temp);unlinkSync(temp);}
|
|
67
|
+
const fd=openSync(temp,"wx",0o600);try{writeFileSync(fd,text);fsyncSync(fd);}finally{closeSync(fd);}
|
|
68
|
+
// Same exact bytes recorded in the durable intent, not a reparsed/re-authored payload.
|
|
69
|
+
renameSync(temp,file);fsyncDirectory(dirname(file));
|
|
70
|
+
}
|
|
71
|
+
function checked(value: unknown, home: string): AdvanceIntent {
|
|
72
|
+
guard(object(value) && exact(value,["schema","home","generation","expectedRevision","previousProfile","previousShards","finalProfile","finalShards","finalProfileHash","finalShardsHash","view","bindings","root"]));
|
|
73
|
+
guard(value.schema === "agent-mem.public-prune-advance/v1" && value.home === home && hex(value.generation,32) && hex(value.expectedRevision,64));
|
|
74
|
+
for(const key of ["previousProfile","previousShards","finalProfile","finalShards"])guard(typeof value[key] === "string");
|
|
75
|
+
const previous=JSON.parse(value.previousProfile as string), oldShards=JSON.parse(value.previousShards as string), final=JSON.parse(value.finalProfile as string), shards=JSON.parse(value.finalShards as string), view=validatePublicPruneView(value.view);
|
|
76
|
+
guard(object(previous) && exact(previous,["schema","kind","generation","foreignPruneFormat","pruneInputSetHash"]) && previous.kind === "generation" && hex(previous.pruneInputSetHash,64) && previous.schema === "agent-mem.profile/v2" && previous.generation === value.generation && previous.foreignPruneFormat === "agent-mem.public-prune-witness/v1" && hashJSON(previous) === value.expectedRevision);
|
|
77
|
+
guard(object(final) && exact(final,["schema","kind","generation","foreignPruneFormat","pruneInputSetHash"]) && final.schema === previous.schema && final.kind === "generation" && final.generation === value.generation && final.foreignPruneFormat === previous.foreignPruneFormat && final.pruneInputSetHash === publicPruneViewHash(view));
|
|
78
|
+
guard(view.receivingGeneration === value.generation && canonicalJSON(final)+"\n" === value.finalProfile && hashJSON(final) === value.finalProfileHash && hashJSON(shards) === value.finalShardsHash && canonicalJSON(shards)+"\n" === value.finalShards);
|
|
79
|
+
guard(object(oldShards) && object(shards) && Object.keys(oldShards).every(key=>(SHARDED_V2_FIELDS as readonly string[]).includes(key)) && Object.keys(shards).every(key=>(SHARDED_V2_FIELDS as readonly string[]).includes(key)) && oldShards.phase === "active" && oldShards.generation === value.generation && oldShards.schema === "agent-mem.shards/v2" && oldShards.selectorRevision === value.expectedRevision && shards.selectorRevision === value.finalProfileHash);
|
|
80
|
+
const {selectorRevision:_old,...a}=oldShards,{selectorRevision:_new,...b}=shards;guard(canonicalJSON(a)===canonicalJSON(b));
|
|
81
|
+
guard(Array.isArray(value.bindings) && value.bindings.length <= 1024 && object(value.root) && exact(value.root,["dev","ino","aliasesHash","machineHash"]) && hex(value.root.aliasesHash,64) && hex(value.root.machineHash,64));
|
|
82
|
+
let prior="";for(const item of value.bindings){guard(object(item)&&exact(item,["key","dir","dev","ino","metadata"])&&typeof item.key==="string"&&item.key>prior&&item.dir===realmDir(home,item.key)&&hex(item.metadata,64));prior=item.key;}
|
|
83
|
+
guard(Buffer.byteLength(canonicalJSON(value))<=MAX);return {...value,view} as unknown as AdvanceIntent;
|
|
84
|
+
}
|
|
85
|
+
function proofs(intent: AdvanceIntent): (()=>void)[] {
|
|
86
|
+
const previous=JSON.parse(intent.previousProfile),old=readPublicPruneViewForActivation(intent.home,previous.pruneInputSetHash,intent.generation);
|
|
87
|
+
guard(Object.keys(old.realms).every(key=>Object.hasOwn(intent.view.realms,key)));
|
|
88
|
+
const changed=Object.keys(intent.view.realms).filter(key=>old.realms[key]!==intent.view.realms[key]);
|
|
89
|
+
guard(intent.bindings.length===changed.length&&intent.bindings.every(item=>changed.includes(item.key)));
|
|
90
|
+
const checks:(()=>void)[]=[];
|
|
91
|
+
for(const [key,set] of Object.entries(intent.view.realms)){
|
|
92
|
+
if(old.realms[key]===set)continue;
|
|
93
|
+
const binding=intent.bindings.find(item=>item.key===key);guard(binding);
|
|
94
|
+
const load=createPublicPruneReplayCache({dir:binding.dir,realm:key==="user"?{scope:"user"}:{scope:"project",key:key.slice(8)},receivingGeneration:intent.generation,inputSetHash:set});
|
|
95
|
+
const check=()=>{const proof:PublicPruneProjection=load();guard(old.realms[key]===undefined||proof.commitments.includes(old.realms[key]!));};check();checks.push(check);
|
|
96
|
+
}
|
|
97
|
+
return checks;
|
|
98
|
+
}
|
|
99
|
+
function run(intent: AdvanceIntent, provided: readonly MutationHandle[]|undefined, checkpoint?: PublicPruneAdvanceOptions["checkpoint"]): Promise<PublicPruneAdvanceResult> {
|
|
100
|
+
const checks=proofs(intent), releases:MutationHandle[]=[];
|
|
101
|
+
const work=()=>{
|
|
102
|
+
const dirs=[intent.home,...intent.bindings.map(item=>item.dir)].sort();
|
|
103
|
+
const handles=provided??dirs.map(dir=>{const lease=acquireMutation(dir);guard(lease);releases.push(lease);return lease;});
|
|
104
|
+
const verify=()=>{
|
|
105
|
+
const root=owned(intent.home,true);guard(root.dev===intent.root.dev&&root.ino===intent.root.ino&&hashJSON(optional(aliasLogPath(intent.home)))===intent.root.aliasesHash&&hashJSON(raw(join(intent.home,"machine.json")))===intent.root.machineHash);
|
|
106
|
+
for(const dir of dirs){let found=false;for(const handle of handles){try{assertMutationHandle(handle,dir);found=true;break;}catch{}}guard(found);}
|
|
107
|
+
for(const item of intent.bindings){const stat=owned(item.dir,true);guard(stat.dev===item.dev&&stat.ino===item.ino&&metadata(item.dir)===item.metadata);}
|
|
108
|
+
for(const check of checks)check();
|
|
109
|
+
};
|
|
110
|
+
const stages=function*():Generator<PublicPruneAdvanceStage,PublicPruneAdvanceResult>{
|
|
111
|
+
verify();let profile=raw(join(intent.home,"profile.json")),shards=raw(join(intent.home,"shards.json"));
|
|
112
|
+
guard((profile===intent.previousProfile||profile===intent.finalProfile)&&(shards===intent.previousShards||shards===intent.finalShards)&&!(profile===intent.finalProfile&&shards!==intent.finalShards));
|
|
113
|
+
const viewHash=publicPruneViewHash(intent.view),file=join(intent.home,PUBLIC_PRUNE_INTENT),text=canonicalJSON(intent)+"\n";
|
|
114
|
+
if(profile===intent.finalProfile&&shards===intent.finalShards&&!lstatSync(file,{throwIfNoEntry:false}))return{revision:intent.finalProfileHash,viewHash};
|
|
115
|
+
immutable(join(intent.home,"public-prune-input-sets",viewHash+".json"),canonicalJSON(intent.view)+"\n");
|
|
116
|
+
immutable(file,text);yield "after-intent";verify();guard(raw(file)===text&&raw(join(intent.home,"profile.json"))===profile&&raw(join(intent.home,"shards.json"))===shards);
|
|
117
|
+
if(shards!==intent.finalShards)replace(join(intent.home,"shards.json"),intent.finalShards);shards=intent.finalShards;
|
|
118
|
+
yield "after-shards";verify();guard(raw(file)===text&&raw(join(intent.home,"profile.json"))===profile&&raw(join(intent.home,"shards.json"))===shards);
|
|
119
|
+
if(profile!==intent.finalProfile)replace(join(intent.home,"profile.json"),intent.finalProfile);profile=intent.finalProfile;
|
|
120
|
+
yield "after-profile";verify();guard(raw(file)===text&&raw(join(intent.home,"profile.json"))===profile&&raw(join(intent.home,"shards.json"))===shards);
|
|
121
|
+
unlinkSync(file);fsyncDirectory(intent.home);yield "after-remove";verify();
|
|
122
|
+
return{revision:intent.finalProfileHash,viewHash};
|
|
123
|
+
};
|
|
124
|
+
if(checkpoint)return(async()=>{const iterator=stages();for(;;){const next=iterator.next();if(next.done)return next.value;await checkpoint(next.value);}})();
|
|
125
|
+
const iterator=stages();for(;;){const next=iterator.next();if(next.done)return next.value;}
|
|
126
|
+
};
|
|
127
|
+
return withProfileLock(intent.home,work).finally(()=>{for(const release of releases.reverse())release();});
|
|
128
|
+
}
|
|
129
|
+
export async function advancePublicPruneView(options: PublicPruneAdvanceOptions):Promise<PublicPruneAdvanceResult>{
|
|
130
|
+
const home=realPath(options.memoryDir),root=owned(home,true),selection=readSelector(home),profile=selection.selector;
|
|
131
|
+
guard(profile?.schema==="agent-mem.profile/v2"&&selection.revision===options.expectedRevision);
|
|
132
|
+
const view=validatePublicPruneView(options.view);guard(view.receivingGeneration===profile.generation);
|
|
133
|
+
const previousProfile=raw(join(home,"profile.json")),previousShards=raw(join(home,"shards.json")),old=readPublicPruneViewForActivation(home,profile.pruneInputSetHash,profile.generation);
|
|
134
|
+
const bindings:Binding[]=[];
|
|
135
|
+
for(const key of Object.keys(view.realms).sort())if(old.realms[key]!==view.realms[key]){
|
|
136
|
+
const bound=resolveShardProfile(home,key==="user"?{user:true}:{project:key.slice(8)}),stat=owned(bound.dir,true);
|
|
137
|
+
bindings.push({key,dir:bound.dir,dev:stat.dev,ino:stat.ino,metadata:metadata(bound.dir)});
|
|
138
|
+
}
|
|
139
|
+
const final={...profile,pruneInputSetHash:publicPruneViewHash(view)},shards={...JSON.parse(previousShards),selectorRevision:hashJSON(final)};
|
|
140
|
+
const intent=checked({schema:"agent-mem.public-prune-advance/v1",home,generation:profile.generation,expectedRevision:options.expectedRevision,previousProfile,previousShards,
|
|
141
|
+
finalProfile:canonicalJSON(final)+"\n",finalShards:canonicalJSON(shards)+"\n",finalProfileHash:hashJSON(final),finalShardsHash:hashJSON(shards),view,bindings,
|
|
142
|
+
root:{dev:root.dev,ino:root.ino,aliasesHash:hashJSON(optional(aliasLogPath(home))),machineHash:hashJSON(raw(join(home,"machine.json")))}},home);
|
|
143
|
+
return run(intent,options.mutations,options.checkpoint);
|
|
144
|
+
}
|
|
145
|
+
/** Explicit forward recovery only; ordinary reads never call this operation. */
|
|
146
|
+
export async function resumePublicPruneAdvance(memoryDir:string,checkpoint?:PublicPruneAdvanceOptions["checkpoint"]):Promise<PublicPruneAdvanceResult>{
|
|
147
|
+
const home=realPath(memoryDir),file=join(home,PUBLIC_PRUNE_INTENT),stat=lstatSync(file),text=raw(file,stat.nlink);
|
|
148
|
+
const intent=checked(JSON.parse(text),home);guard(text===canonicalJSON(intent)+"\n");return run(intent,undefined,checkpoint);
|
|
149
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Metadata-only input-set ancestry for the generation reader's cold path. Never loads proof bodies. */
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
5
|
+
import { realPath } from "./platform.ts";
|
|
6
|
+
|
|
7
|
+
export const PUBLIC_PRUNE_INPUT_MAX_BYTES = 65_536;
|
|
8
|
+
export const PUBLIC_PRUNE_REPLAY_MAX_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
export const PUBLIC_PRUNE_MAX_SETS = 4096;
|
|
10
|
+
export interface PublicPruneInputSetV1 {
|
|
11
|
+
schema: "agent-mem.public-prune-input-set/v1"; receivingGeneration: string; previous: string | null; inputs: string[];
|
|
12
|
+
}
|
|
13
|
+
export class PublicPruneInputError extends Error {
|
|
14
|
+
constructor(readonly code: "unavailable" | "resource" | "binding" = "unavailable") {
|
|
15
|
+
super(`public prune input ${code}`); this.name = "PublicPruneInputError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function guard(ok: unknown, resource = false): asserts ok { if (!ok) throw new PublicPruneInputError(resource ? "resource" : "unavailable"); }
|
|
19
|
+
function protectedCall<T>(work: () => T): T {
|
|
20
|
+
try { return work(); } catch (error) { if (error instanceof PublicPruneInputError) throw error; throw new PublicPruneInputError(); }
|
|
21
|
+
}
|
|
22
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
|
|
24
|
+
export function validatePublicPruneInputSet(v: unknown): PublicPruneInputSetV1 {
|
|
25
|
+
return protectedCall(() => {
|
|
26
|
+
guard(object(v) && Object.keys(v).length === 4 && ["schema", "receivingGeneration", "previous", "inputs"].every(key => Object.hasOwn(v, key)));
|
|
27
|
+
guard(Buffer.byteLength(canonicalJSON(v)) <= PUBLIC_PRUNE_INPUT_MAX_BYTES, true);
|
|
28
|
+
guard(v.schema === "agent-mem.public-prune-input-set/v1" && hex(v.receivingGeneration, 32) && (v.previous === null || hex(v.previous, 64)) && Array.isArray(v.inputs));
|
|
29
|
+
let previous = "";
|
|
30
|
+
for (const id of v.inputs) { guard(hex(id, 64) && id > previous); previous = id; }
|
|
31
|
+
return { schema: v.schema, receivingGeneration: v.receivingGeneration, previous: v.previous, inputs: [...v.inputs] as string[] };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function owned(stat: BigIntStats, directory = false): void {
|
|
35
|
+
guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1n)
|
|
36
|
+
&& stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o077n) === 0n);
|
|
37
|
+
}
|
|
38
|
+
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
39
|
+
/** Complete bounded chain, closed canonical metadata, exact hashes and monotone membership. */
|
|
40
|
+
export function readPublicPruneInputSetChain(input: { dir: string; receivingGeneration: string; inputSetHash: string }): { commitments: string[]; inputs: string[] } {
|
|
41
|
+
return protectedCall(() => {
|
|
42
|
+
guard(hex(input.receivingGeneration, 32) && hex(input.inputSetHash, 64));
|
|
43
|
+
const original = lstatSync(input.dir, { bigint: true }); owned(original, true);
|
|
44
|
+
const dir = realPath(input.dir), root = lstatSync(dir, { bigint: true }); owned(root, true); guard(root.dev === original.dev && root.ino === original.ino);
|
|
45
|
+
const folder = join(dir, "public-prune-input-sets"), parent = lstatSync(folder, { bigint: true }); owned(parent, true);
|
|
46
|
+
const frontier = new Map<string, BigIntStats>(), commitments: string[] = [], seen = new Set<string>();
|
|
47
|
+
let id: string | null = input.inputSetHash, later: Set<string> | undefined, inputs: string[] = [], total = 0;
|
|
48
|
+
while (id !== null) {
|
|
49
|
+
guard(!seen.has(id)); guard(seen.size < PUBLIC_PRUNE_MAX_SETS, true); seen.add(id); commitments.push(id);
|
|
50
|
+
const file = join(folder, id + ".json"), before = lstatSync(file, { bigint: true }); owned(before);
|
|
51
|
+
guard(before.size > 0n && before.size <= BigInt(PUBLIC_PRUNE_INPUT_MAX_BYTES), true);
|
|
52
|
+
total += Number(before.size); guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, true);
|
|
53
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let bytes: Buffer;
|
|
54
|
+
try {
|
|
55
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened); guard(same(before, opened));
|
|
56
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let count = 0;
|
|
57
|
+
while (count < data.length) { const n = readSync(fd, data, count, data.length - count, count); if (!n) break; count += n; }
|
|
58
|
+
const after = fstatSync(fd, { bigint: true }), named = lstatSync(file, { bigint: true }); owned(after); owned(named);
|
|
59
|
+
guard(count === Number(before.size) && same(before, after) && same(before, named)); bytes = data.subarray(0, count); frontier.set(file, before);
|
|
60
|
+
} finally { closeSync(fd); }
|
|
61
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), raw: unknown = JSON.parse(text), canonical = canonicalJSON(raw);
|
|
62
|
+
guard(text === canonical || text === canonical + "\n");
|
|
63
|
+
const set = validatePublicPruneInputSet(raw); guard(hashJSON(set) === id && set.receivingGeneration === input.receivingGeneration);
|
|
64
|
+
if (later) guard(set.inputs.every(member => later!.has(member))); else inputs = set.inputs;
|
|
65
|
+
later = new Set(set.inputs); id = set.previous;
|
|
66
|
+
}
|
|
67
|
+
for (const [file, before] of frontier) { const now = lstatSync(file, { bigint: true }); owned(now); guard(same(before, now)); }
|
|
68
|
+
for (const [file, before] of [[input.dir, original], [dir, root], [folder, parent]] as const) {
|
|
69
|
+
const now = lstatSync(file, { bigint: true }); owned(now, true); guard(now.dev === before.dev && now.ino === before.ino);
|
|
70
|
+
}
|
|
71
|
+
return { commitments, inputs: [...inputs] };
|
|
72
|
+
});
|
|
73
|
+
}
|