@staix/agent-mem 0.13.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 +21 -2
- package/package.json +3 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +1 -0
- 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,139 @@
|
|
|
1
|
+
/** Private local publication authority, never a transport object or a native-row rewrite. */
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeFileSync, type Stats } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { canonicalJSON, fsyncDirectory, type BoundProfile } from "./generation.ts";
|
|
6
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { validatePruneRow, type PruneRow } from "./prune.ts";
|
|
9
|
+
import type { PublicPruneLineage } from "./public-prune-witness.ts";
|
|
10
|
+
import { resolveShardProfile } from "./shard.ts";
|
|
11
|
+
|
|
12
|
+
export interface PublisherBoundaryV1 {
|
|
13
|
+
schema: "agent-mem.prune-publisher-boundary/v1";
|
|
14
|
+
generation: string; sealHash: string; machine: string; end: number; prefixSha256: string;
|
|
15
|
+
}
|
|
16
|
+
const fail = (): never => { throw new Error("prune publisher boundary unavailable or changed"); };
|
|
17
|
+
function guard(value: unknown): asserts value { if (!value) fail(); }
|
|
18
|
+
function owned(stat: Stats, directory = false, links = 1): void {
|
|
19
|
+
guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links)
|
|
20
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
|
|
21
|
+
}
|
|
22
|
+
function same(a: Stats, b: Stats): boolean { return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs; }
|
|
23
|
+
function bytes(file: string, limit: number, links = 1): Buffer {
|
|
24
|
+
const before = lstatSync(file); owned(before, false, links); guard(before.size <= limit);
|
|
25
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
const opened = fstatSync(fd); owned(opened, false, links); guard(same(before, opened));
|
|
28
|
+
const value = readFileSync(fd); guard(value.length === before.size && same(opened, fstatSync(fd)) && same(opened, lstatSync(file))); return value;
|
|
29
|
+
} finally { closeSync(fd); }
|
|
30
|
+
}
|
|
31
|
+
function context(profile: BoundProfile, mutation: MutationHandle): { machine: string; sealHash: string; log: string; file: string; local: string } {
|
|
32
|
+
assertMutationHandle(mutation, profile.dir);
|
|
33
|
+
guard(profile.kind === "generation" && profile.sharded && profile.foreignPruneFormat === "agent-mem.public-prune-witness/v1");
|
|
34
|
+
const current = resolveShardProfile(profile.home, profile.shardUser ? { user: true } : { project: profile.shardKey });
|
|
35
|
+
guard(current.generation === profile.generation && current.foreignPruneFormat === profile.foreignPruneFormat
|
|
36
|
+
&& current.selectorRevision === profile.selectorRevision && realPath(current.dir) === realPath(profile.dir));
|
|
37
|
+
for (const dir of [profile.home, profile.dir, join(profile.dir, "local"), join(profile.dir, "log")]) owned(lstatSync(dir), true);
|
|
38
|
+
const rootMachine = JSON.parse(bytes(join(profile.home, "machine.json"), 4096).toString("utf8")) as { id?: unknown };
|
|
39
|
+
const shardMachine = JSON.parse(bytes(join(profile.dir, "machine.json"), 4096).toString("utf8")) as { id?: unknown };
|
|
40
|
+
guard(typeof rootMachine.id === "string" && /^[a-f0-9]{16}$/.test(rootMachine.id) && rootMachine.id === shardMachine.id);
|
|
41
|
+
// resolveShardProfile checks descriptor/sourceDigest; hash the original complete seal bytes too.
|
|
42
|
+
const sealHash = createHash("sha256").update(bytes(join(profile.dir, "generation-seal.json"), 4096)).digest("hex");
|
|
43
|
+
bytes(join(profile.dir, "generation.json"), 4096); bytes(join(profile.dir, "migration-sources.json"), 262144);
|
|
44
|
+
const machine = rootMachine.id, local = join(profile.dir, "local");
|
|
45
|
+
return { machine, sealHash, local, file: join(local, `publisher-boundary-${machine}.json`), log: join(profile.dir, "log", `${machine}.jsonl`) };
|
|
46
|
+
}
|
|
47
|
+
/** Streaming complete prefix and bounded line scan; no lifetime byte/row omission. */
|
|
48
|
+
function inspect(log: string, end?: number, rows?: ReadonlyMap<string, { row: PruneRow; canonical: string }>): { end: number; hash: string; found: Set<string> } {
|
|
49
|
+
const before = lstatSync(log, { throwIfNoEntry: false });
|
|
50
|
+
if (!before) { guard(end === undefined || end === 0); return { end: 0, hash: createHash("sha256").digest("hex"), found: new Set() }; }
|
|
51
|
+
owned(before); const bound = end ?? before.size; guard(Number.isSafeInteger(bound) && bound >= 0 && bound <= before.size);
|
|
52
|
+
const fd = openSync(log, constants.O_RDONLY | constants.O_NOFOLLOW), digest = createHash("sha256"), chunk = Buffer.alloc(65536);
|
|
53
|
+
let position = 0, lineStart = 0, line: number[] = [], last = -1, boundaryNewline = bound === 0;
|
|
54
|
+
const found = new Set<string>(), older = new Set<string>();
|
|
55
|
+
try {
|
|
56
|
+
guard(same(before, fstatSync(fd)));
|
|
57
|
+
while (position < before.size) {
|
|
58
|
+
const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - position), position); guard(count > 0);
|
|
59
|
+
if (position < bound) digest.update(chunk.subarray(0, Math.min(count, bound - position)));
|
|
60
|
+
for (let i = 0; i < count; i++) {
|
|
61
|
+
const byte = chunk[i]!; last = byte;
|
|
62
|
+
if (position + i + 1 === bound) boundaryNewline = byte === 10;
|
|
63
|
+
if (rows) {
|
|
64
|
+
if (byte === 10) {
|
|
65
|
+
guard(line.length > 0);
|
|
66
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(line)));
|
|
67
|
+
const id = value && typeof value === "object" && "id" in value && typeof value.id === "string" ? value.id : undefined;
|
|
68
|
+
const candidate = id === undefined ? undefined : rows.get(id);
|
|
69
|
+
const canonical = lineStart >= bound ? canonicalJSON(value) : undefined;
|
|
70
|
+
if (candidate) {
|
|
71
|
+
if (lineStart < bound) older.add(candidate.row.id);
|
|
72
|
+
else if (canonical === candidate.canonical) { validatePruneRow(value, candidate.row.generation, "synced"); found.add(candidate.row.id); }
|
|
73
|
+
}
|
|
74
|
+
line = []; lineStart = position + i + 1;
|
|
75
|
+
} else { line.push(byte); guard(line.length <= 1024 * 1024); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
position += count;
|
|
79
|
+
if (!rows && position === before.size) break;
|
|
80
|
+
}
|
|
81
|
+
guard(boundaryNewline && (before.size === 0 || last === 10) && same(before, fstatSync(fd)) && same(before, lstatSync(log)));
|
|
82
|
+
for (const id of older) found.delete(id);
|
|
83
|
+
return { end: bound, hash: digest.digest("hex"), found };
|
|
84
|
+
} finally { closeSync(fd); }
|
|
85
|
+
}
|
|
86
|
+
function decode(value: Buffer): PublisherBoundaryV1 {
|
|
87
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(value), v = JSON.parse(text) as PublisherBoundaryV1;
|
|
88
|
+
guard(v && Object.keys(v).sort().join(",") === "end,generation,machine,prefixSha256,schema,sealHash"
|
|
89
|
+
&& v.schema === "agent-mem.prune-publisher-boundary/v1" && /^[a-f0-9]{32}$/.test(v.generation)
|
|
90
|
+
&& /^[a-f0-9]{16}$/.test(v.machine) && /^[a-f0-9]{64}$/.test(v.sealHash) && /^[a-f0-9]{64}$/.test(v.prefixSha256)
|
|
91
|
+
&& Number.isSafeInteger(v.end) && v.end >= 0 && text === canonicalJSON(v) + "\n"); return v;
|
|
92
|
+
}
|
|
93
|
+
/** Recover only an exact private temp/final inode pair while holding the real kernel fence. */
|
|
94
|
+
function recover(file: string, local: string): void {
|
|
95
|
+
const final = lstatSync(file, { throwIfNoEntry: false }); if (!final || final.nlink === 1) return;
|
|
96
|
+
owned(final, false, 2); decode(bytes(file, 4096, 2));
|
|
97
|
+
const candidates = readdirSync(local).filter(name => /^\.publisher-boundary-[a-f0-9]{32}\.tmp$/.test(name))
|
|
98
|
+
.map(name => join(local, name)).filter(name => { const stat = lstatSync(name); return stat.dev === final.dev && stat.ino === final.ino; });
|
|
99
|
+
guard(candidates.length === 1); const temp = candidates[0]!; owned(lstatSync(temp), false, 2);
|
|
100
|
+
guard(same(final, lstatSync(file)) && same(final, lstatSync(temp))); unlinkSync(temp); fsyncDirectory(local);
|
|
101
|
+
}
|
|
102
|
+
export function assertPublisherBoundary(profile: BoundProfile, mutation: MutationHandle): PublisherBoundaryV1 {
|
|
103
|
+
const ctx = context(profile, mutation); recover(ctx.file, ctx.local);
|
|
104
|
+
const value = decode(bytes(ctx.file, 4096));
|
|
105
|
+
guard(value.generation === profile.generation && value.machine === ctx.machine && value.sealHash === ctx.sealHash);
|
|
106
|
+
guard(inspect(ctx.log, value.end).hash === value.prefixSha256); assertMutationHandle(mutation, profile.dir); return value;
|
|
107
|
+
}
|
|
108
|
+
/** Explicit sync-only admission: existing complete own log is a conservative migration upper bound. */
|
|
109
|
+
export function ensurePublisherBoundary(profile: BoundProfile, mutation: MutationHandle): PublisherBoundaryV1 {
|
|
110
|
+
const ctx = context(profile, mutation); recover(ctx.file, ctx.local);
|
|
111
|
+
if (lstatSync(ctx.file, { throwIfNoEntry: false })) return assertPublisherBoundary(profile, mutation);
|
|
112
|
+
const prefix = inspect(ctx.log), value: PublisherBoundaryV1 = { schema: "agent-mem.prune-publisher-boundary/v1", generation: profile.generation!,
|
|
113
|
+
sealHash: ctx.sealHash, machine: ctx.machine, end: prefix.end, prefixSha256: prefix.hash };
|
|
114
|
+
const temp = join(ctx.local, `.publisher-boundary-${randomBytes(16).toString("hex")}.tmp`), fd = openSync(temp, "wx", 0o600);
|
|
115
|
+
try { writeFileSync(fd, canonicalJSON(value) + "\n"); fsyncSync(fd); } finally { closeSync(fd); }
|
|
116
|
+
// A crash leaves either a harmless unregistered temp or an exact recoverable pair.
|
|
117
|
+
linkSync(temp, ctx.file); fsyncDirectory(ctx.local); unlinkSync(temp); fsyncDirectory(ctx.local);
|
|
118
|
+
return assertPublisherBoundary(profile, mutation);
|
|
119
|
+
}
|
|
120
|
+
/** Verify one candidate set with one complete JSONL parse, independent of the candidate count. */
|
|
121
|
+
export function publisherPruneLineages(profile: BoundProfile, mutation: MutationHandle, rows: readonly PruneRow[]): Map<string, PublicPruneLineage> {
|
|
122
|
+
const boundary = assertPublisherBoundary(profile, mutation), candidates = new Map<string, { row: PruneRow; canonical: string }>();
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
if (row.machine !== boundary.machine || row.generation !== boundary.generation || row.placement !== "synced") continue;
|
|
125
|
+
validatePruneRow(row, boundary.generation, "synced");
|
|
126
|
+
const canonical = canonicalJSON(row), previous = candidates.get(row.id);
|
|
127
|
+
guard(!previous || previous.canonical === canonical); candidates.set(row.id, { row, canonical });
|
|
128
|
+
}
|
|
129
|
+
const result = new Map<string, PublicPruneLineage>();
|
|
130
|
+
if (!candidates.size) return result;
|
|
131
|
+
const ctx = context(profile, mutation), proof = inspect(ctx.log, boundary.end, candidates);
|
|
132
|
+
guard(proof.hash === boundary.prefixSha256); assertPublisherBoundary(profile, mutation);
|
|
133
|
+
for (const id of proof.found) result.set(id, { kind: "post-seal", seal: boundary.sealHash, end: boundary.end });
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
/** Compatibility entry point; batch callers avoid rescanning once per original. */
|
|
137
|
+
export function publisherPruneLineage(profile: BoundProfile, mutation: MutationHandle, row: PruneRow): PublicPruneLineage | undefined {
|
|
138
|
+
return publisherPruneLineages(profile, mutation, [row]).get(row.id);
|
|
139
|
+
}
|
|
@@ -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
|
+
}
|