@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,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
|
+
}
|