@staix/agent-mem 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -2
- package/package.json +3 -2
- package/src/cli-agent-reads.ts +27 -13
- package/src/cli.ts +20 -5
- package/src/commands-table.ts +34 -6
- package/src/memory/catalog.ts +4 -4
- package/src/memory/commands.ts +4 -0
- package/src/memory/doctor.ts +13 -3
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/migrate.ts +1 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +4 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +46 -8
- package/src/memory/sync.ts +141 -5
package/src/memory/generation.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { assertNoPublicPruneIntent, readPublicPruneView, waitForPublicPruneWriter, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { types } from "node:util";
|
|
3
4
|
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
8
|
+
import { shardDir, userShardDir } from "./shard.ts";
|
|
9
|
+
import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
|
|
7
10
|
|
|
8
11
|
export interface BoundProfile {
|
|
9
12
|
kind: "shared-v1" | "generation";
|
|
@@ -12,6 +15,9 @@ export interface BoundProfile {
|
|
|
12
15
|
dir: string;
|
|
13
16
|
generation?: string;
|
|
14
17
|
selectorRevision: string;
|
|
18
|
+
/** Explicit v2 projection only, never inferred from an archive or caller flag. */
|
|
19
|
+
foreignPruneFormat?: "agent-mem.public-prune-witness/v1";
|
|
20
|
+
pruneInputSetHash?: string;
|
|
15
21
|
/** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
|
|
16
22
|
legacyHome?: string;
|
|
17
23
|
/** Public legacy witnesses stay distinct from same-machine private bridge input. */
|
|
@@ -21,7 +27,8 @@ export interface BoundProfile {
|
|
|
21
27
|
shardUser?: boolean;
|
|
22
28
|
}
|
|
23
29
|
export type ProfileInput = "shared-v1" | { generation: string };
|
|
24
|
-
export
|
|
30
|
+
export type ProfileSelector = { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
|
|
31
|
+
| { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: "agent-mem.public-prune-witness/v1"; pruneInputSetHash: string };
|
|
25
32
|
export interface GenerationDescriptor { schema: "agent-mem.generation/v1"; generation: string; legacySchema: "agent-hub.memory/v1" }
|
|
26
33
|
export interface GenerationSeal { schema: "agent-mem.generation-seal/v1"; generation: string; descriptorHash: string; sourceDigest: string }
|
|
27
34
|
export const PROFILE_LOCK_TIMEOUT_MS = 3000;
|
|
@@ -44,16 +51,69 @@ export function readProfileJSON(path: string, limit = LOCAL_METADATA_MAX_BYTES):
|
|
|
44
51
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; return unavailable(); }
|
|
45
52
|
}
|
|
46
53
|
function keys(value: object, allowed: string[]): boolean { return Object.keys(value).every(key => allowed.includes(key)); }
|
|
47
|
-
|
|
54
|
+
function selectorSnapshot(memoryDir: string, deadline: number): { selector?: ProfileSelector; revision: string } {
|
|
55
|
+
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
48
56
|
const value = readProfileJSON(join(memoryDir, "profile.json"));
|
|
49
57
|
if (value === undefined) return { revision: "absent" };
|
|
50
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return unavailable();
|
|
51
59
|
const selector = value as ProfileSelector;
|
|
52
|
-
if (selector.schema
|
|
53
|
-
|
|
60
|
+
if (selector.schema === "agent-mem.profile/v2") {
|
|
61
|
+
if (Object.keys(selector).length !== 5 || !keys(selector, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"])
|
|
62
|
+
|| selector.kind !== "generation" || !validGeneration(selector.generation) || selector.generation.length !== 32
|
|
63
|
+
|| selector.foreignPruneFormat !== "agent-mem.public-prune-witness/v1" || typeof selector.pruneInputSetHash !== "string"
|
|
64
|
+
|| selector.pruneInputSetHash.length !== 64 || !/^[a-f0-9]+$/.test(selector.pruneInputSetHash)) return unavailable();
|
|
65
|
+
const shard = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
66
|
+
if (!shard || shard.schema !== "agent-mem.shards/v2" || shard.phase !== "active" || shard.generation !== selector.generation || shard.selectorRevision !== hashJSON(selector)) return unavailable();
|
|
67
|
+
readPublicPruneView(memoryDir, selector.pruneInputSetHash, selector.generation, deadline);
|
|
68
|
+
} else if (selector.schema !== "agent-mem.profile/v1" || !keys(selector, ["schema", "kind", "generation"])
|
|
69
|
+
|| !["shared-v1", "generation"].includes(selector.kind) || (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
|
|
70
|
+
else if ((readProfileJSON(join(memoryDir, "shards.json")) as {schema?:unknown}|undefined)?.schema === "agent-mem.shards/v2") return unavailable();
|
|
54
71
|
return { selector, revision: hashJSON(selector) };
|
|
55
72
|
}
|
|
73
|
+
|
|
74
|
+
/** A v2 writer can replace its two markers between individual reads. Retry its
|
|
75
|
+
* bounded completed publication rather than exposing a transient mixed pair. */
|
|
76
|
+
export function readSelector(memoryDir: string, absoluteDeadline?: number): { selector?: ProfileSelector; revision: string } {
|
|
77
|
+
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
78
|
+
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
const result = selectorSnapshot(memoryDir, deadline); assertNoPublicPruneIntent(memoryDir, deadline);
|
|
81
|
+
const current = readProfileJSON(join(memoryDir, "profile.json"));
|
|
82
|
+
if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
|
|
85
|
+
if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
|
|
86
|
+
waitForPublicPruneWriter(memoryDir, deadline);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return unavailable();
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
export const selectorRevision = (memoryDir: string): string => readSelector(memoryDir).revision;
|
|
93
|
+
/** Refresh the same shared profile object only across proved monotone same-generation v2 views.
|
|
94
|
+
* Unopened realms contribute set metadata only; their descriptors and bodies stay unopened. */
|
|
95
|
+
export function rebindPublicPruneProfile(profile: BoundProfile): void {
|
|
96
|
+
if (!profile.foreignPruneFormat) return;
|
|
97
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
98
|
+
const current = readSelector(profile.home), selector = current.selector;
|
|
99
|
+
if (current.revision === profile.selectorRevision) return;
|
|
100
|
+
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.pruneInputSetHash
|
|
101
|
+
|| selector?.schema !== "agent-mem.profile/v2" || selector.generation !== profile.generation || selector.foreignPruneFormat !== profile.foreignPruneFormat) return unavailable();
|
|
102
|
+
const previous = readPublicPruneView(profile.home, profile.pruneInputSetHash, profile.generation);
|
|
103
|
+
const next = readPublicPruneView(profile.home, selector.pruneInputSetHash, profile.generation);
|
|
104
|
+
if (!Object.keys(previous.realms).every(key => Object.hasOwn(next.realms, key))) return unavailable();
|
|
105
|
+
for (const [key, hash] of Object.entries(next.realms)) {
|
|
106
|
+
const old = previous.realms[key]; if (old === hash) continue;
|
|
107
|
+
const dir = key === "user" ? userShardDir(profile.home) : shardDir(profile.home, key.slice(8));
|
|
108
|
+
const chain = readPublicPruneInputSetChain({ dir, receivingGeneration: profile.generation, inputSetHash: hash });
|
|
109
|
+
if (old !== undefined && !chain.commitments.includes(old)) return unavailable();
|
|
110
|
+
}
|
|
111
|
+
if (readSelector(profile.home).revision !== current.revision) continue;
|
|
112
|
+
profile.selectorRevision = current.revision; profile.pruneInputSetHash = selector.pruneInputSetHash;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return unavailable();
|
|
116
|
+
}
|
|
57
117
|
export function generationDescriptor(generation: string): GenerationDescriptor {
|
|
58
118
|
if (!validGeneration(generation)) return unavailable();
|
|
59
119
|
return { schema: "agent-mem.generation/v1", generation, legacySchema: "agent-hub.memory/v1" };
|
|
@@ -83,8 +143,9 @@ export function validateGeneration(memoryDir: string, generation: string): strin
|
|
|
83
143
|
export function resolveProfile(memoryDir: string, explicit?: ProfileInput): BoundProfile {
|
|
84
144
|
const home = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), selected = readSelector(home);
|
|
85
145
|
if (explicit !== undefined && explicit !== "shared-v1" && (!explicit || typeof explicit !== "object" || !keys(explicit, ["generation"]) || !validGeneration(explicit.generation))) return unavailable();
|
|
146
|
+
if (selected.selector?.schema === "agent-mem.profile/v2" && explicit !== undefined) return unavailable();
|
|
86
147
|
const generation = explicit === "shared-v1" ? undefined : explicit?.generation ?? (selected.selector?.kind === "generation" ? selected.selector.generation : undefined);
|
|
87
|
-
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision }
|
|
148
|
+
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision, ...(selected.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selected.selector.foreignPruneFormat, pruneInputSetHash: selected.selector.pruneInputSetHash } : {}) }
|
|
88
149
|
: { kind: "shared-v1", home, dir: home, selectorRevision: selected.revision };
|
|
89
150
|
}
|
|
90
151
|
|
|
@@ -142,7 +203,12 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
|
|
|
142
203
|
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
143
204
|
continue;
|
|
144
205
|
}
|
|
145
|
-
try {
|
|
206
|
+
try {
|
|
207
|
+
const result = work();
|
|
208
|
+
// A synchronous publisher must release its lock before a queued sync reader can block this event loop.
|
|
209
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") && typeof (result as PromiseLike<T>).then === "function") return await result;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
146
212
|
finally {
|
|
147
213
|
// A live owner's token cannot be reclaimed; do not remove a file another actor replaced.
|
|
148
214
|
if (existsSync(file) && !lstatSync(file).isSymbolicLink() && readFileSync(file, "utf8") === owner) { unlinkSync(file); fsyncDirectory(memoryDir); }
|
package/src/memory/migrate.ts
CHANGED
|
@@ -105,6 +105,7 @@ function refuseShardSelection(memoryDir: string): void {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
|
|
108
|
+
if (readSelector(options.memoryDir).selector?.schema === "agent-mem.profile/v2") throw new Error("public prune view is forward-only; deactivation is unavailable");
|
|
108
109
|
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
|
|
109
110
|
if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
|
|
110
111
|
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
package/src/memory/mutation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { chmodSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { realPath } from "./platform.ts";
|
|
4
5
|
|
|
5
6
|
export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
6
7
|
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
@@ -14,12 +15,31 @@ export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIME
|
|
|
14
15
|
return db;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
declare const mutationBrand: unique symbol;
|
|
19
|
+
/** Callable for compatibility; authority exists only in the module-private live registry. */
|
|
20
|
+
export interface MutationHandle { (): void; readonly [mutationBrand]: true }
|
|
21
|
+
const mutations = new WeakMap<MutationHandle, { dir: string; dev: number; ino: number; db: Database; active: boolean }>();
|
|
22
|
+
export function assertMutationHandle(handle: MutationHandle, dir: string): void {
|
|
23
|
+
const state = mutations.get(handle), stat = lstatSync(dir);
|
|
24
|
+
if (!state || !state.active || !state.db.inTransaction || stat.isSymbolicLink() || !stat.isDirectory()
|
|
25
|
+
|| realPath(dir) !== state.dir || stat.dev !== state.dev || stat.ino !== state.ino) throw new Error("mutation handle unavailable or directory changed");
|
|
26
|
+
}
|
|
27
|
+
/** Sync holds a separate connection across awaits; an unrelated call cannot bypass it. */
|
|
28
|
+
export function acquireMutation(dir: string): MutationHandle | undefined {
|
|
29
|
+
const stat = lstatSync(dir);
|
|
30
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("mutation directory unavailable");
|
|
31
|
+
const canonical = realPath(dir), db = mutationDatabase(canonical);
|
|
20
32
|
try { db.run("BEGIN IMMEDIATE"); }
|
|
21
33
|
catch { db.close(); return undefined; }
|
|
22
|
-
|
|
34
|
+
const release = (() => {
|
|
35
|
+
const state = mutations.get(release)!;
|
|
36
|
+
if (!state.active) return;
|
|
37
|
+
state.active = false;
|
|
38
|
+
try { db.run("ROLLBACK"); } finally { db.close(); }
|
|
39
|
+
}) as MutationHandle;
|
|
40
|
+
mutations.set(release, { dir: canonical, dev: stat.dev, ino: stat.ino, db, active: true });
|
|
41
|
+
try { assertMutationHandle(release, dir); } catch (error) { release(); throw error; }
|
|
42
|
+
return release;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/** Short local admission fence, never held across sync/network or data mutations. */
|
|
@@ -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
|
+
}
|