@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,168 @@
|
|
|
1
|
+
/** Layer 1 only: pure complete public-pack proof, never publication or an active mask. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { types } from "node:util";
|
|
4
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
5
|
+
import { reducePruneRows, validatePruneRow, pruneRowHash, type PruneRow, type PruneSourceRow } from "./prune.ts";
|
|
6
|
+
import { decodeShardPack, SHARD_PACK_MAX_BYTES, type ShardSyncRealm } from "./shard-sync-format.ts";
|
|
7
|
+
|
|
8
|
+
export const PUBLIC_PRUNE_MANIFEST_MAX_BYTES = 65_536;
|
|
9
|
+
export const PUBLIC_PRUNE_MAX_WITNESSES = 64, PUBLIC_PRUNE_MAX_PACKS = 65;
|
|
10
|
+
export type PublicPruneLineage = { kind: "native-generation"; seal: string } | { kind: "post-seal"; seal: string; end: number };
|
|
11
|
+
interface Occurrence { pack: string; blob: string }
|
|
12
|
+
export interface PublicPruneWitnessV1 {
|
|
13
|
+
schema: "agent-mem.public-prune-witness/v1";
|
|
14
|
+
realm: ShardSyncRealm;
|
|
15
|
+
prune: PruneRow;
|
|
16
|
+
lineage: PublicPruneLineage;
|
|
17
|
+
pruneOccurrence: Occurrence;
|
|
18
|
+
witnesses: (Occurrence & { id: string; rowHash: string })[];
|
|
19
|
+
}
|
|
20
|
+
/** Supplied only from the owned, audited selected-realm source path (Layer 4).
|
|
21
|
+
* Raw bytes prove content, not private-remote consent or authenticity. A flag,
|
|
22
|
+
* outgoing row selection or old hold receipt cannot substitute for these packs.
|
|
23
|
+
*/
|
|
24
|
+
export interface PublicPruneEvidence {
|
|
25
|
+
realm: ShardSyncRealm;
|
|
26
|
+
packs: readonly (Occurrence & { bytes: Uint8Array })[];
|
|
27
|
+
}
|
|
28
|
+
export interface VerifiedPublicPruneWitness {
|
|
29
|
+
status: "verified"; manifest: PublicPruneWitnessV1; hash: string;
|
|
30
|
+
}
|
|
31
|
+
export type PublicPruneVerification = VerifiedPublicPruneWitness | { status: "invalid" }
|
|
32
|
+
| { status: "unsupported_lineage"; reason: "lineage_missing" | "lineage_unsupported" };
|
|
33
|
+
type Candidate = Omit<PublicPruneWitnessV1, "lineage"> & { lineage?: unknown };
|
|
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[]): boolean => Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
|
|
36
|
+
const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
|
|
37
|
+
const bad = (): never => { throw new Error("public prune witness invalid or unavailable"); };
|
|
38
|
+
function guard(ok: unknown): asserts ok { if (!ok) bad(); }
|
|
39
|
+
function realm(input: unknown): ShardSyncRealm {
|
|
40
|
+
guard(object(input));
|
|
41
|
+
if (input.scope === "user") { guard(exact(input, ["scope"])); return { scope: "user" }; }
|
|
42
|
+
guard(input.scope === "project" && exact(input, ["scope", "key"]) && typeof input.key === "string" && input.key.length > 0);
|
|
43
|
+
guard(Buffer.byteLength(input.key, "utf8") <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
|
|
44
|
+
return { scope: "project", key: input.key };
|
|
45
|
+
}
|
|
46
|
+
function occurrence(input: unknown, witness = false): Occurrence {
|
|
47
|
+
guard(object(input) && exact(input, witness ? ["pack", "blob", "id", "rowHash"] : ["pack", "blob"]));
|
|
48
|
+
guard(typeof input.pack === "string" && input.pack.length === 140 && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.test(input.pack) && hex(input.blob, 40));
|
|
49
|
+
return { pack: input.pack, blob: input.blob };
|
|
50
|
+
}
|
|
51
|
+
/** Check the portable JSON shape and exact encoded size before cloning unknown lineage.
|
|
52
|
+
* Iterative traversal bounds cycles, giant scalars and shared subtrees without a new depth ceiling. */
|
|
53
|
+
function boundLineage(input: unknown, budget: number): void {
|
|
54
|
+
guard(budget >= 0);
|
|
55
|
+
const active = new WeakSet<object>(), stack: { value: unknown; exit?: boolean }[] = [{ value: input }];
|
|
56
|
+
const spend = (bytes: number) => { budget -= bytes; guard(budget >= 0); };
|
|
57
|
+
const stringBytes = (value: string) => { guard(value.length <= budget); return Buffer.byteLength(JSON.stringify(value), "utf8"); };
|
|
58
|
+
while (stack.length) {
|
|
59
|
+
const frame = stack.pop()!, value = frame.value;
|
|
60
|
+
if (frame.exit) { active.delete(value as object); continue; }
|
|
61
|
+
if (typeof value === "string") { spend(stringBytes(value)); continue; }
|
|
62
|
+
if (value === null || typeof value === "boolean") { spend(value === null || value === true ? 4 : 5); continue; }
|
|
63
|
+
if (typeof value === "number") { guard(Number.isFinite(value)); spend(JSON.stringify(value).length); continue; }
|
|
64
|
+
guard(value && typeof value === "object" && !types.isProxy(value) && !active.has(value));
|
|
65
|
+
const array = Array.isArray(value), prototype = Object.getPrototypeOf(value);
|
|
66
|
+
guard(array ? prototype === Array.prototype : prototype === Object.prototype || prototype === null);
|
|
67
|
+
active.add(value); stack.push({ value, exit: true }); spend(2);
|
|
68
|
+
let count = 0;
|
|
69
|
+
if (array) { guard(value.length <= budget); if (value.length) spend(value.length - 1); }
|
|
70
|
+
for (const key in value) {
|
|
71
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
72
|
+
const field = Object.getOwnPropertyDescriptor(value, key)!;
|
|
73
|
+
guard("value" in field && field.enumerable);
|
|
74
|
+
if (array) { const index = Number(key); guard(Number.isInteger(index) && index >= 0 && index < value.length && String(index) === key); }
|
|
75
|
+
else spend(stringBytes(key) + 1 + (count ? 1 : 0));
|
|
76
|
+
count++; stack.push({ value: field.value });
|
|
77
|
+
}
|
|
78
|
+
if (array) guard(count === value.length); // JSON arrays have no holes or extra enumerable fields.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function candidate(input: unknown): Candidate {
|
|
82
|
+
guard(object(input));
|
|
83
|
+
guard(exact(input, ["schema", "realm", "prune", "pruneOccurrence", "witnesses", ...(Object.hasOwn(input, "lineage") ? ["lineage"] : [])]));
|
|
84
|
+
guard(input.schema === "agent-mem.public-prune-witness/v1" && Array.isArray(input.witnesses) && input.witnesses.length <= PUBLIC_PRUNE_MAX_WITNESSES);
|
|
85
|
+
guard(object(input.prune) && Array.isArray(input.prune.targets) && input.prune.targets.length <= 32);
|
|
86
|
+
const prune = validatePruneRow(input.prune, undefined, "synced"), selected = realm(input.realm);
|
|
87
|
+
guard(hex(prune.generation, 32) && hex(prune.machine, 16));
|
|
88
|
+
const needed = new Map<string, string>();
|
|
89
|
+
for (const target of prune.targets) for (const ref of [target, target.basis]) {
|
|
90
|
+
guard(!needed.has(ref.id) || needed.get(ref.id) === ref.rowHash); needed.set(ref.id, ref.rowHash);
|
|
91
|
+
}
|
|
92
|
+
let previous = "";
|
|
93
|
+
const witnesses = input.witnesses.map(value => {
|
|
94
|
+
const where = occurrence(value, true); guard(object(value) && hex(value.id, 32) && hex(value.rowHash, 64));
|
|
95
|
+
guard(value.id > previous && needed.get(value.id) === value.rowHash); previous = value.id;
|
|
96
|
+
return { ...where, id: value.id, rowHash: value.rowHash };
|
|
97
|
+
});
|
|
98
|
+
guard(witnesses.length === needed.size);
|
|
99
|
+
const result: Candidate = { schema: input.schema, realm: selected, prune, pruneOccurrence: occurrence(input.pruneOccurrence), witnesses };
|
|
100
|
+
const baseBytes = Buffer.byteLength(canonicalJSON(result), "utf8");
|
|
101
|
+
guard(baseBytes <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
|
|
102
|
+
const origin = Object.getOwnPropertyDescriptor(input, "lineage");
|
|
103
|
+
if (origin) {
|
|
104
|
+
guard("value" in origin);
|
|
105
|
+
const value: unknown = origin.value;
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
boundLineage(value, PUBLIC_PRUNE_MANIFEST_MAX_BYTES - baseBytes - Buffer.byteLength(',"lineage":'));
|
|
108
|
+
result.lineage = structuredClone(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
guard(Buffer.byteLength(canonicalJSON(result), "utf8") <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
function lineage(input: unknown): PublicPruneLineage | undefined {
|
|
115
|
+
if (!object(input) || !["native-generation", "post-seal"].includes(String(input.kind))) return undefined;
|
|
116
|
+
guard(exact(input, input.kind === "native-generation" ? ["kind", "seal"] : ["kind", "seal", "end"]) && hex(input.seal, 64));
|
|
117
|
+
if (input.kind === "native-generation") return { kind: input.kind, seal: input.seal };
|
|
118
|
+
guard(Number.isSafeInteger(input.end) && (input.end as number) >= 0);
|
|
119
|
+
return { kind: "post-seal", seal: input.seal, end: input.end as number };
|
|
120
|
+
}
|
|
121
|
+
export function validatePublicPruneWitness(input: unknown): PublicPruneWitnessV1 {
|
|
122
|
+
try { const value = candidate(input), origin = lineage(value.lineage); guard(origin); return { ...value, lineage: origin }; }
|
|
123
|
+
catch { return bad(); }
|
|
124
|
+
}
|
|
125
|
+
export function publicPruneWitnessHash(input: PublicPruneWitnessV1): string { return hashJSON(validatePublicPruneWitness(input)); }
|
|
126
|
+
|
|
127
|
+
export function verifyPublicPruneWitness(input: unknown, evidence: PublicPruneEvidence): PublicPruneVerification {
|
|
128
|
+
try {
|
|
129
|
+
const value = candidate(input);
|
|
130
|
+
guard(object(evidence) && exact(evidence, ["realm", "packs"]) && canonicalJSON(realm(evidence.realm)) === canonicalJSON(value.realm));
|
|
131
|
+
guard(Array.isArray(evidence.packs) && evidence.packs.length <= PUBLIC_PRUNE_MAX_PACKS);
|
|
132
|
+
const required = new Map<string, string>();
|
|
133
|
+
for (const ref of [value.pruneOccurrence, ...value.witnesses]) {
|
|
134
|
+
guard(!required.has(ref.pack) || required.get(ref.pack) === ref.blob); required.set(ref.pack, ref.blob);
|
|
135
|
+
}
|
|
136
|
+
guard(evidence.packs.length === required.size);
|
|
137
|
+
const packs = new Map<string, Map<string, PruneRow | PruneSourceRow["row"]>>(), seen = new Map<string, string>();
|
|
138
|
+
for (const pack of evidence.packs) {
|
|
139
|
+
guard(object(pack) && exact(pack, ["pack", "blob", "bytes"]) && typeof pack.pack === "string" && typeof pack.blob === "string");
|
|
140
|
+
guard(required.get(pack.pack) === pack.blob && !packs.has(pack.pack));
|
|
141
|
+
guard(pack.bytes instanceof Uint8Array && pack.bytes.byteLength > 0 && pack.bytes.byteLength <= SHARD_PACK_MAX_BYTES);
|
|
142
|
+
const bytes = Buffer.from(pack.bytes);
|
|
143
|
+
const blob = createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
144
|
+
guard(blob === pack.blob);
|
|
145
|
+
const rows = decodeShardPack(pack.pack, bytes, value.realm, value.prune.generation);
|
|
146
|
+
const contents = new Map<string, PruneRow | PruneSourceRow["row"]>();
|
|
147
|
+
for (let i = 0; i < rows.length; i++) {
|
|
148
|
+
const row = rows[i]!, canonical = canonicalJSON(row);
|
|
149
|
+
guard(!seen.has(row.id) || seen.get(row.id) === canonical);
|
|
150
|
+
seen.set(row.id, canonical); contents.set(row.id, row);
|
|
151
|
+
}
|
|
152
|
+
packs.set(pack.pack, contents);
|
|
153
|
+
}
|
|
154
|
+
guard(canonicalJSON(packs.get(value.pruneOccurrence.pack)?.get(value.prune.id)) === canonicalJSON(value.prune));
|
|
155
|
+
const sources: PruneSourceRow[] = [];
|
|
156
|
+
for (const witness of value.witnesses) {
|
|
157
|
+
const row = packs.get(witness.pack)?.get(witness.id);
|
|
158
|
+
guard(row && row.op !== "prune" && pruneRowHash(row) === witness.rowHash);
|
|
159
|
+
sources.push({ row, placement: "synced", publicWitness: true }); // only after complete public pack proof
|
|
160
|
+
}
|
|
161
|
+
const proof = reducePruneRows([{ row: value.prune, placement: "synced" }], { rows: sources, expiries: [] }, value.prune.generation);
|
|
162
|
+
guard(proof.invalid === 0 && proof.pending.length === 0 && proof.targets.size === value.prune.targets.length);
|
|
163
|
+
const origin = lineage(value.lineage);
|
|
164
|
+
if (!origin) return { status: "unsupported_lineage", reason: value.lineage === undefined ? "lineage_missing" : "lineage_unsupported" };
|
|
165
|
+
const manifest: PublicPruneWitnessV1 = { ...value, lineage: origin };
|
|
166
|
+
return { status: "verified", manifest, hash: hashJSON(manifest) };
|
|
167
|
+
} catch { return { status: "invalid" }; }
|
|
168
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readProfileJSON, writeProfileJSON } from "./generation.ts";
|
|
4
4
|
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
5
|
import { processLiveness, processSignature } from "./platform.ts";
|
|
6
|
+
import { waitForPublicPruneWriter } from "./public-prune-view.ts";
|
|
6
7
|
|
|
7
8
|
const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
|
|
8
9
|
const RENEW_MS = 25_000;
|
|
@@ -12,7 +13,9 @@ interface OpenLease { refs: number; timer: ReturnType<typeof setInterval>; busyT
|
|
|
12
13
|
const open = new Map<string, OpenLease>();
|
|
13
14
|
let activeIdentity: { pid: number; signature: string } | undefined;
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
type LeaseScope = "legacy" | "shard";
|
|
17
|
+
const leaseFolder = (scope: LeaseScope) => scope === "shard" ? "shard-readers" : "readers";
|
|
18
|
+
const fileFor = (memoryDir: string, pid: number, signature: string, scope: LeaseScope) => join(memoryDir, leaseFolder(scope), `${pid}-${signature}.json`);
|
|
16
19
|
const record = (pid: number, signature: string): Lease => ({ schema: READER_LEASE_SCHEMA, pid, signature, renewedAt: new Date().toISOString() });
|
|
17
20
|
|
|
18
21
|
function withFence<T>(memoryDir: string, work: () => T, busyTimeoutMs = 3000): T {
|
|
@@ -20,27 +23,27 @@ function withFence<T>(memoryDir: string, work: () => T, busyTimeoutMs = 3000): T
|
|
|
20
23
|
try { return db.transaction(work).immediate(); } finally { db.close(); }
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
function checkReadersDir(memoryDir: string): string {
|
|
24
|
-
const dir = join(memoryDir,
|
|
26
|
+
function checkReadersDir(memoryDir: string, scope: LeaseScope): string {
|
|
27
|
+
const dir = join(memoryDir, leaseFolder(scope));
|
|
25
28
|
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
26
29
|
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new Error("reader lease directory is not a regular directory");
|
|
27
30
|
return dir;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
function readLease(memoryDir: string, file: string): Lease | undefined {
|
|
33
|
+
function readLease(memoryDir: string, file: string, scope: LeaseScope): Lease | undefined {
|
|
31
34
|
try {
|
|
32
35
|
const value = readProfileJSON(file) as Partial<Lease> | undefined;
|
|
33
36
|
if (value === undefined) return undefined;
|
|
34
37
|
if (!value || value.schema !== READER_LEASE_SCHEMA || !Number.isSafeInteger(value.pid) || value.pid! <= 0
|
|
35
38
|
|| typeof value.signature !== "string" || !/^[0-9a-f]{64}$/.test(value.signature)
|
|
36
|
-
|| fileFor(memoryDir, value.pid!, value.signature) !== file || typeof value.renewedAt !== "string" || Number.isNaN(Date.parse(value.renewedAt))) throw new Error();
|
|
39
|
+
|| fileFor(memoryDir, value.pid!, value.signature, scope) !== file || typeof value.renewedAt !== "string" || Number.isNaN(Date.parse(value.renewedAt))) throw new Error();
|
|
37
40
|
return value as Lease;
|
|
38
41
|
} catch { throw new Error("reader lease is unreadable or unverifiable"); }
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
function ownedLease(memoryDir: string, file: string, signature: string): void {
|
|
42
|
-
checkReadersDir(memoryDir);
|
|
43
|
-
const lease = readLease(memoryDir, file);
|
|
44
|
+
function ownedLease(memoryDir: string, file: string, signature: string, scope: LeaseScope): void {
|
|
45
|
+
checkReadersDir(memoryDir, scope);
|
|
46
|
+
const lease = readLease(memoryDir, file, scope);
|
|
44
47
|
if (!lease || lease.pid !== process.pid || lease.signature !== signature) throw new Error("reader lease ownership is unavailable");
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -48,12 +51,17 @@ function admitReader(memoryDir: string): void {
|
|
|
48
51
|
// The profile lock is created under the same reader admission fence, then held across migration awaits.
|
|
49
52
|
// Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
|
|
50
53
|
try {
|
|
54
|
+
waitForPublicPruneWriter(memoryDir);
|
|
51
55
|
if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
|
|
52
56
|
} catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
/** One lease per process, reference-counted across handles
|
|
56
|
-
|
|
59
|
+
/** One lease per process/home/namespace, reference-counted across handles.
|
|
60
|
+
* Shard scope shares root admission and native identity; it grants no authority.
|
|
61
|
+
* An unref'd timer does not keep the process resident. */
|
|
62
|
+
export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?: number; signature?: string; scope?: "shard" } = {}): ReaderLease {
|
|
63
|
+
if (options.scope !== undefined && options.scope !== "shard") throw new Error("invalid reader lease scope");
|
|
64
|
+
const scope: LeaseScope = options.scope === "shard" ? "shard" : "legacy";
|
|
57
65
|
const requestedTimeout = options.busyTimeoutMs ?? 3000;
|
|
58
66
|
if (!Number.isSafeInteger(requestedTimeout) || requestedTimeout < 0 || requestedTimeout > 3000) throw new Error("invalid reader admission busy timeout");
|
|
59
67
|
if (options.signature !== undefined && (typeof options.signature !== "string" || !/^[a-f0-9]{64}$/.test(options.signature))) throw new Error("invalid reader lease identity");
|
|
@@ -65,28 +73,28 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
|
|
|
65
73
|
// Host-only: the supplied hash must already describe this process's pinned native ps identity.
|
|
66
74
|
const signature = inherited ?? options.signature ?? processSignature(pid);
|
|
67
75
|
if (!signature) throw new Error("cannot establish reader lease identity");
|
|
68
|
-
const file = fileFor(memoryDir, process.pid, signature);
|
|
76
|
+
const file = fileFor(memoryDir, process.pid, signature, scope);
|
|
69
77
|
const busyTimeoutMs = open.get(file)?.busyTimeoutMs ?? requestedTimeout;
|
|
70
78
|
mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
71
79
|
withFence(memoryDir, () => {
|
|
72
80
|
admitReader(memoryDir);
|
|
73
81
|
const current = open.get(file);
|
|
74
82
|
if (current) {
|
|
75
|
-
ownedLease(memoryDir, file, signature);
|
|
83
|
+
ownedLease(memoryDir, file, signature, scope);
|
|
76
84
|
current.refs++;
|
|
77
85
|
return;
|
|
78
86
|
}
|
|
79
|
-
const dir = checkReadersDir(memoryDir);
|
|
87
|
+
const dir = checkReadersDir(memoryDir, scope);
|
|
80
88
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
81
89
|
// A failed earlier close may have retained evidence. Never overwrite an unverifiable replacement.
|
|
82
|
-
const existing = readLease(memoryDir, file);
|
|
90
|
+
const existing = readLease(memoryDir, file, scope);
|
|
83
91
|
if (existing && (existing.pid !== process.pid || existing.signature !== signature)) throw new Error("reader lease ownership is unavailable");
|
|
84
92
|
writeProfileJSON(file, record(process.pid, signature));
|
|
85
93
|
const timer = setInterval(() => {
|
|
86
94
|
if (!open.has(file)) return;
|
|
87
95
|
try {
|
|
88
96
|
withFence(memoryDir, () => {
|
|
89
|
-
ownedLease(memoryDir, file, signature);
|
|
97
|
+
ownedLease(memoryDir, file, signature, scope);
|
|
90
98
|
writeProfileJSON(file, record(process.pid, signature));
|
|
91
99
|
}, busyTimeoutMs);
|
|
92
100
|
} catch { /* keep unknown evidence; migration admission remains fail-closed */ }
|
|
@@ -107,7 +115,7 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
|
|
|
107
115
|
open.delete(file);
|
|
108
116
|
try {
|
|
109
117
|
withFence(memoryDir, () => {
|
|
110
|
-
ownedLease(memoryDir, file, signature);
|
|
118
|
+
ownedLease(memoryDir, file, signature, scope);
|
|
111
119
|
rmSync(file);
|
|
112
120
|
}, held.busyTimeoutMs);
|
|
113
121
|
} catch { /* Contention or replacement keeps evidence; never remove another actor's lease. */ }
|
|
@@ -118,30 +126,34 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
|
|
|
118
126
|
// The held lifetime lease blocks legitimate migration. Verification reads only: no ps, DB fence or refcount mutation.
|
|
119
127
|
// ponytail: no atomic same-user concurrent path-swap claim; descriptor-relative ownership checks are the upgrade path.
|
|
120
128
|
admitReader(memoryDir);
|
|
121
|
-
ownedLease(memoryDir, file, signature);
|
|
129
|
+
ownedLease(memoryDir, file, signature, scope);
|
|
122
130
|
},
|
|
123
131
|
});
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
134
|
+
/** Default migration checks legacy readers only; v2 activation opts into both
|
|
135
|
+
* namespaces under the same root admission fence. Scope is cooperation, not authentication. */
|
|
136
|
+
export function assertNoLiveReaders(memoryDir: string, options: { includeShards?: boolean; onDeadLeaseRemoved?: (event: { pid: number; lease: string }) => void } = {}): { removed: number } {
|
|
128
137
|
return withFence(memoryDir, () => {
|
|
129
|
-
const dir = checkReadersDir(memoryDir);
|
|
130
|
-
let names: string[] = [];
|
|
131
|
-
try { names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); }
|
|
132
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { removed: 0 }; throw new Error("reader lease directory is unreadable"); }
|
|
133
138
|
let removed = 0;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
139
|
+
const scopes: LeaseScope[] = options.includeShards === true ? ["legacy", "shard"] : ["legacy"];
|
|
140
|
+
for (const scope of scopes) {
|
|
141
|
+
const dir = checkReadersDir(memoryDir, scope);
|
|
142
|
+
let names: string[] = [];
|
|
143
|
+
try { names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); }
|
|
144
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error("reader lease directory is unreadable"); }
|
|
145
|
+
for (const name of names) {
|
|
146
|
+
if (!/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(name)) throw new Error("unverifiable reader lease filename");
|
|
147
|
+
const file = join(dir, name);
|
|
148
|
+
const lease = readLease(memoryDir, file, scope);
|
|
149
|
+
if (!lease) throw new Error("reader lease is unreadable or unverifiable");
|
|
150
|
+
const liveness = processLiveness(lease.pid, lease.signature);
|
|
151
|
+
if (liveness === "live") throw new Error(`legacy reader is live (pid ${lease.pid}); close it before migration`);
|
|
152
|
+
if (liveness === "unknown") throw new Error(`legacy reader liveness is unknown (pid ${lease.pid})`);
|
|
153
|
+
rmSync(file);
|
|
154
|
+
removed++;
|
|
155
|
+
options.onDeadLeaseRemoved?.({ pid: lease.pid, lease: name });
|
|
156
|
+
}
|
|
145
157
|
}
|
|
146
158
|
return { removed };
|
|
147
159
|
});
|
|
@@ -8,9 +8,10 @@ import { pruneId, prunePayloadHash, reducePruneRows, validatePruneRow, type Prun
|
|
|
8
8
|
import { acquireMutation } from "./mutation.ts";
|
|
9
9
|
import { canonicalPath, realPath } from "./platform.ts";
|
|
10
10
|
import { assertNoLiveReaders } from "./reader-lease.ts";
|
|
11
|
-
import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
|
|
11
|
+
import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, SHARDED_V2_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
|
|
12
12
|
import { buildGenerationIndex, type Entry } from "./store.ts";
|
|
13
13
|
import { readMigrationSources, type SourcePrefix } from "./migrate.ts";
|
|
14
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
14
15
|
|
|
15
16
|
const USER = Symbol("shared-user-shard");
|
|
16
17
|
type Destination = string | typeof USER;
|
|
@@ -18,7 +19,7 @@ const JOURNAL = "shards.json";
|
|
|
18
19
|
const CONSEQUENCE = "Legacy bytes are never rewritten; unannounced older readers cannot be detected and remain safe only because of that rule. Before splitting a live home, ensure every installed host uses openShardStore; ordinary new MemoryStore opens are refused once the shard marker exists. No host capability registry is available to prove an absent host safe.";
|
|
19
20
|
|
|
20
21
|
interface ShardJournal {
|
|
21
|
-
schema: typeof SHARDED_SCHEMA;
|
|
22
|
+
schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA;
|
|
22
23
|
generation: string;
|
|
23
24
|
sourceGeneration?: string;
|
|
24
25
|
phase: "captured" | "active";
|
|
@@ -66,7 +67,7 @@ function readJournal(memoryDir: string, optional = false): ShardJournal | undefi
|
|
|
66
67
|
if (optional) return undefined;
|
|
67
68
|
throw new Error("no resumable shard migration journal");
|
|
68
69
|
}
|
|
69
|
-
if (value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || !["captured", "active"].includes(value.phase ?? "")
|
|
70
|
+
if ((value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || !["captured", "active"].includes(value.phase ?? "")
|
|
70
71
|
|| value.sourceGeneration !== undefined && (!validGeneration(value.sourceGeneration) || value.sourceGeneration !== value.generation)
|
|
71
72
|
|| typeof value.selectorRevision !== "string" || !Array.isArray(value.sources) || !value.machine
|
|
72
73
|
|| typeof value.machine.id !== "string" || typeof value.machine.label !== "string") throw new Error("invalid shard migration journal");
|
|
@@ -502,6 +503,7 @@ function preflight(memoryDir: string, options: ShardMigrationOptions, chosen?: s
|
|
|
502
503
|
}
|
|
503
504
|
|
|
504
505
|
export async function migrateShards(options: ShardMigrationOptions): Promise<ShardMigrationResult> {
|
|
506
|
+
if (readSelector(options.memoryDir).selector?.schema === "agent-mem.profile/v2") throw new Error("public prune view is forward-only; use migrate --public-prune-view --resume");
|
|
505
507
|
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("shard migration requires an authorized person in a terminal");
|
|
506
508
|
if (!["dry-run", "apply", "resume"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid shard migration request");
|
|
507
509
|
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
|
@@ -560,3 +562,40 @@ export async function migrateShards(options: ShardMigrationOptions): Promise<Sha
|
|
|
560
562
|
return { mode: options.mode, generation, active: true, ...(alreadyActive ? { alreadyActive: true } : {}), counts: counts(plan), ...refusalSummary(plan), consequence: CONSEQUENCE, ...removalEvidence(journal) };
|
|
561
563
|
});
|
|
562
564
|
}
|
|
565
|
+
|
|
566
|
+
/** Render a shard migration result for a person; JSON output remains owned by the CLI. */
|
|
567
|
+
export function shardMigrationText(result: ShardMigrationResult, s: Style = PLAIN): string {
|
|
568
|
+
const counts = result.counts;
|
|
569
|
+
const groupRows = (name: string, value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
|
|
570
|
+
.map(([key, count]) => `${label(s, "ok")} ${name} ${singleLine(key)} ${count}`);
|
|
571
|
+
const countRows = [
|
|
572
|
+
["rows", counts.rows], ["skipped", counts.skippedRows], ["userRows", counts.userRows], ["tailBytes", counts.tailBytes],
|
|
573
|
+
["legacyRows", counts.legacyRows], ["generationRows", counts.generationRows], ["generationLocalRows", counts.generationLocalRows],
|
|
574
|
+
["legacyLocalRows", counts.legacyLocalRows], ["legacyMainRows", counts.legacyMainRows], ["pruneRows", counts.pruneRows],
|
|
575
|
+
["translatedPruneRows", counts.translatedPruneRows], ["refusedRows", counts.refusedRows],
|
|
576
|
+
["sharedSupersededByPrivate", counts.sharedSupersededByPrivate], ["crossPrivateReplacement", counts.crossPrivateReplacement],
|
|
577
|
+
["danglingRows", counts.danglingRows],
|
|
578
|
+
].map(([key, value]) => {
|
|
579
|
+
const flagged = key === "refusedRows" && counts.refusedRows > 0 || key === "sharedSupersededByPrivate" && counts.sharedSupersededByPrivate > 0
|
|
580
|
+
|| key === "crossPrivateReplacement" && counts.crossPrivateReplacement > 0 || key === "danglingRows" && counts.danglingRows > 0;
|
|
581
|
+
return `${label(s, flagged ? "attention" : "ok")} ${key} ${value}`;
|
|
582
|
+
});
|
|
583
|
+
const warnings = result.refusals ?? [];
|
|
584
|
+
const attention = warnings.length > 0 || counts.refusedRows > 0 || counts.danglingRows > 0 || (!result.active && result.mode === "resume");
|
|
585
|
+
const state = attention ? "attention" : result.mode === "dry-run" ? "preview" : result.active ? "complete" : "attention";
|
|
586
|
+
const action = warnings.length || counts.refusedRows > 0 || counts.danglingRows > 0 ? "resolve the listed refusals or flagged rows before retrying"
|
|
587
|
+
: result.mode === "dry-run" && (!result.active || counts.rows > 0) ? "apply shard migration: amem migrate --shards --apply"
|
|
588
|
+
: result.mode === "resume" && !result.active ? "resume shard migration: amem migrate --shards --resume"
|
|
589
|
+
: "none";
|
|
590
|
+
return [
|
|
591
|
+
`${s.bold("amem migrate --shards:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
|
|
592
|
+
`${label(s, "ok")} generation ${result.generation.slice(0, 8)}`,
|
|
593
|
+
...countRows,
|
|
594
|
+
...groupRows("shard rows", counts.byShard),
|
|
595
|
+
...groupRows("project rows", counts.byProject),
|
|
596
|
+
...(result.deadLeasesRemoved?.length ? [`${label(s, "ok")} dead reader leases removed ${result.deadLeasesRemoved.length}`] : []),
|
|
597
|
+
...(warnings.length ? ["Refusals:", ...warnings.map(reason => ` ${label(s, "attention")} ${singleLine(reason)}`)] : []),
|
|
598
|
+
`${label(s, "ok")} effect ${singleLine(result.consequence)}`,
|
|
599
|
+
`Next action: ${action}`,
|
|
600
|
+
].join("\n");
|
|
601
|
+
}
|
|
@@ -3,11 +3,15 @@ import { legacyEntries } from "./bridge.ts";
|
|
|
3
3
|
import { canonicalJSON, validGeneration } from "./generation.ts";
|
|
4
4
|
import { validatePruneRow, type PruneRow } from "./prune.ts";
|
|
5
5
|
import { shardKeyHash } from "./shard.ts";
|
|
6
|
+
import { validatePublicPruneWitness, type PublicPruneWitnessV1 } from "./public-prune-witness.ts";
|
|
6
7
|
import type { Entry } from "./store.ts";
|
|
7
8
|
|
|
8
9
|
export type ShardSyncRealm = { scope: "project"; key: string } | { scope: "user" };
|
|
9
10
|
export type ShardSyncRow = Entry | PruneRow;
|
|
10
11
|
export const SHARD_SYNC_SCHEMA = "agent-mem.shard-sync/v1";
|
|
12
|
+
export const SHARD_SYNC_V2_SCHEMA = "agent-mem.shard-sync/v2";
|
|
13
|
+
const METADATA_MAX_BYTES = 65_536;
|
|
14
|
+
const MANIFEST = /^prune-witnesses\/([a-f0-9]{64})\.json$/;
|
|
11
15
|
export const SHARD_PACK_MAX_BYTES = 1024 * 1024;
|
|
12
16
|
const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
|
|
13
17
|
const hash = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
|
|
@@ -28,9 +32,43 @@ export function shardSyncRef(realm: ShardSyncRealm): string {
|
|
|
28
32
|
checkRealm(realm);
|
|
29
33
|
return realm.scope === "user" ? "refs/heads/amem/u" : `refs/heads/amem/p/${shardKeyHash(realm.key)}`;
|
|
30
34
|
}
|
|
31
|
-
export function shardSyncDescriptor(realm: ShardSyncRealm): string {
|
|
35
|
+
export function shardSyncDescriptor(realm: ShardSyncRealm, version: "v1" | "v2" = "v1"): string {
|
|
32
36
|
checkRealm(realm);
|
|
33
|
-
|
|
37
|
+
if (version !== "v1" && version !== "v2") throw new ShardSyncFormatError("shard", "unsupported descriptor version");
|
|
38
|
+
const schema = version === "v1" ? SHARD_SYNC_SCHEMA : SHARD_SYNC_V2_SCHEMA;
|
|
39
|
+
return canonicalJSON(realm.scope === "user" ? { schema, scope: "user" } : { schema, scope: "project", key: realm.key }) + "\n";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Closed canonical metadata, including its single complete newline terminator. */
|
|
43
|
+
function decodeMetadata(bytes: Uint8Array, label: string): Record<string, unknown> {
|
|
44
|
+
try {
|
|
45
|
+
if (!bytes.byteLength || bytes.byteLength > METADATA_MAX_BYTES) throw new Error("bound");
|
|
46
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), value: unknown = JSON.parse(text);
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || canonicalJSON(value) + "\n" !== text) throw new Error("canonical");
|
|
48
|
+
return value as Record<string, unknown>;
|
|
49
|
+
} catch { throw new ShardSyncFormatError(label, "invalid canonical metadata or bound"); }
|
|
50
|
+
}
|
|
51
|
+
export function decodeShardDescriptor(bytes: Uint8Array, realm: ShardSyncRealm): "v1" | "v2" {
|
|
52
|
+
const label = shardSyncLabel(realm), value = decodeMetadata(bytes, label);
|
|
53
|
+
const version = value.schema === SHARD_SYNC_SCHEMA ? "v1" : value.schema === SHARD_SYNC_V2_SCHEMA ? "v2" : undefined;
|
|
54
|
+
if (!version || canonicalJSON(value) + "\n" !== shardSyncDescriptor(realm, version)) throw new ShardSyncFormatError(label, "descriptor realm or schema mismatch");
|
|
55
|
+
return version;
|
|
56
|
+
}
|
|
57
|
+
export function isShardManifestPath(path: string): boolean { return MANIFEST.exec(path)?.[0] === path; }
|
|
58
|
+
/** Shape and lineage classification belong to Layer 1 after complete pack acquisition. */
|
|
59
|
+
export function decodeShardManifest(path: string, bytes: Uint8Array): unknown {
|
|
60
|
+
const match = MANIFEST.exec(path), label = "shard manifest";
|
|
61
|
+
if (!match || match[0] !== path) throw new ShardSyncFormatError(label, "invalid manifest path");
|
|
62
|
+
const value = decodeMetadata(bytes, label);
|
|
63
|
+
if (value.schema !== "agent-mem.public-prune-witness/v1" || hash(canonicalJSON(value)) !== match[1]) throw new ShardSyncFormatError(label, "invalid manifest schema or hash");
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
export function encodeShardManifest(manifest: PublicPruneWitnessV1): { path: string; bytes: Buffer } {
|
|
67
|
+
try {
|
|
68
|
+
const value = validatePublicPruneWitness(manifest), body = canonicalJSON(value), bytes = Buffer.from(body + "\n");
|
|
69
|
+
if (bytes.byteLength > METADATA_MAX_BYTES) throw new Error("bound");
|
|
70
|
+
return { path: `prune-witnesses/${hash(body)}.json`, bytes };
|
|
71
|
+
} catch { throw new ShardSyncFormatError("shard manifest", "invalid supported manifest or bound"); }
|
|
34
72
|
}
|
|
35
73
|
|
|
36
74
|
/** Only row shape/public placement here. The sync engine must prove target/basis realm ownership across the full source set. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Bounded selected-path acquisition only. Transport admission and receiving binding stay with the caller. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { PUBLIC_PRUNE_GRAPH_MAX_BYTES, PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, PUBLIC_PRUNE_INPUT_MAX_BYTES, PublicPruneInputError, type PublicPruneObject } from "./public-prune-inputs.ts";
|
|
4
|
+
import { decodeShardDescriptor, decodeShardManifest, SHARD_PACK_MAX_BYTES, type ShardSyncRealm } from "./shard-sync-format.ts";
|
|
5
|
+
import { PUBLIC_PRUNE_MAX_PACKS, PUBLIC_PRUNE_MAX_WITNESSES, verifyPublicPruneWitness } from "./public-prune-witness.ts";
|
|
6
|
+
|
|
7
|
+
type ObjectType = "commit" | "tree" | "blob";
|
|
8
|
+
export interface PublicPruneGitGraphOptions {
|
|
9
|
+
commit: string; manifestPath: string; manifestBlob: string; realm: ShardSyncRealm;
|
|
10
|
+
readObject: (oid: string) => Promise<{ type: ObjectType; body: Uint8Array }>;
|
|
11
|
+
}
|
|
12
|
+
const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
|
|
13
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
function guard(ok: unknown, resource = false): asserts ok { if (!ok) throw new PublicPruneInputError(resource ? "resource" : "unavailable"); }
|
|
15
|
+
interface TreeEntry { mode: "40000" | "100644"; oid: string }
|
|
16
|
+
function parseTree(body: Buffer): Map<string, TreeEntry> {
|
|
17
|
+
const entries = new Map<string, TreeEntry>();
|
|
18
|
+
for (let offset = 0; offset < body.length;) {
|
|
19
|
+
const space = body.indexOf(32, offset); guard(space > offset);
|
|
20
|
+
const end = body.indexOf(0, space + 1); guard(end > space + 1 && end + 21 <= body.length);
|
|
21
|
+
const modeBytes = body.subarray(offset, space), mode = modeBytes.toString("ascii");
|
|
22
|
+
guard((mode === "40000" || mode === "100644") && modeBytes.equals(Buffer.from(mode)));
|
|
23
|
+
const rawName = body.subarray(space + 1, end), name = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(rawName);
|
|
24
|
+
guard(name.length > 0 && name !== "." && name !== ".." && !name.includes("/") && !entries.has(name));
|
|
25
|
+
entries.set(name, { mode, oid: body.subarray(end + 1, end + 21).toString("hex") }); offset = end + 21;
|
|
26
|
+
}
|
|
27
|
+
return entries;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Complete raw object graph for one manifest. Unsupported lineage still returns its complete verified public proof. */
|
|
31
|
+
export async function collectPublicPruneGitGraph(options: PublicPruneGitGraphOptions): Promise<PublicPruneObject[]> {
|
|
32
|
+
try {
|
|
33
|
+
const { commit, manifestPath, manifestBlob, readObject } = options, realm = structuredClone(options.realm);
|
|
34
|
+
guard(hex(commit, 40) && hex(manifestBlob, 40));
|
|
35
|
+
guard(typeof manifestPath === "string" && manifestPath.length === 85 && /^prune-witnesses\/[a-f0-9]{64}\.json$/.test(manifestPath));
|
|
36
|
+
const objects = new Map<string, { type: ObjectType; body: Buffer; bytes: Buffer }>(), trees = new Map<string, Map<string, TreeEntry>>(); let total = 0;
|
|
37
|
+
const get = async (oid: string, type: ObjectType) => {
|
|
38
|
+
guard(hex(oid, 40)); let found = objects.get(oid);
|
|
39
|
+
if (!found) {
|
|
40
|
+
guard(objects.size < PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, true);
|
|
41
|
+
const value = await readObject(oid);
|
|
42
|
+
guard(value && ["commit", "tree", "blob"].includes(value.type) && value.body instanceof Uint8Array);
|
|
43
|
+
guard(value.body.byteLength <= (value.type === "commit" ? PUBLIC_PRUNE_INPUT_MAX_BYTES : SHARD_PACK_MAX_BYTES), true);
|
|
44
|
+
const header = Buffer.from(`${value.type} ${value.body.byteLength}\0`);
|
|
45
|
+
guard(header.length + value.body.byteLength <= SHARD_PACK_MAX_BYTES + 64 && total + header.length + value.body.byteLength <= PUBLIC_PRUNE_GRAPH_MAX_BYTES, true);
|
|
46
|
+
const body = Buffer.from(value.body), bytes = Buffer.concat([header, body]);
|
|
47
|
+
guard(createHash("sha1").update(bytes).digest("hex") === oid);
|
|
48
|
+
found = { type: value.type, body, bytes }; total += bytes.length; objects.set(oid, found);
|
|
49
|
+
}
|
|
50
|
+
guard(found.type === type); return found;
|
|
51
|
+
};
|
|
52
|
+
const commitBody = (await get(commit, "commit")).body, lf = commitBody.indexOf(10);
|
|
53
|
+
// Only the ASCII tree header is interpreted; arbitrary commit message bytes remain exact.
|
|
54
|
+
guard(lf === 45 && commitBody.subarray(0, 5).equals(Buffer.from("tree ")));
|
|
55
|
+
const rootBytes = commitBody.subarray(5, lf), root = rootBytes.toString("ascii");
|
|
56
|
+
guard(hex(root, 40) && rootBytes.equals(Buffer.from(root)));
|
|
57
|
+
const path = async (name: string) => {
|
|
58
|
+
const parts = name.split("/"); guard(parts.length > 0 && parts.every(part => part && part !== "." && part !== ".."));
|
|
59
|
+
let oid = root;
|
|
60
|
+
for (let i = 0; i < parts.length; i++) {
|
|
61
|
+
let entries = trees.get(oid);
|
|
62
|
+
if (!entries) { entries = parseTree((await get(oid, "tree")).body); trees.set(oid, entries); }
|
|
63
|
+
const entry = entries.get(parts[i]!); guard(entry && entry.mode === (i === parts.length - 1 ? "100644" : "40000")); oid = entry.oid;
|
|
64
|
+
}
|
|
65
|
+
const value = await get(oid, "blob"); return { oid, body: value.body };
|
|
66
|
+
};
|
|
67
|
+
guard(decodeShardDescriptor((await path("shard.json")).body, realm) === "v2");
|
|
68
|
+
const manifest = await path(manifestPath); guard(manifest.oid === manifestBlob);
|
|
69
|
+
const raw = decodeShardManifest(manifestPath, manifest.body);
|
|
70
|
+
guard(object(raw) && object(raw.pruneOccurrence) && Array.isArray(raw.witnesses));
|
|
71
|
+
guard(raw.witnesses.length <= PUBLIC_PRUNE_MAX_WITNESSES, true);
|
|
72
|
+
const packs = new Map<string, { pack: string; blob: string; bytes: Buffer }>();
|
|
73
|
+
for (const ref of [raw.pruneOccurrence, ...raw.witnesses]) {
|
|
74
|
+
guard(object(ref) && typeof ref.pack === "string" && ref.pack.length === 140 && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.test(ref.pack) && hex(ref.blob, 40));
|
|
75
|
+
const previous = packs.get(ref.pack);
|
|
76
|
+
if (previous) { guard(previous.blob === ref.blob); continue; }
|
|
77
|
+
guard(packs.size < PUBLIC_PRUNE_MAX_PACKS, true);
|
|
78
|
+
const found = await path(ref.pack); guard(found.oid === ref.blob && found.body.length <= SHARD_PACK_MAX_BYTES);
|
|
79
|
+
packs.set(ref.pack, { pack: ref.pack, blob: ref.blob, bytes: found.body });
|
|
80
|
+
}
|
|
81
|
+
const proof = verifyPublicPruneWitness(raw, { realm, packs: [...packs.values()] }); guard(proof.status !== "invalid");
|
|
82
|
+
return [...objects].map(([oid, item]) => ({ oid, bytes: item.bytes }));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof PublicPruneInputError) throw error;
|
|
85
|
+
throw new PublicPruneInputError();
|
|
86
|
+
}
|
|
87
|
+
}
|