@staix/agent-mem 0.20.3 → 0.22.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 +30 -3
- package/package.json +2 -2
- package/src/cli.ts +36 -3
- package/src/commands-table.ts +3 -0
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +31 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/commands.ts +3 -2
- 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-activation.ts +4 -4
- 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 +66 -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-cadence.ts +2 -1
- package/src/memory/sync-placement-abandon.ts +142 -0
- package/src/memory/sync-placement-activation-locks.ts +28 -0
- package/src/memory/sync-placement-activation-path.ts +2 -0
- package/src/memory/sync-placement-activation-proof.ts +68 -0
- package/src/memory/sync-placement-activation-storage.ts +167 -0
- package/src/memory/sync-placement-activation.ts +185 -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 +373 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-effective-writers.ts +84 -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 +104 -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 +240 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +557 -0
- package/src/memory/sync-placement-journal.ts +160 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-prerequisites.ts +50 -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 +500 -0
- package/src/memory/sync-placement-writer-history.ts +278 -0
- package/src/memory/sync-placement-writer-renewal.ts +179 -0
- package/src/memory/sync-placement-writer-revision-format.ts +122 -0
- package/src/memory/sync-placement-writer-roots.ts +167 -0
- package/src/memory/sync-placement-writers.ts +135 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +19 -64
- package/src/setup.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
- 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
|
+
}
|
|
@@ -88,8 +88,9 @@ export function endLikeSignal(name: string): void {
|
|
|
88
88
|
*/
|
|
89
89
|
function syncRepository(home: string): string | undefined {
|
|
90
90
|
try {
|
|
91
|
-
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
92
91
|
const { selector } = readSelector(home);
|
|
92
|
+
if (selector?.schema === "agent-mem.profile/v3") return join(home,"shard-sync-placement","objects.git","config");
|
|
93
|
+
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
93
94
|
if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
|
|
94
95
|
return join(home, ".git", "config");
|
|
95
96
|
} catch { return undefined; }
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Person coordinator's precommit-only metadata cleanup. No rollback, source rewrite, renewal or package measurement. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, opendirSync, readSync, rmdirSync, unlinkSync, type BigIntStats } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { canonicalJSON } from "./generation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
9
|
+
import { placementEpochHash, SyncPlacementJournalError, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
|
|
10
|
+
import { validatePlacementActivationIntent, readPlacementActivationIntent, clearPlacementActivationIntent, PLACEMENT_ACTIVATION_INTENT_FILE, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
|
|
11
|
+
import { planSyncPlacementWriterRoots, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
|
|
12
|
+
import { syncPlacementWriterId } from "./sync-placement-writers.ts";
|
|
13
|
+
import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
|
|
14
|
+
|
|
15
|
+
export interface PlacementAbandonOptions { mutation: MutationHandle; assertCurrent(): void }
|
|
16
|
+
export class PlacementActivationAbandonError extends Error {
|
|
17
|
+
readonly code: "forward" | "metadata" | "bound";
|
|
18
|
+
constructor(code: "forward" | "metadata" | "bound" = "metadata") {
|
|
19
|
+
super(code === "forward"
|
|
20
|
+
? "activation markers or active fence changed; use forward --resume with a compatible recovery release"
|
|
21
|
+
: code === "bound" ? "activation cleanup metadata exceeds the supported bound; inspect it with a compatible recovery release"
|
|
22
|
+
: "activation cleanup metadata is unsafe, changed or unknown; inspect it with a compatible recovery release before deleting anything");
|
|
23
|
+
this.name = "PlacementActivationAbandonError"; this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
|
|
27
|
+
const bytes = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
|
|
28
|
+
const hash = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
29
|
+
const identity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode].map(String).join(":");
|
|
30
|
+
const stamp = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String).join(":");
|
|
31
|
+
function owned(s: BigIntStats, directory = false, links: readonly bigint[] = [1n], marker = false): void {
|
|
32
|
+
if (s.isSymbolicLink() || s.uid !== BigInt(process.getuid?.() ?? -1)
|
|
33
|
+
|| (directory ? !s.isDirectory() || (s.mode & 0o077n) !== 0n : !s.isFile() || !links.includes(s.nlink) || (marker ? (s.mode & 0o022n) !== 0n : (s.mode & 0o777n) !== 0o600n))) fail();
|
|
34
|
+
}
|
|
35
|
+
interface Member { stat: BigIntStats; expected: Buffer }
|
|
36
|
+
interface State { files: Map<string, Member>; dirs: Map<string, BigIntStats> }
|
|
37
|
+
function readMember(file: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n], marker = false): Member | undefined {
|
|
38
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
|
|
39
|
+
owned(before, false, links, marker); if (before.size > BigInt(expected.length)) fail("bound");
|
|
40
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const opened = fstatSync(fd, { bigint: true }); if (stamp(opened) !== stamp(before)) fail("changed");
|
|
43
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let at = 0;
|
|
44
|
+
while (at < data.length) { const n = readSync(fd, data, at, data.length - at, at); if (!n) break; at += n; }
|
|
45
|
+
if (at !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(file, { bigint: true })) !== stamp(opened)) fail("changed");
|
|
46
|
+
if ((!prefix && at !== expected.length) || !data.subarray(0, at).equals(expected.subarray(0, at))) fail("conflict");
|
|
47
|
+
return { stat: opened, expected };
|
|
48
|
+
} finally { closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
function directory(path: string): BigIntStats | undefined {
|
|
51
|
+
const value = lstatSync(path, { bigint: true, throwIfNoEntry: false }); if (value) owned(value, true); return value;
|
|
52
|
+
}
|
|
53
|
+
function names(path: string, maximum: number): string[] {
|
|
54
|
+
const result: string[] = [], iterator = opendirSync(path); let size = 2;
|
|
55
|
+
try { for (;;) { const entry = iterator.readSync(); if (!entry) break; if (result.length >= maximum) fail("bound");
|
|
56
|
+
size += Buffer.byteLength(JSON.stringify(entry.name)) + 1; if (size > SYNC_SCREEN_MAX_BYTES) fail("bound"); result.push(entry.name);
|
|
57
|
+
} } finally { iterator.closeSync(); } return result.sort();
|
|
58
|
+
}
|
|
59
|
+
/** Called only after person approval and under activation's existing live profile/readers/sync/mutation fences. */
|
|
60
|
+
export function abandonPlacementActivation(homeInput: string, input: PlacementActivationIntent, options: PlacementAbandonOptions): void {
|
|
61
|
+
try {
|
|
62
|
+
const intent = validatePlacementActivationIntent(input), initialHome = lstatSync(homeInput, { bigint: true }); owned(initialHome, true);
|
|
63
|
+
const home = realPath(homeInput); if (home !== intent.home || identity(directory(home)!) !== identity(initialHome)) fail("changed");
|
|
64
|
+
const epochHash = placementEpochHash(intent.epoch), storage = join(home, "sync-placement"), epochs = join(storage, "epochs"), epochDir = join(epochs, epochHash);
|
|
65
|
+
const intentFile = join(home, PLACEMENT_ACTIVATION_INTENT_FILE), intentBytes = bytes(intent), intentTemp = intentFile + ".pending-" + hash(intentBytes);
|
|
66
|
+
const roots = intent.roots.map(root => {
|
|
67
|
+
const writerId = syncPlacementWriterId(root), writer = intent.plan.writers.find(value => value.writerId === writerId); if (!writer) fail("conflict");
|
|
68
|
+
return { root, writerId, version: writer.version, pin: writer.pin };
|
|
69
|
+
});
|
|
70
|
+
const locatorBytes = bytes(planSyncPlacementWriterRoots({ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, roots },
|
|
71
|
+
{ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, writers: intent.plan.writers }));
|
|
72
|
+
const locator = join(home, SYNC_PLACEMENT_WRITER_ROOTS_FILE), locatorTemp = locator + ".pending-" + hash(locatorBytes);
|
|
73
|
+
const expectedEpoch = new Map([["epoch.json", bytes(intent.epoch)], ["activation.json", bytes(intent.plan)]]);
|
|
74
|
+
let clearing = false;
|
|
75
|
+
const proof = () => {
|
|
76
|
+
assertMutationHandle(options.mutation, home); options.assertCurrent(); assertMutationHandle(options.mutation, home);
|
|
77
|
+
if (identity(directory(home)!) !== identity(initialHome)) fail("changed");
|
|
78
|
+
try {
|
|
79
|
+
if (!readMember(join(home, "profile.json"), Buffer.from(intent.previousProfile), false, [1n], true)
|
|
80
|
+
|| !readMember(join(home, "shards.json"), Buffer.from(intent.previousShards), false, [1n], true)) throw new PlacementActivationAbandonError("forward");
|
|
81
|
+
} catch { throw new PlacementActivationAbandonError("forward"); }
|
|
82
|
+
if (directory(storage) && lstatSync(join(storage, "active.json"), { throwIfNoEntry: false })) throw new PlacementActivationAbandonError("forward");
|
|
83
|
+
const actual = readPlacementActivationIntent(home);
|
|
84
|
+
if (actual ? canonicalJSON(actual) !== canonicalJSON(intent) : !clearing) fail("changed");
|
|
85
|
+
};
|
|
86
|
+
const scan = (): State => {
|
|
87
|
+
const files = new Map<string, Member>(), dirs = new Map<string, BigIntStats>([[home, directory(home)!]]);
|
|
88
|
+
for (const path of [storage, epochs, epochDir]) { const stat = directory(path); if (stat) dirs.set(path, stat); }
|
|
89
|
+
if (dirs.has(storage)) for (const name of names(storage, 2)) if (name !== "epochs") fail("corrupt");
|
|
90
|
+
const knownTemps = new Set([intentTemp, locatorTemp]);
|
|
91
|
+
for (const name of names(home, SYNC_SCREEN_MAX_ROWS)) if (name.startsWith(PLACEMENT_ACTIVATION_INTENT_FILE + ".pending-") || name.startsWith(SYNC_PLACEMENT_WRITER_ROOTS_FILE + ".pending-"))
|
|
92
|
+
if (!knownTemps.has(join(home, name))) fail("corrupt");
|
|
93
|
+
const add = (path: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n]) => { const value = readMember(path, expected, prefix, links); if (value) files.set(path, value); return value; };
|
|
94
|
+
const final = add(locator, locatorBytes, false, [1n, 2n]), temp = add(locatorTemp, locatorBytes, true, [1n, 2n]);
|
|
95
|
+
if (final?.stat.nlink === 2n || temp?.stat.nlink === 2n) {
|
|
96
|
+
if (!final || !temp || final.stat.size !== BigInt(locatorBytes.length) || temp.stat.size !== BigInt(locatorBytes.length) || stamp(final.stat) !== stamp(temp.stat)) fail("corrupt");
|
|
97
|
+
}
|
|
98
|
+
if (dirs.has(epochDir)) for (const name of names(epochDir, 3)) { const expected = expectedEpoch.get(name); if (!expected) fail("corrupt"); add(join(epochDir, name), expected, true); }
|
|
99
|
+
const current = add(intentFile, intentBytes, false, [1n, 2n]), companion = add(intentTemp, intentBytes, false, [1n, 2n]);
|
|
100
|
+
if (current?.stat.nlink === 2n || companion) {
|
|
101
|
+
if (!current || !companion || current.stat.nlink !== 2n || companion.stat.nlink !== 2n || stamp(current.stat) !== stamp(companion.stat)) fail("corrupt");
|
|
102
|
+
}
|
|
103
|
+
if (!current && !clearing) fail("changed"); return { files, dirs };
|
|
104
|
+
};
|
|
105
|
+
proof(); let state = scan();
|
|
106
|
+
const check = () => {
|
|
107
|
+
proof(); const next = scan();
|
|
108
|
+
const expectedFiles = [...state.files.keys()].filter(path => !clearing || path !== intentFile).sort(), foundFiles = [...next.files.keys()].filter(path => !clearing || path !== intentFile).sort();
|
|
109
|
+
if (canonicalJSON(expectedFiles) !== canonicalJSON(foundFiles) || canonicalJSON([...state.dirs.keys()].sort()) !== canonicalJSON([...next.dirs.keys()].sort())) fail("changed");
|
|
110
|
+
for (const [path, before] of state.files) { const after = next.files.get(path); if (clearing && path === intentFile && !after) continue; if (!after || stamp(before.stat) !== stamp(after.stat)) fail("changed"); }
|
|
111
|
+
for (const [path, before] of state.dirs) if (identity(before) !== identity(next.dirs.get(path)!)) fail("changed");
|
|
112
|
+
};
|
|
113
|
+
const sync = (path: string) => {
|
|
114
|
+
check(); const expected = state.dirs.get(path); if (!expected) fail("changed"); const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
115
|
+
try { if (identity(fstatSync(fd, { bigint: true })) !== identity(expected)) fail("changed"); fsyncSync(fd); } finally { closeSync(fd); } check();
|
|
116
|
+
};
|
|
117
|
+
const remove = (path: string) => {
|
|
118
|
+
if (!state.files.has(path)) return; check(); const before = state.files.get(path)!; unlinkSync(path); state.files.delete(path);
|
|
119
|
+
for (const [peer, item] of state.files) if (item.stat.dev === before.stat.dev && item.stat.ino === before.stat.ino) {
|
|
120
|
+
const now = lstatSync(peer, { bigint: true }); owned(now);
|
|
121
|
+
if (before.stat.nlink !== 2n || now.nlink !== 1n || now.dev !== item.stat.dev || now.ino !== item.stat.ino || now.uid !== item.stat.uid || now.mode !== item.stat.mode || now.size !== item.stat.size || now.mtimeNs !== item.stat.mtimeNs) fail("changed");
|
|
122
|
+
item.stat = now;
|
|
123
|
+
}
|
|
124
|
+
sync(dirname(path));
|
|
125
|
+
};
|
|
126
|
+
remove(locatorTemp); remove(locator);
|
|
127
|
+
for (const name of ["activation.json", "epoch.json"]) remove(join(epochDir, name));
|
|
128
|
+
if (state.dirs.has(epochDir)) { check(); if (names(epochDir, 1).length) fail("changed"); rmdirSync(epochDir); state.dirs.delete(epochDir); sync(epochs); }
|
|
129
|
+
remove(intentTemp);
|
|
130
|
+
// A prior owner may have unlinked a member or removed this epoch before its directory fsync.
|
|
131
|
+
// Reassert every retained parent before the intent can acknowledge that cleanup as complete.
|
|
132
|
+
for (const path of [epochs, storage, home]) if (state.dirs.has(path)) sync(path);
|
|
133
|
+
check(); clearing = true;
|
|
134
|
+
clearPlacementActivationIntent(home, intent, { mutation: options.mutation, assertCurrent: check }); state.files.delete(intentFile); check();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementActivationAbandonError) throw error;
|
|
137
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
|
|
138
|
+
const value = Object.getOwnPropertyDescriptor(error, "code"); if (value && "value" in value && ["invalid", "corrupt", "bound", "conflict", "pending", "changed", "authorization", "policy", "local", "busy"].includes(value.value)) throw new PlacementActivationAbandonError(value.value === "bound" ? "bound" : "metadata");
|
|
139
|
+
}
|
|
140
|
+
throw new PlacementActivationAbandonError();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared person-migration fences. No serialized value grants ownership. */
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { withProfileLock } from "./generation.ts";
|
|
5
|
+
import { acquireMutation, type MutationHandle } from "./mutation.ts";
|
|
6
|
+
import { generationSyncLock } from "./generation-sync.ts";
|
|
7
|
+
import { assertNoLiveReaders } from "./reader-lease.ts";
|
|
8
|
+
import { createPlacementActivationOwner } from "./sync-placement-activation-proof.ts";
|
|
9
|
+
export class PlacementInventoryFenceError extends Error{
|
|
10
|
+
constructor(readonly causeClass:"readers"|"busy"){super(`Sync placement migration is ${causeClass}; close cooperating readers/writers, then run amem migrate --sync-placement --dry-run before retrying.`);this.name="PlacementInventoryFenceError";}
|
|
11
|
+
}
|
|
12
|
+
function fail(cause:"readers"|"busy"):never{throw new PlacementInventoryFenceError(cause);}
|
|
13
|
+
export async function withPlacementActivationFences<T>(home:string,directories:readonly string[],work:(mutation:MutationHandle,owner:ReturnType<typeof createPlacementActivationOwner>)=>Promise<T>):Promise<T> {
|
|
14
|
+
return withProfileLock(home,async()=>{
|
|
15
|
+
const releases:(()=>void)[]=[];
|
|
16
|
+
try {
|
|
17
|
+
const transports=["shard-sync","shard-sync-placement"].map(name=>join(home,name)).filter(dir=>lstatSync(dir,{throwIfNoEntry:false})!==undefined);
|
|
18
|
+
const dirs=[...new Set([home,...directories,...transports])].sort();let mutation:MutationHandle|undefined;
|
|
19
|
+
for(const dir of dirs){
|
|
20
|
+
try{assertNoLiveReaders(dir,{includeShards:true});}catch{fail("readers");}
|
|
21
|
+
const handle=acquireMutation(dir);if(!handle)fail("busy");releases.push(handle);if(dir===home)mutation=handle;
|
|
22
|
+
}
|
|
23
|
+
if(!mutation)fail("busy");
|
|
24
|
+
for(const dir of [home,...transports]){const release=generationSyncLock(dir);if(!release)fail("busy");releases.push(release);}
|
|
25
|
+
const owner=createPlacementActivationOwner(home,mutation);return await work(mutation,owner);
|
|
26
|
+
}finally{for(const release of releases.reverse())release();}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Internal activation-owner proof. No serialized object or boolean grants lock bypass authority. */
|
|
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 { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
6
|
+
import { processSignature, realPath } from "./platform.ts";
|
|
7
|
+
import type { PlacementActivationMarkerSnapshot } from "./sync-placement-capture.ts";
|
|
8
|
+
import { readPlacementActivationIntent, validatePlacementActivationIntent, placementActivationFinalMarkers, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
|
|
9
|
+
|
|
10
|
+
declare const ownerBrand: unique symbol, captureBrand: unique symbol;
|
|
11
|
+
export interface PlacementActivationOwner { readonly [ownerBrand]: true }
|
|
12
|
+
export interface PlacementActivationCaptureProof { readonly [captureBrand]: true }
|
|
13
|
+
interface Owner { home: string; mutation: MutationHandle; root: BigIntStats; lock: string; lockStamp: string }
|
|
14
|
+
const owners = new WeakMap<PlacementActivationOwner, Owner>();
|
|
15
|
+
const captures = new WeakMap<PlacementActivationCaptureProof, { owner: PlacementActivationOwner; intent: PlacementActivationIntent }>();
|
|
16
|
+
const stamp = (s: BigIntStats) => [s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
|
|
17
|
+
function fail(): never { throw Error("Sync placement activation ownership or retained evidence changed; close cooperating writers and run amem migrate --sync-placement --resume after person inspection."); }
|
|
18
|
+
function lockBytes(home: string): { text: string; stamp: string } {
|
|
19
|
+
const file = join(home,"profile.lock"), before = lstatSync(file,{bigint:true});
|
|
20
|
+
if (!before.isFile() || before.isSymbolicLink() || before.uid !== BigInt(process.getuid?.() ?? -1) || (before.mode & 0o077n) !== 0n || before.nlink !== 1n || before.size < 1n || before.size > 4096n) fail();
|
|
21
|
+
const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
22
|
+
try {
|
|
23
|
+
const opened = fstatSync(fd,{bigint:true}); if (stamp(opened) !== stamp(before)) fail();
|
|
24
|
+
const out = Buffer.alloc(Number(opened.size)); let at = 0;
|
|
25
|
+
while (at < out.length) { const n = readSync(fd,out,at,out.length-at,at); if (!n) fail(); at += n; }
|
|
26
|
+
if (stamp(fstatSync(fd,{bigint:true})) !== stamp(opened) || stamp(lstatSync(file,{bigint:true})) !== stamp(opened)) fail();
|
|
27
|
+
return { text: new TextDecoder("utf-8",{fatal:true}).decode(out), stamp: stamp(opened) };
|
|
28
|
+
} finally { closeSync(fd); }
|
|
29
|
+
}
|
|
30
|
+
export function createPlacementActivationOwner(homeInput: string, mutation: MutationHandle): PlacementActivationOwner {
|
|
31
|
+
try {
|
|
32
|
+
const home = realPath(homeInput); assertMutationHandle(mutation,home);
|
|
33
|
+
const root = lstatSync(home,{bigint:true}), lock = lockBytes(home), value = JSON.parse(lock.text) as Record<string,unknown>;
|
|
34
|
+
if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== BigInt(process.getuid?.() ?? -1) || (root.mode & 0o077n) !== 0n
|
|
35
|
+
|| Object.keys(value).sort().join() !== "pid,signature,token" || value.pid !== process.pid || value.signature !== processSignature(process.pid)
|
|
36
|
+
|| typeof value.signature !== "string" || !value.signature || typeof value.token !== "string" || !/^[a-f0-9]{32}$/.test(value.token)) fail();
|
|
37
|
+
const token = Object.freeze({}) as PlacementActivationOwner;
|
|
38
|
+
owners.set(token,{home,mutation,root,lock:lock.text,lockStamp:lock.stamp}); return token;
|
|
39
|
+
} catch { return fail(); }
|
|
40
|
+
}
|
|
41
|
+
export function assertPlacementActivationOwner(token: PlacementActivationOwner, home?: string): void {
|
|
42
|
+
try {
|
|
43
|
+
const owner = owners.get(token); if (!owner) fail(); assertMutationHandle(owner.mutation,owner.home);
|
|
44
|
+
if(home!==undefined&&realPath(home)!==owner.home)fail();
|
|
45
|
+
const root = lstatSync(owner.home,{bigint:true}), lock = lockBytes(owner.home);
|
|
46
|
+
if (!root.isDirectory() || root.isSymbolicLink() || root.dev !== owner.root.dev || root.ino !== owner.root.ino || root.uid !== owner.root.uid || (root.mode & 0o077n) !== 0n
|
|
47
|
+
|| lock.text !== owner.lock || lock.stamp !== owner.lockStamp) fail();
|
|
48
|
+
assertMutationHandle(owner.mutation,owner.home);
|
|
49
|
+
} catch { fail(); }
|
|
50
|
+
}
|
|
51
|
+
export function createPlacementActivationCaptureProof(owner: PlacementActivationOwner, input: PlacementActivationIntent): PlacementActivationCaptureProof {
|
|
52
|
+
assertPlacementActivationOwner(owner);
|
|
53
|
+
const state = owners.get(owner)!;
|
|
54
|
+
const intent = validatePlacementActivationIntent(input), actual = readPlacementActivationIntent(state.home);
|
|
55
|
+
if (!intent || !actual || canonicalJSON(intent) !== canonicalJSON(actual)
|
|
56
|
+
|| actual.home !== state.home || actual.epoch.homeHash !== hashJSON([state.home,String(state.root.dev),String(state.root.ino),String(state.root.uid)])) fail();
|
|
57
|
+
const token = Object.freeze({}) as PlacementActivationCaptureProof; captures.set(token,{owner,intent:actual}); return token;
|
|
58
|
+
}
|
|
59
|
+
export function activationSourceMarkers(token: PlacementActivationCaptureProof, home: string, markers: { profileText: string; shardsText: string; profileStamp: readonly string[]; shardsStamp: readonly string[] }): PlacementActivationMarkerSnapshot {
|
|
60
|
+
const scope = captures.get(token); if (!scope) fail(); assertPlacementActivationOwner(scope.owner);
|
|
61
|
+
const state = owners.get(scope.owner)!, actual = readPlacementActivationIntent(home), intent = scope.intent;
|
|
62
|
+
if (home !== state.home || !actual || canonicalJSON(actual) !== canonicalJSON(intent)) fail();
|
|
63
|
+
const { profileText, shardsText } = markers;
|
|
64
|
+
const final = placementActivationFinalMarkers(intent), oldProfile = profileText === intent.previousProfile, oldShards = shardsText === intent.previousShards;
|
|
65
|
+
if ((!oldProfile && profileText !== final.profile) || (!oldShards && shardsText !== final.shards) || !oldProfile && shardsText !== final.shards) fail();
|
|
66
|
+
if (oldProfile && canonicalJSON(markers.profileStamp) !== canonicalJSON(intent.profileStamp) || oldShards && canonicalJSON(markers.shardsStamp) !== canonicalJSON(intent.shardsStamp)) fail();
|
|
67
|
+
return { profileText:intent.previousProfile,shardsText:intent.previousShards,profileStamp:[...intent.profileStamp],shardsStamp:[...intent.shardsStamp] };
|
|
68
|
+
}
|