@staix/agent-mem 0.20.3 → 0.21.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 +4 -3
- package/package.json +2 -2
- package/src/cli.ts +3 -0
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +67 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +98 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
package/src/memory/shard.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readActivePlacementBinding } from "./sync-placement-active.ts";
|
|
2
|
+
import { validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
|
|
3
|
+
import { assertPlacementRuntime, SyncPlacementRuntimeError } from "./sync-placement-guard.ts";
|
|
1
4
|
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
2
5
|
import { indexFileName } from "./index-file.ts";
|
|
3
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -84,31 +87,46 @@ export function captureShardDir(base: string, key: string, machine: string): str
|
|
|
84
87
|
export const SHARDED_SCHEMA = "agent-mem.shards/v1";
|
|
85
88
|
export const SHARDED_V2_SCHEMA = "agent-mem.shards/v2";
|
|
86
89
|
export const SHARDED_V2_FIELDS = ["schema", "generation", "phase", "selectorRevision", "sourceGeneration", "sources", "machine", "legacyCommit", "deadLeasesRemoved"] as const;
|
|
87
|
-
export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
|
|
90
|
+
export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA | "agent-mem.shards/v3"; generation: string; phase: "active"; selectorRevision: string }
|
|
88
91
|
|
|
89
92
|
export function userShardDir(base: string): string {
|
|
90
93
|
return join(base, "user");
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
type ShardedJournal = Record<string, unknown> & ShardedSelection;
|
|
97
|
+
/** Keep the existing active reader shape and the migration's captured-v1/v2 state distinct from v3. */
|
|
98
|
+
export function legacyShardMarkerShape(value: unknown): boolean {
|
|
99
|
+
if (value === undefined) return true;
|
|
100
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
101
|
+
const row = value as Record<string, unknown>;
|
|
102
|
+
return (row.schema === SHARDED_SCHEMA || row.schema === SHARDED_V2_SCHEMA) && validGeneration(row.generation)
|
|
103
|
+
&& typeof row.phase === "string" && ["captured", "active"].includes(row.phase) && typeof row.selectorRevision === "string"
|
|
104
|
+
&& (row.schema !== SHARDED_V2_SCHEMA || !Object.keys(row).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key)));
|
|
105
|
+
}
|
|
94
106
|
function shardJournal(memoryDir: string): ShardedJournal {
|
|
95
107
|
const value = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
108
|
+
if (value?.schema === "agent-mem.shards/v3") {
|
|
109
|
+
const profile = validatePlacementProfile(readProfileJSON(join(memoryDir, "profile.json")));
|
|
110
|
+
return validatePlacementShards(value, profile) as unknown as ShardedJournal;
|
|
111
|
+
}
|
|
96
112
|
if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
|
|
97
113
|
if (value.schema === SHARDED_V2_SCHEMA && Object.keys(value).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key))) throw new Error("unsupported curated shard view");
|
|
98
114
|
return value as ShardedJournal;
|
|
99
115
|
}
|
|
100
116
|
function compatibleShardAdvance(before: ShardedJournal, after: ShardedJournal): boolean {
|
|
101
|
-
if (before.schema
|
|
117
|
+
if (![SHARDED_V2_SCHEMA, "agent-mem.shards/v3"].includes(before.schema) || after.schema !== before.schema) return false;
|
|
102
118
|
const { selectorRevision: _old, ...old } = before, { selectorRevision: _next, ...next } = after;
|
|
103
119
|
return canonicalJSON(old) === canonicalJSON(next);
|
|
104
120
|
}
|
|
105
121
|
function shardSnapshot(memoryDir: string, deadline: number) {
|
|
122
|
+
assertPlacementRuntime(memoryDir);
|
|
106
123
|
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
107
124
|
let journal = shardJournal(memoryDir);
|
|
108
125
|
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
109
126
|
let selector: ReturnType<typeof readSelector>;
|
|
110
127
|
try { selector = readSelector(memoryDir, deadline); }
|
|
111
128
|
catch (error) {
|
|
129
|
+
if (error instanceof SyncPlacementRuntimeError) throw error;
|
|
112
130
|
// The writer's spent wait budget or unavailable proof is never restarted here.
|
|
113
131
|
if (error instanceof Error && error.message === "public prune view unavailable") throw error;
|
|
114
132
|
const current = shardJournal(memoryDir);
|
|
@@ -120,8 +138,8 @@ function shardSnapshot(memoryDir: string, deadline: number) {
|
|
|
120
138
|
if (!compatibleShardAdvance(journal, current)) throw new Error("curated shard selector changed");
|
|
121
139
|
journal = current; continue;
|
|
122
140
|
}
|
|
123
|
-
if ((journal.schema === SHARDED_V2_SCHEMA) !== (selector.selector?.schema === "agent-mem.profile/v2")) throw new Error("curated shard selector changed");
|
|
124
|
-
if (journal.schema === SHARDED_V2_SCHEMA && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
|
|
141
|
+
if ((journal.schema === SHARDED_V2_SCHEMA || journal.schema === "agent-mem.shards/v3") !== (selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3")) throw new Error("curated shard selector changed");
|
|
142
|
+
if ((journal.schema === SHARDED_V2_SCHEMA || journal.schema === "agent-mem.shards/v3") && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
|
|
125
143
|
return { journal, selector };
|
|
126
144
|
}
|
|
127
145
|
throw new Error("curated shard selector changed");
|
|
@@ -135,6 +153,14 @@ export function readShardedSelection(memoryDir: string): ShardedSelection {
|
|
|
135
153
|
* ponytail: same-user concurrent parent replacement needs descriptor-relative opens; fixed preflight does not claim that fence.
|
|
136
154
|
*/
|
|
137
155
|
export function assertCuratedShardPaths(memoryDir: string, opts: { project?: string; user?: boolean }): void {
|
|
156
|
+
assertPlacementRuntime(memoryDir);
|
|
157
|
+
const selector = readProfileJSON(join(memoryDir, "profile.json")) as {schema?:unknown} | undefined;
|
|
158
|
+
if (selector?.schema === "agent-mem.profile/v3") {
|
|
159
|
+
const active = readActivePlacementBinding(memoryDir, selector, readProfileJSON(join(memoryDir, "shards.json")));
|
|
160
|
+
const keyHash = opts.user ? undefined : createHash("sha256").update(opts.project!).digest("hex");
|
|
161
|
+
if (!active || !active.activation.realms.some(realm => opts.user ? realm.scope === "user" : realm.scope === "project" && realm.keyHash === keyHash))
|
|
162
|
+
throw new Error("sync placement realm is outside the activated inventory; review its activation before opening it");
|
|
163
|
+
}
|
|
138
164
|
const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
|
|
139
165
|
const check = (path: string, directory: boolean) => {
|
|
140
166
|
try {
|
|
@@ -169,10 +195,10 @@ export function resolveShardProfile(memoryDir: string, opts: { project?: string;
|
|
|
169
195
|
const final = shardSnapshot(memoryDir, deadline), selector = final.selector;
|
|
170
196
|
if (canonicalJSON(final.journal) !== canonicalJSON(initial.journal) && !compatibleShardAdvance(initial.journal, final.journal)
|
|
171
197
|
|| selector.revision !== final.journal.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
|
|
172
|
-
if (selector.selector?.schema === "agent-mem.profile/v2") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
|
|
198
|
+
if (selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
|
|
173
199
|
const legacyMainHome = join(dir, "legacy-main");
|
|
174
200
|
const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
|
|
175
201
|
return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
|
|
176
|
-
...(selector.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash } : {}),
|
|
202
|
+
...((selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3") ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash, ...(selector.selector.schema === "agent-mem.profile/v3" ? { publicationFormat: selector.selector.publicationFormat, placementHead: selector.selector.placementHead } : {}) } : {}),
|
|
177
203
|
legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
178
204
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertPlacementRuntime, placementSelectorRefusal, SyncPlacementRuntimeError } from "./sync-placement-guard.ts";
|
|
1
2
|
import { assertFormatFloor } from "./formats.ts";
|
|
2
3
|
import { readPublicPruneView } from "./public-prune-view.ts";
|
|
3
4
|
import { INDEX_VERSION, indexFileName } from "./index-file.ts";
|
|
@@ -234,7 +235,10 @@ export class MemoryStore {
|
|
|
234
235
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
235
236
|
const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
236
237
|
assertFormatFloor(home, "curated");
|
|
238
|
+
const placementRefusal = placementSelectorRefusal(home);
|
|
239
|
+
if (placementRefusal) throw new SyncPlacementRuntimeError(placementRefusal);
|
|
237
240
|
if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
|
|
241
|
+
if (bootstrap) assertPlacementRuntime(home);
|
|
238
242
|
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
239
243
|
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
240
244
|
|| (!this.profile.sharded && this.profile.dir !== generationDir(this.profile.home, this.profile.generation))
|
|
@@ -1561,6 +1565,14 @@ export class MemoryStore {
|
|
|
1561
1565
|
});
|
|
1562
1566
|
}
|
|
1563
1567
|
|
|
1568
|
+
/** Canonical generation outbound sources only; no refresh, rebind or derived-state write.
|
|
1569
|
+
* Call the returned bounded recheck after visiting all opened targets, before declaring completeness.
|
|
1570
|
+
*/
|
|
1571
|
+
inspectOutboundRows(visit: (row: Record<string, unknown>) => void, limits: { maxBytes: number; maxRows: number }): () => void {
|
|
1572
|
+
if (!this.generationIndex) throw Object.assign(new Error("outbound generation input unavailable"), { code: "input-unavailable" });
|
|
1573
|
+
return this.generationIndex.inspectOutboundRows(visit, limits);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1564
1576
|
/** Person-local diagnostic receipt; malformed legacy bodies are never included. */
|
|
1565
1577
|
bridgeStatus(): { skippedRows: number } | undefined {
|
|
1566
1578
|
return this.generationIndex ? { skippedRows: this.generationIndex.skippedRows() } : undefined;
|
|
@@ -1622,7 +1634,7 @@ function initializeShard(home: string, opts: { project?: string; user?: boolean
|
|
|
1622
1634
|
writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
|
|
1623
1635
|
const selected = readSelector(home).selector;
|
|
1624
1636
|
const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
|
|
1625
|
-
...(selected?.schema === "agent-mem.profile/v2" ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash} : {}),
|
|
1637
|
+
...((selected?.schema === "agent-mem.profile/v2" || selected?.schema === "agent-mem.profile/v3") ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash, ...(selected.schema === "agent-mem.profile/v3" ? {publicationFormat:selected.publicationFormat,placementHead:selected.placementHead} : {})} : {}),
|
|
1626
1638
|
legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
1627
1639
|
buildGenerationIndex(profile, sources);
|
|
1628
1640
|
writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
|
|
@@ -1645,6 +1657,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1645
1657
|
const missing = !existsSync(join(dir, "generation.json"));
|
|
1646
1658
|
if (missing) {
|
|
1647
1659
|
const selected = readSelector(home).selector;
|
|
1660
|
+
if (selected?.schema === "agent-mem.profile/v3") throw new Error("sync placement realm was not activated; review a complete realm inventory before opening a new curated shard");
|
|
1648
1661
|
if (selected?.schema === "agent-mem.profile/v2") {
|
|
1649
1662
|
const view = readPublicPruneView(home, selected.pruneInputSetHash, selected.generation), key = opts.user ? "user" : `project:${opts.project}`;
|
|
1650
1663
|
if (view.realms[key] || ["generation-seal.json", "migration-sources.json", "machine.json"].some(name => existsSync(join(dir, name)))) throw new Error("v2 generation input unavailable; reader cannot repair committed inputs");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import type { MemoryStore } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
/** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
|
|
7
|
+
export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
|
|
8
|
+
/** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
|
|
9
|
+
export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
|
|
10
|
+
function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
|
|
11
|
+
const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
|
|
12
|
+
if (!store.profile.sharded) return unavailable();
|
|
13
|
+
const uid = process.getuid?.(); if (uid === undefined) return unavailable();
|
|
14
|
+
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
15
|
+
const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
|
|
16
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
|
|
17
|
+
|| stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
|
|
18
|
+
};
|
|
19
|
+
const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
|
|
20
|
+
const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
|
|
21
|
+
const directory = (dir: string): void => {
|
|
22
|
+
if (directories.has(dir)) return;
|
|
23
|
+
const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
|
|
24
|
+
if (dir !== home) directory(dirname(dir));
|
|
25
|
+
const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
|
|
26
|
+
emit(["directory", dir, String(before.dev), String(before.ino)]);
|
|
27
|
+
checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
|
|
28
|
+
};
|
|
29
|
+
// Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
|
|
30
|
+
const source = (file: string, privateMode = true): void => {
|
|
31
|
+
directory(dirname(file));
|
|
32
|
+
let before: BigIntStats;
|
|
33
|
+
try { before = lstatSync(file, { bigint: true }); }
|
|
34
|
+
catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
36
|
+
emit(["source", file, "absent"]);
|
|
37
|
+
checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
|
|
38
|
+
}
|
|
39
|
+
owned(before, false, privateMode);
|
|
40
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
|
|
43
|
+
const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
|
|
44
|
+
for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
|
|
45
|
+
const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
|
|
46
|
+
if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
|
|
47
|
+
emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
|
|
48
|
+
} finally { closeSync(fd); }
|
|
49
|
+
checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
directory(home);
|
|
53
|
+
for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
|
|
54
|
+
const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
55
|
+
for (const target of targets) {
|
|
56
|
+
directory(target.dir);
|
|
57
|
+
emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
|
|
58
|
+
for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
|
|
59
|
+
}
|
|
60
|
+
for (const check of checks) check();
|
|
61
|
+
return digest.digest("hex");
|
|
62
|
+
} catch { return unavailable(); }
|
|
63
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Metadata-only v3 admission. Publication still requires the complete journal/source/policy proof. */
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
5
|
+
import { realPath } from "./platform.ts";
|
|
6
|
+
import { validatePlacementProfile, validatePlacementShards, type PlacementProfile, type PlacementShards } from "./sync-placement-format.ts";
|
|
7
|
+
import { validatePlacementPlan, type SyncPlacementActivationPlan } from "./sync-placement-plan.ts";
|
|
8
|
+
import { placementEpochHash, validatePlacementEpoch, validatePlacementActiveFence, type PlacementEpoch } from "./sync-placement-journal-format.ts";
|
|
9
|
+
import { readPublicPruneViewForActivation } from "./public-prune-view.ts";
|
|
10
|
+
import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
|
|
11
|
+
import { shardDir, userShardDir } from "./shard.ts";
|
|
12
|
+
|
|
13
|
+
export interface ActivePlacementBinding { profile: PlacementProfile; shards: PlacementShards; epoch: PlacementEpoch; activation: SyncPlacementActivationPlan }
|
|
14
|
+
function fail(): never { throw new Error("sync placement activation metadata unavailable or changed; inspect the retained activation before continuing"); }
|
|
15
|
+
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
16
|
+
function owned(s: BigIntStats, directory = false): void {
|
|
17
|
+
if (s.uid !== BigInt(process.getuid?.() ?? -1) || (s.mode & 0o077n) !== 0n || s.isSymbolicLink()
|
|
18
|
+
|| (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1n)) fail();
|
|
19
|
+
}
|
|
20
|
+
/** Initial activation commitments remain authoritative; maintenance may only grow the same-generation prune view. */
|
|
21
|
+
export function assertPlacementViewAdvance(home: string, activation: SyncPlacementActivationPlan, profile: PlacementProfile, shards: PlacementShards): void {
|
|
22
|
+
const initial = activation.targetProfile;
|
|
23
|
+
const { pruneInputSetHash: _old, ...before } = initial, { pruneInputSetHash: _next, ...after } = profile;
|
|
24
|
+
if (canonicalJSON(before) !== canonicalJSON(after)) fail();
|
|
25
|
+
const initialShards = { ...shards, selectorRevision: hashJSON(initial) };
|
|
26
|
+
if (hashJSON(initialShards) !== activation.targetShardsHash) fail();
|
|
27
|
+
if (initial.pruneInputSetHash === profile.pruneInputSetHash) return;
|
|
28
|
+
const oldView = readPublicPruneViewForActivation(home, initial.pruneInputSetHash, profile.generation);
|
|
29
|
+
const nextView = readPublicPruneViewForActivation(home, profile.pruneInputSetHash, profile.generation);
|
|
30
|
+
if (!Object.keys(oldView.realms).every(key => Object.hasOwn(nextView.realms, key))) fail();
|
|
31
|
+
for (const [key, hash] of Object.entries(nextView.realms)) {
|
|
32
|
+
const old = oldView.realms[key]; if (old === hash) continue;
|
|
33
|
+
const dir = key === "user" ? userShardDir(home) : shardDir(home, key.slice(8));
|
|
34
|
+
const chain = readPublicPruneInputSetChain({ dir, receivingGeneration: profile.generation, inputSetHash: hash });
|
|
35
|
+
if (old !== undefined && !chain.commitments.includes(old)) fail();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** No mutation/reader registration/index open. Missing preparation is distinct from malformed retained evidence. */
|
|
39
|
+
export function readActivePlacementBinding(homeInput: string, profileInput: unknown, shardsInput: unknown): ActivePlacementBinding | undefined {
|
|
40
|
+
const profile = validatePlacementProfile(profileInput), shards = validatePlacementShards(shardsInput, profile);
|
|
41
|
+
const home = realPath(homeInput), root = lstatSync(home, { bigint: true }); owned(root, true);
|
|
42
|
+
const checks: (() => void)[] = [() => { const now = lstatSync(home, { bigint: true }); owned(now, true); if (now.dev !== root.dev || now.ino !== root.ino) fail(); }];
|
|
43
|
+
const directory = (dir: string): boolean => {
|
|
44
|
+
const s = lstatSync(dir, { bigint: true, throwIfNoEntry: false }); if (!s) return false; owned(s, true);
|
|
45
|
+
checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (s.dev !== now.dev || s.ino !== now.ino) fail(); }); return true;
|
|
46
|
+
};
|
|
47
|
+
const read = (file: string): { value: unknown; text: string } | undefined => {
|
|
48
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
|
|
49
|
+
owned(before); if (before.size <= 0n || before.size > 262144n) fail();
|
|
50
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
51
|
+
try {
|
|
52
|
+
const s = fstatSync(fd, { bigint: true }); owned(s); if (!same(before, s)) fail();
|
|
53
|
+
const bytes = Buffer.alloc(Number(s.size) + 1); let at = 0;
|
|
54
|
+
while (at < bytes.length) { const n = readSync(fd, bytes, at, bytes.length - at, at); if (!n) break; at += n; }
|
|
55
|
+
if (at !== Number(s.size) || !same(s, fstatSync(fd, { bigint: true })) || !same(s, lstatSync(file, { bigint: true }))) fail();
|
|
56
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, at)), value: unknown = JSON.parse(text);
|
|
57
|
+
if (canonicalJSON(value) + "\n" !== text) fail();
|
|
58
|
+
checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now); if (!same(s, now)) fail(); }); return { value, text };
|
|
59
|
+
} finally { closeSync(fd); }
|
|
60
|
+
};
|
|
61
|
+
if (!directory(join(home, "sync-placement")) || !directory(join(home, "sync-placement", "epochs"))) return undefined;
|
|
62
|
+
const dir = join(home, "sync-placement", "epochs", profile.placementHead);
|
|
63
|
+
if (!directory(dir)) return undefined;
|
|
64
|
+
const epochFile = read(join(dir, "epoch.json")), activationFile = read(join(dir, "activation.json")), fenceFile = read(join(home, "sync-placement", "active.json"));
|
|
65
|
+
if (!epochFile || !activationFile || !fenceFile) return undefined;
|
|
66
|
+
const epoch = validatePlacementEpoch(epochFile.value), activation = validatePlacementPlan(activationFile.value), fence = validatePlacementActiveFence(fenceFile.value);
|
|
67
|
+
const homeHash = hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]);
|
|
68
|
+
if (epoch.homeHash !== homeHash || epoch.generation !== profile.generation || placementEpochHash(epoch) !== profile.placementHead
|
|
69
|
+
|| activation.contextHash !== epoch.contextHash || activation.generation !== epoch.generation || activation.targetProfile.placementHead !== profile.placementHead
|
|
70
|
+
|| fence.epochHash !== profile.placementHead || fence.homeHash !== homeHash || fence.generation !== profile.generation) fail();
|
|
71
|
+
if (read(join(home, "profile.json"))?.text !== canonicalJSON(profile) + "\n" || read(join(home, "shards.json"))?.text !== canonicalJSON(shards) + "\n") fail();
|
|
72
|
+
assertPlacementViewAdvance(home, activation, profile, shards);
|
|
73
|
+
for (const check of checks) check();
|
|
74
|
+
return { profile, shards, epoch, activation };
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Shared private cache metadata validation. These checks never initialize, repair or publish a repository. */
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON } from "./generation.ts";
|
|
5
|
+
import { SyncPlacementJournalError } from "./sync-placement-journal-format.ts";
|
|
6
|
+
|
|
7
|
+
function fail(): never { throw new SyncPlacementJournalError("corrupt"); }
|
|
8
|
+
export function placementCacheRootStamp(home: string): string {
|
|
9
|
+
const stat = lstatSync(join(home, "shard-sync-placement"), { bigint: true });
|
|
10
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (stat.mode & 0o077n) !== 0n) fail();
|
|
11
|
+
return canonicalJSON([String(stat.dev), String(stat.ino), String(stat.uid), String(stat.mode)]);
|
|
12
|
+
}
|
|
13
|
+
export function placementCacheConfigStamp(cache: string): string {
|
|
14
|
+
const stat = lstatSync(join(cache, "config"), { bigint: true });
|
|
15
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || stat.uid !== BigInt(process.getuid?.() ?? -1)
|
|
16
|
+
|| (stat.mode & 0o077n) !== 0n || stat.size > 65536n) fail();
|
|
17
|
+
return canonicalJSON([String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]);
|
|
18
|
+
}
|
|
19
|
+
export function validatePlacementCacheConfig(output: string): ReadonlyMap<string, string> {
|
|
20
|
+
const seen = new Map<string, string>();
|
|
21
|
+
const booleans = new Set(["core.filemode", "core.bare", "core.ignorecase", "core.precomposeunicode", "core.protecthfs", "core.protectntfs", "core.logallrefupdates", "core.symlinks"]);
|
|
22
|
+
for (const row of output.split("\0").filter(Boolean)) {
|
|
23
|
+
const at = row.indexOf("\n"); if (at < 1) fail();
|
|
24
|
+
const key = row.slice(0, at), value = row.slice(at + 1);
|
|
25
|
+
if (seen.has(key)) fail(); seen.set(key, value);
|
|
26
|
+
if (booleans.has(key)) { if (value !== "true" && value !== "false") fail(); }
|
|
27
|
+
else if (key === "core.repositoryformatversion") { if (value !== "0" && value !== "1") fail(); }
|
|
28
|
+
else if (key === "extensions.objectformat") { if (value !== "sha1") fail(); }
|
|
29
|
+
else if (!["remote.origin.url", "remote.origin.fetch", "remote.origin.pushurl"].includes(key)) fail();
|
|
30
|
+
}
|
|
31
|
+
return seen;
|
|
32
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { PlacementPublicationSource } from "./sync-placement-publication-plan.ts";
|
|
2
|
+
import { validatePlacementKey, type PlacementRealm } from "./sync-placement-journal-format.ts";
|
|
3
|
+
import { types } from "node:util";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { canonicalJSON, generationDescriptor, hashJSON, validateVaultLinkRow, type BoundProfile, type VaultLinkRow } from "./generation.ts";
|
|
8
|
+
import { validatePlacementBaseV2, validatePlacementProfile, validatePlacementShards, PLACEMENT_PROFILE_SCHEMA } from "./sync-placement-format.ts";
|
|
9
|
+
import { planSyncPlacementActivation, type SyncPlacementActivationContext, type SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
|
|
10
|
+
import { readPublicPruneViewForActivation, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
|
|
11
|
+
import { realPath, DENY_NAMES, DENY_SEGMENTS } from "./platform.ts";
|
|
12
|
+
import { loadAmemConfig } from "./config.ts";
|
|
13
|
+
import { memoryPolicySnapshot } from "./policy.ts";
|
|
14
|
+
import { SegmentReader, SegmentReadBudgetError, checkedEntry, completeLines } from "./bridge.ts";
|
|
15
|
+
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
16
|
+
import { coalesceGenerationSource } from "./generation-index.ts";
|
|
17
|
+
import { validatePruneRow, type PruneSourceRow, type PruneRow } from "./prune.ts";
|
|
18
|
+
import { shardKeyHash, SHARD_SCHEMA } from "./shard.ts";
|
|
19
|
+
import { SyncPlacementJournalError, placementKeyId, type PlacementKey } from "./sync-placement-journal-format.ts";
|
|
20
|
+
import type { Entry } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
/** Host authentication/compatibility facts are re-read at every effect boundary, never inferred from a session marker. */
|
|
23
|
+
export interface PlacementCapturePort {
|
|
24
|
+
attest(): { writers: readonly SyncPlacementWriterAttestation[]; legacySyncQuiesced: true };
|
|
25
|
+
/** Required when a caller adds a policy callback; must change whenever its policy changes. */
|
|
26
|
+
policyVersion?(): string;
|
|
27
|
+
}
|
|
28
|
+
/** Internal actual-transport proof, admitted only after the native pending plan and fetched original were validated. */
|
|
29
|
+
export interface PlacementPendingSource { realm: PlacementRealm; pathHash: string; offset: number; prefixHash: string; line: string }
|
|
30
|
+
export interface PlacementSourceRow { key: PlacementKey; row: Entry; sourcePlacement: "local" | "synced"; intrinsicLocal: boolean }
|
|
31
|
+
export interface PlacementSpecialSourceRow { key: PlacementKey; row: PruneRow | VaultLinkRow; sourcePlacement: "local" | "synced"; intrinsicLocal: boolean }
|
|
32
|
+
export interface PlacementCapture {
|
|
33
|
+
home: string; context: SyncPlacementActivationContext; activeHead?: string;
|
|
34
|
+
markerHash: string; fingerprint: string; directories: string[]; prefixes: PlacementPublicationSource[]; rows: Map<string, PlacementSourceRow>;
|
|
35
|
+
/** Diagnostic inventory only; these rows never enter the ordinary transport/capture row map implicitly. */
|
|
36
|
+
specialRows: Map<string, PlacementSpecialSourceRow>;
|
|
37
|
+
sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>;
|
|
38
|
+
assertCurrent(): void;
|
|
39
|
+
/** Under live writer fences, inode/ctime/size checks protect the already-hashed prefixes across callbacks without reparsing rows. */
|
|
40
|
+
assertUnchanged(): void;
|
|
41
|
+
}
|
|
42
|
+
function fail(code: "changed" | "invalid" | "bound" = "changed"): never { throw new SyncPlacementJournalError(code); }
|
|
43
|
+
const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
|
|
44
|
+
const stamp = (s: BigIntStats) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].map(String);
|
|
45
|
+
const same = (a: BigIntStats, b: BigIntStats) => canonicalJSON(stamp(a)) === canonicalJSON(stamp(b));
|
|
46
|
+
function ownedDirectory(dir: string): BigIntStats {
|
|
47
|
+
const s = lstatSync(dir, { bigint: true });
|
|
48
|
+
if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== BigInt(process.getuid!()) || (s.mode & 0o022n) !== 0n) fail();
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
/** ponytail: capture is capped at 256 metadata objects and 8 MiB/20000 source rows across all realms.
|
|
52
|
+
* Larger homes need a reviewed chunked activation proof; no input is silently omitted at this ceiling. */
|
|
53
|
+
function capture(homeInput: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = []): Omit<PlacementCapture, "assertCurrent"> {
|
|
54
|
+
let selection: PlacementRealm[] | undefined;
|
|
55
|
+
if (selected !== undefined) {
|
|
56
|
+
if (!Array.isArray(selected) || types.isProxy(selected) || selected.length > 256) fail("bound");
|
|
57
|
+
const copied = memoryPolicySnapshot(selected); if (!copied) fail();
|
|
58
|
+
selection = copied.map(realm => validatePlacementKey({ realm, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm);
|
|
59
|
+
if (!selection.length || new Set(selection.map(realm => canonicalJSON(realm))).size !== selection.length) fail();
|
|
60
|
+
}
|
|
61
|
+
// All host callouts precede filesystem capture: a callback cannot alter input after its final proof.
|
|
62
|
+
const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
|
|
63
|
+
if (!attestation) fail();
|
|
64
|
+
if (port.policyVersion && typeof policyVersion !== "string") fail();
|
|
65
|
+
const home = realPath(homeInput), root = ownedDirectory(home);
|
|
66
|
+
let metadataObjects = 0, metadataBytes = 0;
|
|
67
|
+
const facts: unknown[] = [], sourceChecks: (() => void)[] = [];
|
|
68
|
+
const pendingByPath = new Map(pending.map(item => [canonicalJSON(item.realm)+":"+item.pathHash,item]));
|
|
69
|
+
if (pendingByPath.size !== pending.length) fail();
|
|
70
|
+
const directoryFact = (dir: string) => {
|
|
71
|
+
const stat = ownedDirectory(dir); facts.push([dir, "directory", String(stat.dev), String(stat.ino), String(stat.uid)]);
|
|
72
|
+
sourceChecks.push(() => { const now=ownedDirectory(dir); if(now.dev!==stat.dev||now.ino!==stat.ino) fail(); });
|
|
73
|
+
};
|
|
74
|
+
const bytes = (file: string, optional = false): Buffer | undefined => {
|
|
75
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
76
|
+
if (!before) { if (!optional) fail(); facts.push([file, null]); sourceChecks.push(() => { if(lstatSync(file,{throwIfNoEntry:false})) fail(); }); return undefined; }
|
|
77
|
+
if (++metadataObjects > 256 || before.size > 262144n || metadataBytes + Number(before.size) > 8 * 1024 * 1024) fail("bound");
|
|
78
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.uid !== root.uid || (before.mode & 0o022n) !== 0n) fail();
|
|
79
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
80
|
+
try {
|
|
81
|
+
const opened = fstatSync(fd, { bigint: true }); if (!same(before, opened)) fail();
|
|
82
|
+
const out = Buffer.alloc(Number(opened.size)); let at = 0;
|
|
83
|
+
while (at < out.length) { const count = readSync(fd, out, at, out.length - at, at); if (!count) fail(); at += count; }
|
|
84
|
+
const after = lstatSync(file, { bigint: true }); if (!same(opened, fstatSync(fd, { bigint: true })) || !same(opened, after)) fail();
|
|
85
|
+
metadataBytes += out.length; facts.push([file, stamp(opened), digest(out)]);
|
|
86
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true}); if(!now.isFile()||now.isSymbolicLink()||now.nlink!==1n||!same(opened,now)) fail(); }); return out;
|
|
87
|
+
} finally { closeSync(fd); }
|
|
88
|
+
};
|
|
89
|
+
const json = (file: string, optional = false): any => {
|
|
90
|
+
const input = bytes(file, optional); if (!input) return undefined;
|
|
91
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input));
|
|
92
|
+
};
|
|
93
|
+
const rawProfile = json(join(home, "profile.json")), rawShards = json(join(home, "shards.json"));
|
|
94
|
+
let activeHead: string | undefined;
|
|
95
|
+
let base: ReturnType<typeof validatePlacementBaseV2>;
|
|
96
|
+
if (rawProfile?.schema === PLACEMENT_PROFILE_SCHEMA) {
|
|
97
|
+
const profile = validatePlacementProfile(rawProfile), shards = validatePlacementShards(rawShards, profile);
|
|
98
|
+
activeHead = profile.placementHead;
|
|
99
|
+
const { publicationFormat: _pf, placementHead: _ph, ...p } = profile;
|
|
100
|
+
const profile2 = { ...p, schema: "agent-mem.profile/v2" };
|
|
101
|
+
const { publicationFormat: _sf, placementHead: _sh, ...s } = shards;
|
|
102
|
+
base = validatePlacementBaseV2(profile2, { ...s, schema: "agent-mem.shards/v2", selectorRevision: hashJSON(profile2) });
|
|
103
|
+
} else base = validatePlacementBaseV2(rawProfile, rawShards);
|
|
104
|
+
const markerHash = hashJSON({ profile: rawProfile, shards: rawShards });
|
|
105
|
+
bytes(join(home, "public-prune-input-sets", `${base.profile.pruneInputSetHash}.json`));
|
|
106
|
+
readPublicPruneViewForActivation(home, base.profile.pruneInputSetHash, base.profile.generation);
|
|
107
|
+
const aliases = bytes(join(home, "aliases.jsonl"), true);
|
|
108
|
+
bytes(join(home, "amem.config.json"), true);
|
|
109
|
+
const machine = json(join(home, "machine.json"));
|
|
110
|
+
if (machine?.id !== base.shards.machine.id) fail();
|
|
111
|
+
// An in-flight migration/prune is never accepted as a stable placement source.
|
|
112
|
+
for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT]) if (lstatSync(join(home, name), { throwIfNoEntry: false })) fail();
|
|
113
|
+
const reader = new SegmentReader(16 * 1024 * 1024, { maxSourceBytes: 8 * 1024 * 1024, maxRows: 20000 });
|
|
114
|
+
const rows = new Map<string, PlacementSourceRow>(), specialRows = new Map<string, PlacementSpecialSourceRow>(), sourceRowsByRealm = new Map<string, readonly PruneSourceRow[]>();
|
|
115
|
+
const directories = [home], prefixes: PlacementPublicationSource[] = [];
|
|
116
|
+
const realms: SyncPlacementActivationContext["realms"][number][] = [];
|
|
117
|
+
const listing = (dir: string, optional = false): string[] => {
|
|
118
|
+
const s = lstatSync(dir, { throwIfNoEntry: false });
|
|
119
|
+
if (!s) { if (!optional) fail(); facts.push([dir, null]); sourceChecks.push(() => {if(lstatSync(dir,{throwIfNoEntry:false}))fail();}); return []; }
|
|
120
|
+
directoryFact(dir); const listStamp = lstatSync(dir,{bigint:true});
|
|
121
|
+
sourceChecks.push(() => { if(!same(listStamp,lstatSync(dir,{bigint:true}))) fail(); });
|
|
122
|
+
const iterator = opendirSync(dir), names: string[] = [];
|
|
123
|
+
try { for (;;) { const item = iterator.readSync(); if (!item) break; if (names.length >= 256) fail("bound"); names.push(item.name); } }
|
|
124
|
+
finally { iterator.closeSync(); }
|
|
125
|
+
names.sort(); facts.push([dir, names]); return names;
|
|
126
|
+
};
|
|
127
|
+
const realm = (dir: string, identity: PlacementKey["realm"], project?: string) => {
|
|
128
|
+
directories.push(dir);
|
|
129
|
+
const beforeFacts = facts.length; directoryFact(dir);
|
|
130
|
+
if (project !== undefined) {
|
|
131
|
+
const descriptor = json(join(dir, "project.json"));
|
|
132
|
+
if (descriptor?.schema !== SHARD_SCHEMA || descriptor.key !== project || typeof descriptor.local !== "boolean") fail();
|
|
133
|
+
}
|
|
134
|
+
const descriptor = json(join(dir, "generation.json")), sources = json(join(dir, "migration-sources.json")), seal = json(join(dir, "generation-seal.json"));
|
|
135
|
+
if (canonicalJSON(descriptor) !== canonicalJSON(generationDescriptor(base.profile.generation)) || !Array.isArray(sources?.sources)
|
|
136
|
+
|| sources.schema !== "agent-mem.migration-sources/v1" || seal?.schema !== "agent-mem.generation-seal/v1"
|
|
137
|
+
|| seal.generation !== base.profile.generation || seal.descriptorHash !== hashJSON(descriptor)
|
|
138
|
+
|| seal.sourceDigest !== hashJSON({ sources: sources.sources, legacyCommit: sources.legacyCommit })
|
|
139
|
+
|| json(join(dir, "machine.json"))?.id !== machine.id) fail();
|
|
140
|
+
const hasMain = !!lstatSync(join(dir, "legacy-main"), { throwIfNoEntry: false });
|
|
141
|
+
const profile: BoundProfile = { kind: "generation", home, dir, generation: base.profile.generation, selectorRevision: hashJSON(base.profile),
|
|
142
|
+
sharded: true, legacyHome: join(dir, "legacy"), ...(hasMain ? { legacyMainHome: join(dir, "legacy-main") } : {}) };
|
|
143
|
+
for (const root of [profile.legacyHome!, ...(hasMain ? [profile.legacyMainHome!] : [])]) {
|
|
144
|
+
directoryFact(root); bytes(join(root, "machine.json"), true);
|
|
145
|
+
for(const folder of ["log","local"]) {
|
|
146
|
+
const file=join(root,folder), s=lstatSync(file,{bigint:true,throwIfNoEntry:false});
|
|
147
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true,throwIfNoEntry:false}); if(s ? !now||!same(s,now) : now!==undefined) fail(); });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const segments = readShardLegacySnapshot(profile, { reader });
|
|
151
|
+
for (const folder of ["log", "local"]) {
|
|
152
|
+
directoryFact(join(dir, folder));
|
|
153
|
+
const folderPath=join(dir,folder), folderStamp=lstatSync(folderPath,{bigint:true});
|
|
154
|
+
sourceChecks.push(() => { if(!same(folderStamp,lstatSync(folderPath,{bigint:true}))) fail(); });
|
|
155
|
+
if(folder === "local") bytes(join(dir,folder,`publisher-boundary-${machine.id}.json`),true);
|
|
156
|
+
for (const name of reader.listFiles(join(dir, folder))) {
|
|
157
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
158
|
+
if ( !/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) fail();
|
|
159
|
+
segments.push({ ...reader.read(join(dir, folder, name), undefined, true), source: "generation", path: `${folder}/${name}`, local: folder === "local" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const presence = new Map<string, PruneSourceRow>();
|
|
163
|
+
const special = new Map<string, PlacementSpecialSourceRow>();
|
|
164
|
+
const retainSpecial = (row: PruneRow | VaultLinkRow, local: boolean) => {
|
|
165
|
+
const key = { realm: identity, id: row.id, rowHash: hashJSON(row) }, prior = special.get(row.id);
|
|
166
|
+
if (prior && (prior.key.rowHash !== key.rowHash || prior.sourcePlacement !== (local ? "local" : "synced"))) fail();
|
|
167
|
+
special.set(row.id, { key, row, sourcePlacement: local ? "local" : "synced", intrinsicLocal: row.op === "vault_link" });
|
|
168
|
+
};
|
|
169
|
+
for (const segment of segments) {
|
|
170
|
+
const file = join(segment.source === "generation" ? dir : segment.source === "legacy-main" ? profile.legacyMainHome! : profile.legacyHome!, segment.path);
|
|
171
|
+
const fileStat = lstatSync(file);
|
|
172
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.nlink !== 1 || fileStat.uid !== process.getuid?.() || (fileStat.mode & 0o022) !== 0) fail();
|
|
173
|
+
let sourceHash = segment.hash, sourceLength = segment.offset;
|
|
174
|
+
if (segment.tailBytes) {
|
|
175
|
+
const proof = pendingByPath.get(canonicalJSON(identity)+":"+digest(segment.path));
|
|
176
|
+
if (segment.source !== "generation" || !proof || proof.offset !== segment.offset || proof.prefixHash !== segment.hash
|
|
177
|
+
|| segment.tailBytes >= Buffer.byteLength(proof.line) || segment.tailBytes > 16384) fail();
|
|
178
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
179
|
+
try {
|
|
180
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
181
|
+
if (canonicalJSON(stamp(opened)) !== segment.stamp) fail();
|
|
182
|
+
const tail = Buffer.alloc(segment.tailBytes); let at = 0;
|
|
183
|
+
while (at < tail.length) { const n = readSync(fd, tail, at, tail.length-at, segment.offset+at); if (!n) fail(); at+=n; }
|
|
184
|
+
if (!tail.equals(Buffer.from(proof.line).subarray(0,tail.length)) || !same(opened,fstatSync(fd,{bigint:true})) || !same(opened,lstatSync(file,{bigint:true}))) fail();
|
|
185
|
+
sourceHash = createHash("sha256").update(segment.bytes).update(tail).digest("hex"); sourceLength += tail.length;
|
|
186
|
+
} finally { closeSync(fd); }
|
|
187
|
+
}
|
|
188
|
+
facts.push([dir, segment.source, segment.path, sourceLength, sourceHash, segment.stamp]);
|
|
189
|
+
const fileStamp=lstatSync(file,{bigint:true});
|
|
190
|
+
if(canonicalJSON(stamp(fileStamp))!==segment.stamp) fail();
|
|
191
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true}); if(now.isSymbolicLink()||!now.isFile()||now.nlink!==1n||!same(fileStamp,now)) fail(); });
|
|
192
|
+
if (!segment.stamp || !["generation", "legacy-local", "legacy-main"].includes(segment.source)) fail();
|
|
193
|
+
const physical: unknown = JSON.parse(segment.stamp);
|
|
194
|
+
if (!Array.isArray(physical) || physical.length !== 5 || !physical.every(value => typeof value === "string" && /^[0-9]+$/.test(value))) fail();
|
|
195
|
+
prefixes.push({ realm: identity, source: segment.source as PlacementPublicationSource["source"], pathHash: digest(segment.path),
|
|
196
|
+
dev: physical[0] as string, ino: physical[1] as string, byteLength: sourceLength, hash: sourceHash,
|
|
197
|
+
mtimeNs: physical[3] as string, ctimeNs: physical[4] as string });
|
|
198
|
+
for (const line of completeLines(segment.bytes)) {
|
|
199
|
+
if (!line.length) continue;
|
|
200
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line));
|
|
201
|
+
if (segment.source === "generation" && parsed?.op === "prune") {
|
|
202
|
+
const row = validatePruneRow(parsed, profile.generation, segment.local ? "local" : "synced");
|
|
203
|
+
if (segment.path !== `${segment.local ? "local" : "log"}/${row.machine}.jsonl`) fail();
|
|
204
|
+
retainSpecial(row, segment.local);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (segment.source === "generation" && parsed?.op === "vault_link") {
|
|
208
|
+
const row = validateVaultLinkRow(parsed, profile.generation, segment.local ? "local" : "synced");
|
|
209
|
+
if (segment.path !== `local/${row.machine}.jsonl`) fail();
|
|
210
|
+
retainSpecial(row, true);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const entry = checkedEntry(parsed);
|
|
214
|
+
presence.set(entry.id, coalesceGenerationSource(presence.get(entry.id), entry, segment));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const source of special.values()) {
|
|
218
|
+
if (presence.has(source.key.id)) fail();
|
|
219
|
+
specialRows.set(placementKeyId(source.key), source);
|
|
220
|
+
}
|
|
221
|
+
sourceRowsByRealm.set(canonicalJSON(identity), [...presence.values()]);
|
|
222
|
+
for (const source of presence.values()) {
|
|
223
|
+
const key = { realm: identity, id: source.row.id, rowHash: hashJSON(source.row) };
|
|
224
|
+
rows.set(placementKeyId(key), { key, row: source.row, sourcePlacement: source.placement,
|
|
225
|
+
intrinsicLocal: source.row.op === "settle" || source.row.op === "note" && source.row.tier === "short" });
|
|
226
|
+
}
|
|
227
|
+
const bindingHash = hashJSON(facts.slice(beforeFacts));
|
|
228
|
+
realms.push({ ...identity, bindingHash });
|
|
229
|
+
};
|
|
230
|
+
if (!selection || selection.some(item => item.scope === "user")) realm(join(home, "user"), { scope: "user" });
|
|
231
|
+
for (const name of listing(join(home, "projects"), true)) {
|
|
232
|
+
if (!/^[a-f0-9]{16}$/.test(name)) fail();
|
|
233
|
+
if (selection && !selection.some(item => item.scope === "project" && item.keyHash.slice(0,16) === name)) continue;
|
|
234
|
+
const dir = join(home, "projects", name), project = json(join(dir, "project.json"));
|
|
235
|
+
if (typeof project?.key !== "string" || shardKeyHash(project.key) !== name) fail();
|
|
236
|
+
// Capture scaffolds are not curated realms, but their identity still binds this inventory.
|
|
237
|
+
if (!lstatSync(join(dir, "generation.json"), { throwIfNoEntry: false })) continue;
|
|
238
|
+
const keyHash = digest(project.key);
|
|
239
|
+
if (selection && !selection.some(item => item.scope === "project" && item.keyHash === keyHash)) fail();
|
|
240
|
+
realm(dir, { scope: "project", keyHash }, project.key);
|
|
241
|
+
}
|
|
242
|
+
if (selection && realms.length !== selection.length) fail();
|
|
243
|
+
const defaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS }); if (!defaults) fail();
|
|
244
|
+
const configuration = loadAmemConfig(home).config;
|
|
245
|
+
const context: SyncPlacementActivationContext = { ...base, homeHash: hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]), sourceHash: hashJSON(facts),
|
|
246
|
+
aliasesHash: digest(aliases ?? Buffer.alloc(0)), policyHash: hashJSON({ config: configuration, defaults, policyVersion }), realms,
|
|
247
|
+
writers: attestation.writers, legacySyncQuiesced: attestation.legacySyncQuiesced };
|
|
248
|
+
// Reuse the closed stage-1 context validator, including writer/version/realm bounds.
|
|
249
|
+
const validated = planSyncPlacementActivation(context, "0".repeat(64));
|
|
250
|
+
context.writers = validated.writers; context.realms = validated.realms;
|
|
251
|
+
// ponytail: fast rechecks rely on held mutation fences plus native inode/size/ctime identity for the already-hashed prefixes.
|
|
252
|
+
// A hostile same-user raw writer is outside that contract; descriptor-bound immutable source blocks are the upgrade path.
|
|
253
|
+
const assertUnchanged = () => {
|
|
254
|
+
try {
|
|
255
|
+
// Host callbacks run first; the final filesystem proof never invokes caller code afterwards.
|
|
256
|
+
const currentAttestation=memoryPolicySnapshot(port.attest()), currentVersion=port.policyVersion?.();
|
|
257
|
+
if(!currentAttestation) fail();
|
|
258
|
+
const checked=planSyncPlacementActivation({...context,writers:currentAttestation.writers,legacySyncQuiesced:currentAttestation.legacySyncQuiesced},"0".repeat(64));
|
|
259
|
+
const currentDefaults=memoryPolicySnapshot({names:DENY_NAMES,segments:DENY_SEGMENTS});
|
|
260
|
+
if(!currentDefaults)fail();
|
|
261
|
+
if(canonicalJSON(checked.writers)!==canonicalJSON(context.writers)||hashJSON({config:configuration,defaults:currentDefaults,policyVersion:currentVersion})!==context.policyHash) fail();
|
|
262
|
+
const now=ownedDirectory(home); if(now.dev!==root.dev||now.ino!==root.ino) fail();
|
|
263
|
+
for(const check of sourceChecks)check();
|
|
264
|
+
} catch { fail(); }
|
|
265
|
+
};
|
|
266
|
+
return { home, context, assertUnchanged, ...(activeHead ? { activeHead } : {}), markerHash, fingerprint: hashJSON({ context, markerHash }), directories: directories.sort(), prefixes, rows, specialRows, sourceRowsByRealm };
|
|
267
|
+
}
|
|
268
|
+
export function captureSyncPlacementContext(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pendingInput: readonly PlacementPendingSource[] = []): PlacementCapture {
|
|
269
|
+
try {
|
|
270
|
+
const selection = selected === undefined ? undefined : memoryPolicySnapshot(selected);
|
|
271
|
+
if (selected !== undefined && !selection) fail();
|
|
272
|
+
if (!Array.isArray(pendingInput) || types.isProxy(pendingInput) || pendingInput.length > 20000) fail("bound");
|
|
273
|
+
const pending = pendingInput.map(item => {
|
|
274
|
+
if (!item || typeof item !== "object" || types.isProxy(item)) fail();
|
|
275
|
+
const fields = Object.getOwnPropertyDescriptors(item);
|
|
276
|
+
if (Object.keys(fields).sort().join() !== "line,offset,pathHash,prefixHash,realm" || Object.values(fields).some(field => !("value" in field) || !field.enumerable)) fail();
|
|
277
|
+
const realm = validatePlacementKey({realm:fields.realm!.value,id:"0".repeat(32),rowHash:"0".repeat(64)}).realm;
|
|
278
|
+
const line: unknown = fields.line!.value, offset: unknown = fields.offset!.value, pathHash: unknown = fields.pathHash!.value, prefixHash: unknown = fields.prefixHash!.value;
|
|
279
|
+
if (typeof line !== "string" || Buffer.byteLength(line)>16384 || !line.endsWith("\n") || typeof offset!=="number" || !Number.isSafeInteger(offset) || offset<0
|
|
280
|
+
|| typeof pathHash!=="string" || pathHash.length!==64 || !/^[a-f0-9]+$/.test(pathHash) || typeof prefixHash!=="string" || prefixHash.length!==64 || !/^[a-f0-9]+$/.test(prefixHash)) fail();
|
|
281
|
+
return {realm,line,offset,pathHash,prefixHash};
|
|
282
|
+
});
|
|
283
|
+
const before = capture(home, port, selection, pending);
|
|
284
|
+
return { ...before, assertCurrent: () => { try { if (capture(home, port, selection, pending).fingerprint !== before.fingerprint) fail(); } catch { fail(); } } };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
287
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
288
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
289
|
+
}
|
|
290
|
+
return fail();
|
|
291
|
+
}
|
|
292
|
+
}
|