@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Initial activation metadata only. Caller owns person authority, profile lock, writer inventory and source fences. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, unlinkSync, type BigIntStats } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
9
|
+
import { validatePlacementBaseV2, validatePlacementShards, validatePlacementMarkerTransition, PLACEMENT_FORMAT, PLACEMENT_SHARDS_SCHEMA } from "./sync-placement-format.ts";
|
|
10
|
+
import { validatePlacementPlan, type SyncPlacementActivationPlan } from "./sync-placement-plan.ts";
|
|
11
|
+
import { validatePlacementEpoch, placementEpochHash, SyncPlacementJournalError, type PlacementEpoch, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
|
|
12
|
+
import { writeImmutableActivationMetadata } from "./public-prune-activation.ts";
|
|
13
|
+
import { PLACEMENT_ACTIVATION_INTENT } from "./sync-placement-activation-path.ts";
|
|
14
|
+
|
|
15
|
+
export const PLACEMENT_ACTIVATION_INTENT_SCHEMA = "agent-mem.sync-placement-activation/v1";
|
|
16
|
+
export const PLACEMENT_ACTIVATION_INTENT_FILE = PLACEMENT_ACTIVATION_INTENT;
|
|
17
|
+
const MAX_BYTES = 1024 * 1024, ROOTS_MAX = 256;
|
|
18
|
+
export type PlacementActivationMarkerStamp = [string, string, string, string, string];
|
|
19
|
+
export interface PlacementActivationIntent {
|
|
20
|
+
schema: typeof PLACEMENT_ACTIVATION_INTENT_SCHEMA; home: string; epoch: PlacementEpoch; plan: SyncPlacementActivationPlan; roots: string[];
|
|
21
|
+
previousProfile: string; previousShards: string; profileStamp: PlacementActivationMarkerStamp; shardsStamp: PlacementActivationMarkerStamp; floorHash: string;
|
|
22
|
+
inputHashes: {sourceHash:string;aliasesHash:string;policyHash:string}; seal: string;
|
|
23
|
+
}
|
|
24
|
+
export type PlacementActivationIntentBody = Omit<PlacementActivationIntent, "seal">;
|
|
25
|
+
export interface PlacementActivationStorageProof { mutation: MutationHandle; assertCurrent(): void }
|
|
26
|
+
function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
|
|
27
|
+
const bytes = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
|
|
28
|
+
const digest = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
29
|
+
const hex = (value: unknown, length = 64): value is string => typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value);
|
|
30
|
+
function object(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
31
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || ![null, Object.prototype].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) fail("invalid");
|
|
32
|
+
const fields = Object.getOwnPropertyDescriptors(value); if (Object.keys(fields).length !== keys.length) fail("invalid");
|
|
33
|
+
const copied: Record<string, unknown> = Object.create(null);
|
|
34
|
+
for (const key of keys) { const field = fields[key]; if (!field || !("value" in field) || !field.enumerable) fail("invalid"); copied[key] = field.value; }
|
|
35
|
+
return copied;
|
|
36
|
+
}
|
|
37
|
+
function array(value: unknown, maximum: number): unknown[] {
|
|
38
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length) fail("invalid");
|
|
39
|
+
const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
|
|
40
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > maximum) fail("bound");
|
|
41
|
+
if (Object.getOwnPropertyNames(value).length !== length + 1) fail("invalid");
|
|
42
|
+
const output: unknown[] = [];
|
|
43
|
+
for (let i = 0; i < length; i++) { const field = Object.getOwnPropertyDescriptor(value, String(i)); if (!field || !("value" in field) || !field.enumerable) fail("invalid"); output.push(field.value); }
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
function path(value: unknown): string {
|
|
47
|
+
if (typeof value !== "string" || !isAbsolute(value) || Buffer.byteLength(value) > 4096 || value.includes("\0") || resolve(value) !== value) fail("invalid"); return value;
|
|
48
|
+
}
|
|
49
|
+
function markerStamp(value: unknown, text: string): PlacementActivationMarkerStamp {
|
|
50
|
+
const fields = array(value, 5);
|
|
51
|
+
if (fields.length !== 5 || fields.some((value, index) => typeof value !== "string" || value.length > 32 || !/^-?(?:0|[1-9][0-9]*)$/.test(value) || /[\r\n]/.test(value) || index < 3 && value.startsWith("-"))) fail("invalid");
|
|
52
|
+
if (fields[2] !== String(Buffer.byteLength(text))) fail("invalid"); return fields as PlacementActivationMarkerStamp;
|
|
53
|
+
}
|
|
54
|
+
function baseMarkers(profile: unknown, shards: unknown) {
|
|
55
|
+
if (typeof profile !== "string" || typeof shards !== "string" || Buffer.byteLength(profile) > 262144 || Buffer.byteLength(shards) > 262144) fail("bound");
|
|
56
|
+
// Preserve original UTF-8 text/stamps, including whitespace/BOM; only the containing intent and final v3 pair
|
|
57
|
+
// are canonical. Match the existing fatal UTF-8 marker decoder's BOM behavior without rewriting the source.
|
|
58
|
+
const decode = (text: string) => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(text)));
|
|
59
|
+
return validatePlacementBaseV2(decode(profile), decode(shards));
|
|
60
|
+
}
|
|
61
|
+
function validate(value: unknown, sealed: boolean): PlacementActivationIntent {
|
|
62
|
+
const v = object(value, ["schema", "home", "epoch", "plan", "roots", "previousProfile", "previousShards", "profileStamp", "shardsStamp", "floorHash", "inputHashes", ...(sealed ? ["seal"] : [])]);
|
|
63
|
+
if (v.schema !== PLACEMENT_ACTIVATION_INTENT_SCHEMA || !hex(v.floorHash) || sealed && !hex(v.seal)) fail("invalid");
|
|
64
|
+
const home = path(v.home), epoch = validatePlacementEpoch(v.epoch), plan = validatePlacementPlan(v.plan), base = baseMarkers(v.previousProfile, v.previousShards);
|
|
65
|
+
const inputs=object(v.inputHashes,["sourceHash","aliasesHash","policyHash"]);
|
|
66
|
+
if(!hex(inputs.sourceHash)||!hex(inputs.aliasesHash)||!hex(inputs.policyHash))fail("invalid");
|
|
67
|
+
const inputHashes={sourceHash:inputs.sourceHash,aliasesHash:inputs.aliasesHash,policyHash:inputs.policyHash};
|
|
68
|
+
if(hashJSON({...base,homeHash:epoch.homeHash,...inputHashes,realms:plan.realms,writers:plan.writers,legacySyncQuiesced:true})!==plan.contextHash)fail("invalid");
|
|
69
|
+
const roots = array(v.roots, ROOTS_MAX).map(path); if (new Set(roots).size !== roots.length || canonicalJSON([...roots].sort()) !== canonicalJSON(roots)) fail("invalid");
|
|
70
|
+
if (epoch.contextHash !== plan.contextHash || epoch.generation !== plan.generation || base.profile.generation !== epoch.generation || placementEpochHash(epoch) !== plan.targetProfile.placementHead
|
|
71
|
+
|| hashJSON(base.profile) !== plan.previousProfileHash || hashJSON(base.shards) !== plan.previousShardsHash) fail("invalid");
|
|
72
|
+
const final = validatePlacementShards({ ...base.shards, schema: PLACEMENT_SHARDS_SCHEMA, selectorRevision: hashJSON(plan.targetProfile), publicationFormat: PLACEMENT_FORMAT, placementHead: plan.targetProfile.placementHead }, plan.targetProfile);
|
|
73
|
+
validatePlacementMarkerTransition(base.profile, base.shards, plan.targetProfile, final); if (hashJSON(final) !== plan.targetShardsHash) fail("invalid");
|
|
74
|
+
const body: PlacementActivationIntentBody = { schema: PLACEMENT_ACTIVATION_INTENT_SCHEMA, home, epoch, plan, roots, previousProfile: v.previousProfile as string, previousShards: v.previousShards as string,
|
|
75
|
+
profileStamp: markerStamp(v.profileStamp, v.previousProfile as string), shardsStamp: markerStamp(v.shardsStamp, v.previousShards as string), floorHash: v.floorHash,inputHashes };
|
|
76
|
+
const intent = { ...body, seal: hashJSON(body) }; if (sealed && intent.seal !== v.seal) fail("corrupt"); if (bytes(intent).length > MAX_BYTES) fail("bound"); return intent;
|
|
77
|
+
}
|
|
78
|
+
function safe<T>(work: () => T): T {
|
|
79
|
+
try { return work(); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
|
|
82
|
+
const field = Object.getOwnPropertyDescriptor(error, "code");
|
|
83
|
+
if (field && "value" in field && ["invalid", "corrupt", "bound", "conflict", "pending", "changed", "authorization", "policy", "local", "busy"].includes(field.value)) fail(field.value);
|
|
84
|
+
}
|
|
85
|
+
return fail();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function sealPlacementActivationIntent(body: PlacementActivationIntentBody): PlacementActivationIntent { return safe(() => validate(body, false)); }
|
|
89
|
+
/** Detached data validation uses intent bounds, never policy-payload string limits. */
|
|
90
|
+
export function validatePlacementActivationIntent(value: unknown): PlacementActivationIntent { return safe(() => validate(value, true)); }
|
|
91
|
+
export function placementActivationFinalMarkers(value: PlacementActivationIntent): { profile: string; shards: string } {
|
|
92
|
+
return safe(() => {
|
|
93
|
+
const intent = validate(value, true), old = baseMarkers(intent.previousProfile, intent.previousShards);
|
|
94
|
+
const shards = validatePlacementShards({ ...old.shards, schema: PLACEMENT_SHARDS_SCHEMA, selectorRevision: hashJSON(intent.plan.targetProfile), publicationFormat: PLACEMENT_FORMAT, placementHead: intent.plan.targetProfile.placementHead }, intent.plan.targetProfile);
|
|
95
|
+
return { profile: canonicalJSON(intent.plan.targetProfile) + "\n", shards: canonicalJSON(shards) + "\n" };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function owned(stat: BigIntStats, directory = false, links: readonly bigint[] = [1n]): void {
|
|
99
|
+
if (stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (directory ? !stat.isDirectory() || (stat.mode & 0o077n) !== 0n : !stat.isFile() || (stat.mode & 0o777n) !== 0o600n || !links.includes(stat.nlink))) fail();
|
|
100
|
+
}
|
|
101
|
+
function same(a: BigIntStats, b: BigIntStats): boolean { return a.dev === b.dev && a.ino === b.ino && a.uid === b.uid && a.mode === b.mode && a.nlink === b.nlink && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs; }
|
|
102
|
+
function root(home: string): { home: string; stat: BigIntStats; file: string } {
|
|
103
|
+
const stat = lstatSync(home, { bigint: true }); owned(stat, true); const canonical = realPath(home), current = lstatSync(canonical, { bigint: true }); owned(current, true);
|
|
104
|
+
if (stat.dev !== current.dev || stat.ino !== current.ino) fail("changed"); return { home: canonical, stat, file: join(canonical, PLACEMENT_ACTIVATION_INTENT_FILE) };
|
|
105
|
+
}
|
|
106
|
+
function bindHome(intent: PlacementActivationIntent, current: ReturnType<typeof root>): void {
|
|
107
|
+
if (intent.home !== current.home || intent.epoch.homeHash !== hashJSON([current.home, String(current.stat.dev), String(current.stat.ino), String(current.stat.uid)])) fail("changed");
|
|
108
|
+
}
|
|
109
|
+
interface Read { intent: PlacementActivationIntent; text: string; stat: BigIntStats }
|
|
110
|
+
function read(current: ReturnType<typeof root>): Read | undefined {
|
|
111
|
+
const before = lstatSync(current.file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
|
|
112
|
+
owned(before, false, [1n, 2n]); if (before.size < 1n || before.size > BigInt(MAX_BYTES)) fail("bound");
|
|
113
|
+
const fd = openSync(current.file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
114
|
+
try {
|
|
115
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened, false, [1n, 2n]); if (!same(before, opened)) fail("changed");
|
|
116
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let at = 0;
|
|
117
|
+
while (at < data.length) { const n = readSync(fd, data, at, data.length - at, at); if (!n) break; at += n; }
|
|
118
|
+
if (at !== Number(opened.size) || !same(opened, fstatSync(fd, { bigint: true })) || !same(opened, lstatSync(current.file, { bigint: true }))) fail("changed");
|
|
119
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data.subarray(0, at)), intent = validate(JSON.parse(text), true);
|
|
120
|
+
if (canonicalJSON(intent) + "\n" !== text) fail(); bindHome(intent, current);
|
|
121
|
+
if (opened.nlink === 2n) {
|
|
122
|
+
const companion = lstatSync(current.file + ".pending-" + digest(Buffer.from(text)), { bigint: true }); owned(companion, false, [2n]);
|
|
123
|
+
if (!same(opened, companion) || !same(opened, lstatSync(current.file, { bigint: true }))) fail("changed");
|
|
124
|
+
}
|
|
125
|
+
const finalRoot = root(current.home); if (finalRoot.stat.dev !== current.stat.dev || finalRoot.stat.ino !== current.stat.ino) fail("changed");
|
|
126
|
+
return { intent, text, stat: opened };
|
|
127
|
+
} finally { closeSync(fd); }
|
|
128
|
+
}
|
|
129
|
+
export function readPlacementActivationIntent(home: string): PlacementActivationIntent | undefined { return safe(() => read(root(home))?.intent); }
|
|
130
|
+
function proof(current: ReturnType<typeof root>, options: PlacementActivationStorageProof): void {
|
|
131
|
+
try { assertMutationHandle(options.mutation, current.home); } catch { fail("busy"); }
|
|
132
|
+
try { options.assertCurrent(); } catch { fail("changed"); }
|
|
133
|
+
try { assertMutationHandle(options.mutation, current.home); } catch { fail("busy"); }
|
|
134
|
+
const now = root(current.home); if (now.stat.dev !== current.stat.dev || now.stat.ino !== current.stat.ino) fail("changed");
|
|
135
|
+
}
|
|
136
|
+
function syncHome(current: ReturnType<typeof root>, options: PlacementActivationStorageProof): void {
|
|
137
|
+
proof(current, options); const fd = openSync(current.home, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
138
|
+
try { const stat = fstatSync(fd, { bigint: true }); owned(stat, true); if (stat.dev !== current.stat.dev || stat.ino !== current.stat.ino) fail("changed"); proof(current, options); fsyncSync(fd); }
|
|
139
|
+
finally { closeSync(fd); } proof(current, options);
|
|
140
|
+
}
|
|
141
|
+
export function writePlacementActivationIntent(home: string, value: PlacementActivationIntent, options: PlacementActivationStorageProof): void {
|
|
142
|
+
safe(() => {
|
|
143
|
+
const current = root(home), intent = validate(value, true); bindHome(intent, current); const expected = canonicalJSON(intent) + "\n";
|
|
144
|
+
proof(current, options); const prior = read(current); if (prior && prior.text !== expected) fail("conflict");
|
|
145
|
+
proof(current, options);
|
|
146
|
+
// The shared helper intentionally keeps its old algorithm; bound its unregistered staging read here first.
|
|
147
|
+
const temp = lstatSync(current.file + ".pending-" + digest(Buffer.from(expected)), { bigint: true, throwIfNoEntry: false });
|
|
148
|
+
if (temp) { owned(temp, false, prior?.stat.nlink === 2n ? [2n] : [1n]); if (temp.size > BigInt(MAX_BYTES)) fail("bound"); }
|
|
149
|
+
writeImmutableActivationMetadata(current.file, expected); proof(current, options);
|
|
150
|
+
const installed = read(current); if (!installed || installed.stat.nlink !== 1n || installed.text !== expected) fail("changed");
|
|
151
|
+
const fd = openSync(current.file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
152
|
+
try { if (!same(installed.stat, fstatSync(fd, { bigint: true }))) fail("changed"); proof(current, options); if (!same(installed.stat, lstatSync(current.file, { bigint: true }))) fail("changed"); fsyncSync(fd); }
|
|
153
|
+
finally { closeSync(fd); } syncHome(current, options); proof(current, options);
|
|
154
|
+
if (read(current)?.text !== expected) fail("changed");
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export function clearPlacementActivationIntent(home: string, value: PlacementActivationIntent, options: PlacementActivationStorageProof): void {
|
|
158
|
+
safe(() => {
|
|
159
|
+
const current = root(home), intent = validate(value, true); bindHome(intent, current); proof(current, options);
|
|
160
|
+
const prior = read(current);
|
|
161
|
+
if (prior) {
|
|
162
|
+
if (prior.text !== canonicalJSON(intent) + "\n") fail("conflict"); if (prior.stat.nlink !== 1n) fail("pending");
|
|
163
|
+
proof(current, options); if (!same(prior.stat, lstatSync(current.file, { bigint: true }))) fail("changed"); unlinkSync(current.file);
|
|
164
|
+
}
|
|
165
|
+
syncHome(current, options); proof(current, options); if (lstatSync(current.file, { throwIfNoEntry: false })) fail("changed");
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Person-only home-wide initial activation. No automatic migration, renewal or transport. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { canonicalJSON, hashJSON, writeProfileJSON, fsyncDirectory } from "./generation.ts";
|
|
7
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
8
|
+
import { withPlacementActivationFences, PlacementInventoryFenceError } from "./sync-placement-activation-locks.ts";
|
|
9
|
+
import { realPath } from "./platform.ts";
|
|
10
|
+
import { planSyncPlacementActivation, validateSyncPlacementActivation, SyncPlacementPlanError } from "./sync-placement-plan.ts";
|
|
11
|
+
import { PLACEMENT_EPOCH_SCHEMA, placementEpochHash, SyncPlacementJournalError, type PlacementEpoch } from "./sync-placement-journal-format.ts";
|
|
12
|
+
import { preparePlacementEpoch, ensurePlacementFence } from "./sync-placement-journal-storage.ts";
|
|
13
|
+
import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
|
|
14
|
+
import { readActivePlacementBinding } from "./sync-placement-active.ts";
|
|
15
|
+
import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
16
|
+
import { captureSyncPlacementActivationContext, previewSyncPlacementSources, type PlacementSourcePreview } from "./sync-placement-capture.ts";
|
|
17
|
+
import { createPlacementActivationOwner, assertPlacementActivationOwner, createPlacementActivationCaptureProof } from "./sync-placement-activation-proof.ts";
|
|
18
|
+
import { sealPlacementActivationIntent, readPlacementActivationIntent, writePlacementActivationIntent, clearPlacementActivationIntent, placementActivationFinalMarkers, PLACEMENT_ACTIVATION_INTENT_SCHEMA, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
|
|
19
|
+
import { writeSyncPlacementWriterRoots, readSyncPlacementWriterRoots, planSyncPlacementWriterRoots, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
|
|
20
|
+
import { SyncPlacementWriterError } from "./sync-placement-writers.ts";
|
|
21
|
+
import { requirePlacementActivationPrerequisites, requirePlacementHomeFloor } from "./sync-placement-prerequisites.ts";
|
|
22
|
+
import { outboundPolicyEvaluator, type SyncPolicyCategory } from "./sync-screen.ts";
|
|
23
|
+
import { initialPlacementWriterContext, PlacementWriterContextError, type PlacementWriterContext } from "../sync-placement-writer-context.ts";
|
|
24
|
+
import { abandonPlacementActivation, PlacementActivationAbandonError } from "./sync-placement-abandon.ts";
|
|
25
|
+
import { renewSyncPlacementWriters } from "./sync-placement-writer-renewal.ts";
|
|
26
|
+
import type { WriterHistoryPhase } from "./sync-placement-writer-history.ts";
|
|
27
|
+
|
|
28
|
+
export type PlacementActivationStage = "after-intent"|"after-epoch"|"after-root-map"|"after-shards"|"after-profile"|"after-fence"|"after-remove";
|
|
29
|
+
export interface PlacementActivationOptions {
|
|
30
|
+
memoryDir: string; mode: "dry-run"|"apply"|"resume"|"abandon"; writerRoots: readonly string[]; ownRoot: string;
|
|
31
|
+
authorize(binding: Readonly<{seal:string;generation:string;homeHash:string}>): boolean;
|
|
32
|
+
checkpoint?(stage: PlacementActivationStage): void|Promise<void>;
|
|
33
|
+
/** Internal renewal fault seam; the CLI never supplies it. */
|
|
34
|
+
renewalCheckpoint?(stage:WriterHistoryPhase):void;
|
|
35
|
+
/** Internal fault-test observer, never supplied by CLI options. */
|
|
36
|
+
testOnlyFailure?(error: unknown): void;
|
|
37
|
+
}
|
|
38
|
+
export class PlacementActivationError extends Error {
|
|
39
|
+
constructor(readonly causeClass: "readers"|"busy"|"changed"|"metadata"|"authorization"|"pending"|"bound") {
|
|
40
|
+
super(`Sync placement activation failed (${causeClass}); close cooperating writers and run amem migrate --sync-placement --dry-run before retrying apply. If an activation intent is retained, use --resume, or --abandon only while both markers are still the original v2 bytes. Once a v3 marker exists, resume is forward-only; never restore v2 markers.`);
|
|
41
|
+
this.name="PlacementActivationError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class PlacementActivationInputError extends Error {
|
|
45
|
+
constructor(readonly inputClass:"policy"|"source"|"aliases"|"writer root"|"receipt"|"floor"){
|
|
46
|
+
super(`Sync placement activation frozen ${inputClass} input changed or is unavailable; restore that input and run amem migrate --sync-placement --resume. If both markers still contain their original v2 bytes, the person may run amem migrate --sync-placement --abandon, then review a fresh --dry-run and --apply. Once any v3 marker exists, only forward resume is supported.`);
|
|
47
|
+
this.name="PlacementActivationInputError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writerInputFailure(error:unknown):never {
|
|
51
|
+
const category=error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof PlacementWriterContextError?Object.getOwnPropertyDescriptor(error,"category")?.value:undefined;
|
|
52
|
+
throw new PlacementActivationInputError(category==="setup receipt"?"receipt":"writer root");
|
|
53
|
+
}
|
|
54
|
+
function fail(cause: PlacementActivationError["causeClass"]): never { throw new PlacementActivationError(cause); }
|
|
55
|
+
function rethrowActivationFailure(error:unknown):never {
|
|
56
|
+
if(error&&typeof error==="object"&&!types.isProxy(error)){
|
|
57
|
+
if(error instanceof PlacementInventoryFenceError)fail(error.causeClass);
|
|
58
|
+
if(error instanceof PlacementWriterContextError||error instanceof PlacementActivationError||error instanceof PlacementActivationInputError||error instanceof PlacementActivationAbandonError)throw error;
|
|
59
|
+
if(error instanceof SyncPlacementJournalError||error instanceof SyncPlacementPlanError||error instanceof SyncPlacementWriterError){
|
|
60
|
+
const code=Object.getOwnPropertyDescriptor(error,"code");
|
|
61
|
+
if(code&&"value" in code){if(code.value==="bound")fail("bound");if(code.value==="busy")fail("busy");if(code.value==="changed")fail("changed");if(code.value==="authorization")fail("authorization");}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fail("metadata");
|
|
65
|
+
}
|
|
66
|
+
function counts(source: PlacementSourcePreview) {
|
|
67
|
+
const evaluate = outboundPolicyEvaluator(source.home), categories: Partial<Record<SyncPolicyCategory,number>> = {};
|
|
68
|
+
let eligibleOriginals=0,declined=0,intrinsicLocal=0;
|
|
69
|
+
for (const row of [...source.rows.values(),...source.specialRows.values()]) {
|
|
70
|
+
if (row.intrinsicLocal || row.sourcePlacement!=="synced") { intrinsicLocal++; continue; }
|
|
71
|
+
const verdict=evaluate(row.row as unknown as Record<string,unknown>);
|
|
72
|
+
if (verdict.ok) eligibleOriginals++; else { declined++;categories[verdict.category]=(categories[verdict.category]??0)+1; }
|
|
73
|
+
}
|
|
74
|
+
source.assertSourcesUnchanged();
|
|
75
|
+
return {selected:source.rows.size+source.specialRows.size,policyPermittedOriginals:eligibleOriginals,declined,intrinsicLocal,categories,dependencyDecisions:"deferred-to-sync" as const};
|
|
76
|
+
}
|
|
77
|
+
function committed(home:string,intent:Pick<PlacementActivationIntent,"epoch"|"plan">) {
|
|
78
|
+
const markers=readPlacementRuntimeMarkers(home);
|
|
79
|
+
if ((markers.profile as {schema?:string})?.schema!=="agent-mem.profile/v3"||(markers.shards as {schema?:string})?.schema!=="agent-mem.shards/v3") return false;
|
|
80
|
+
const binding=readActivePlacementBinding(home,markers.profile,markers.shards);
|
|
81
|
+
return binding?.profile.placementHead===placementEpochHash(intent.epoch)&&binding.activation.seal===intent.plan.seal;
|
|
82
|
+
}
|
|
83
|
+
function syncFile(file:string) {
|
|
84
|
+
const before=lstatSync(file,{bigint:true});
|
|
85
|
+
if (!before.isFile()||before.isSymbolicLink()||before.nlink!==1n||before.uid!==BigInt(process.getuid?.()??-1)||(before.mode&0o077n)!==0n) fail("metadata");
|
|
86
|
+
const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
87
|
+
try { const actual=fstatSync(fd,{bigint:true}); if(actual.dev!==before.dev||actual.ino!==before.ino||actual.size!==before.size)fail("changed");fsyncSync(fd); }
|
|
88
|
+
finally{closeSync(fd);}fsyncDirectory(join(file,".."));
|
|
89
|
+
}
|
|
90
|
+
function authorize(options:PlacementActivationOptions,intent:Pick<PlacementActivationIntent,"epoch"|"plan">) {
|
|
91
|
+
let okay=false;try{okay=options.authorize(Object.freeze({seal:intent.plan.seal,generation:intent.epoch.generation,homeHash:intent.epoch.homeHash}))===true;}catch{fail("authorization");}
|
|
92
|
+
if(!okay)fail("authorization");
|
|
93
|
+
}
|
|
94
|
+
async function finishCommittedActivation(home:string,binding:Pick<PlacementActivationIntent,"epoch"|"plan">,options:PlacementActivationOptions,
|
|
95
|
+
mutation:MutationHandle,owner:ReturnType<typeof createPlacementActivationOwner>,intent?:PlacementActivationIntent,report?:ReturnType<typeof counts>) {
|
|
96
|
+
const epochHash=placementEpochHash(binding.epoch);
|
|
97
|
+
const check=()=>{assertPlacementActivationOwner(owner);requirePlacementHomeFloor(home);if(!committed(home,binding))fail("changed");readSyncPlacementWriterRoots(home);assertMutationHandle(mutation,home);};
|
|
98
|
+
authorize(options,binding);check();
|
|
99
|
+
for(const name of ["profile.json","shards.json",SYNC_PLACEMENT_WRITER_ROOTS_FILE,join("sync-placement","epochs",epochHash,"epoch.json"),join("sync-placement","epochs",epochHash,"activation.json")]){check();syncFile(join(home,name));}
|
|
100
|
+
ensurePlacementFence(home,epochHash,{mutation,assertCurrent:check});check();
|
|
101
|
+
if(intent)clearPlacementActivationIntent(home,intent,{mutation,assertCurrent:check});else fsyncDirectory(home);
|
|
102
|
+
await options.checkpoint?.("after-remove");check();
|
|
103
|
+
return{mode:options.mode,active:true,forwardOnly:true,epochHash,generation:binding.epoch.generation,writers:binding.plan.writers.length,source:report,resumed:options.mode==="resume",alreadyCommitted:!intent,writerProof:"not-claimed-during-committed-cleanup" as const};
|
|
104
|
+
}
|
|
105
|
+
export async function activateSyncPlacement(options:PlacementActivationOptions) {
|
|
106
|
+
if(!["dry-run","apply","resume","abandon"].includes(options.mode)||typeof options.authorize!=="function")fail("authorization");
|
|
107
|
+
let home:string;
|
|
108
|
+
try{home=realPath(options.memoryDir);}catch{throw Error("Sync placement memory home is unavailable; select an existing private home and run amem migrate --sync-placement --dry-run.");}
|
|
109
|
+
const pending=readPlacementActivationIntent(home);
|
|
110
|
+
if(!pending&&requirePlacementActivationPrerequisites(home).active){
|
|
111
|
+
const renewed=await renewSyncPlacementWriters(options);if(renewed)return renewed;
|
|
112
|
+
}
|
|
113
|
+
if(options.mode==="abandon"){
|
|
114
|
+
if(!pending)throw Error("No initial sync placement intent is retained; use amem migrate --sync-placement --dry-run to inspect the home. Active v3 placement cannot be abandoned or downgraded.");
|
|
115
|
+
const dirs=pending.plan.realms.map(realm=>realm.scope==="user"?join(home,"user"):join(home,"projects",realm.keyHash.slice(0,16)));
|
|
116
|
+
return withPlacementActivationFences(home,dirs,async(mutation,owner)=>{
|
|
117
|
+
authorize(options,pending);
|
|
118
|
+
abandonPlacementActivation(home,pending,{mutation,assertCurrent:()=>assertPlacementActivationOwner(owner)});
|
|
119
|
+
return{mode:options.mode,active:false,abandoned:true,forwardOnly:true,nextStep:"amem migrate --sync-placement --dry-run"};
|
|
120
|
+
}).catch(error=>rethrowActivationFailure(error));
|
|
121
|
+
}
|
|
122
|
+
const initialBase=pending?undefined:requirePlacementActivationPrerequisites(home);
|
|
123
|
+
let initialFloorHash:string;
|
|
124
|
+
try{initialFloorHash=pending?requirePlacementHomeFloor(home):initialBase!.floorHash;}catch(error){if(pending)throw new PlacementActivationInputError("floor");throw error;}
|
|
125
|
+
if(options.mode==="resume"&&!pending){
|
|
126
|
+
if(!initialBase?.active)throw Error("No retained sync placement activation exists; run amem migrate --sync-placement --dry-run to review an initial activation.");
|
|
127
|
+
const active=initialBase.active,dirs=active.activation.realms.map(realm=>realm.scope==="user"?join(home,"user"):join(home,"projects",realm.keyHash.slice(0,16)));
|
|
128
|
+
return withPlacementActivationFences(home,dirs,(mutation,owner)=>finishCommittedActivation(home,{epoch:active.epoch,plan:active.activation},options,mutation,owner)).catch(error=>rethrowActivationFailure(error));
|
|
129
|
+
}
|
|
130
|
+
if(pending&&options.mode!=="resume")throw Error("Sync placement activation is retained; run amem migrate --sync-placement --resume, or person --abandon while both markers retain the original v2 bytes, before reviewing a new --dry-run.");
|
|
131
|
+
const source=pending?undefined:previewSyncPlacementSources(home);
|
|
132
|
+
const writer=pending?undefined:initialPlacementWriterContext(home,options.writerRoots,options.ownRoot);
|
|
133
|
+
const report=source?counts(source):undefined;
|
|
134
|
+
if(options.mode==="dry-run")return{mode:options.mode,active:false,forwardOnly:true,quiescence:"deferred-to-apply",realms:source!.base.realms.length,writers:writer!.measurement.roots,source:report};
|
|
135
|
+
if(!pending&&!options.writerRoots.length)throw Error("Initial sync placement apply requires an explicit complete --writer-root inventory; run amem migrate --sync-placement --dry-run, then apply the reviewed roots.");
|
|
136
|
+
const directories=pending?pending.plan.realms.map(realm=>realm.scope==="user"?join(home,"user"):join(home,"projects",realm.keyHash.slice(0,16))):source!.directories;
|
|
137
|
+
try{return await withPlacementActivationFences(home,directories,async(mutation,owner)=>{
|
|
138
|
+
let intent=pending;
|
|
139
|
+
const initialCheck=()=>{assertPlacementActivationOwner(owner);source!.assertSourcesUnchanged();writer!.attest();if(requirePlacementHomeFloor(home)!==initialFloorHash)fail("changed");};
|
|
140
|
+
if(!intent){
|
|
141
|
+
initialCheck();const facts=writer!.attest(),context={...source!.base,writers:facts.writers,legacySyncQuiesced:true as const};
|
|
142
|
+
const normalized=planSyncPlacementActivation(context,"0".repeat(64));context.writers=normalized.writers;context.realms=normalized.realms;
|
|
143
|
+
const epoch:PlacementEpoch={schema:PLACEMENT_EPOCH_SCHEMA,publicationFormat:PLACEMENT_FORMAT,generation:(context.profile as {generation:string}).generation,homeHash:context.homeHash,contextHash:hashJSON(context),nonce:randomBytes(16).toString("hex")};
|
|
144
|
+
const plan=planSyncPlacementActivation(context,placementEpochHash(epoch));
|
|
145
|
+
intent=sealPlacementActivationIntent({schema:PLACEMENT_ACTIVATION_INTENT_SCHEMA,home,epoch,plan,roots:writer!.measurement.roots.map(row=>row.root).sort(),previousProfile:source!.markers.profileText,previousShards:source!.markers.shardsText,profileStamp:source!.markers.profileStamp,shardsStamp:source!.markers.shardsStamp,floorHash:initialFloorHash,inputHashes:{sourceHash:context.sourceHash,aliasesHash:context.aliasesHash,policyHash:context.policyHash}});
|
|
146
|
+
// All future initial-locator framing/binding limits must fit before retaining an exclusive activation intent.
|
|
147
|
+
const epochHash=placementEpochHash(epoch);
|
|
148
|
+
planSyncPlacementWriterRoots({epochHash,homeHash:epoch.homeHash,activationHash:plan.seal,roots:writer!.measurement.roots},
|
|
149
|
+
{epochHash,homeHash:epoch.homeHash,activationHash:plan.seal,writers:plan.writers});
|
|
150
|
+
validateSyncPlacementActivation(plan,{current:()=>{initialCheck();return context;},authorize:()=>{authorize(options,intent!);return true;}});
|
|
151
|
+
writePlacementActivationIntent(home,intent,{mutation,assertCurrent:initialCheck});
|
|
152
|
+
}
|
|
153
|
+
const fixed=intent,epochHash=placementEpochHash(fixed.epoch);
|
|
154
|
+
const metadataCheck=()=>{assertPlacementActivationOwner(owner);requirePlacementHomeFloor(home);if(!committed(home,fixed))fail("changed");readSyncPlacementWriterRoots(home);assertMutationHandle(mutation,home);};
|
|
155
|
+
if(committed(home,fixed))return finishCommittedActivation(home,fixed,options,mutation,owner,fixed,report);
|
|
156
|
+
let current:PlacementWriterContext;
|
|
157
|
+
try{current=writer??initialPlacementWriterContext(home,fixed.roots,options.ownRoot);}catch(error){writerInputFailure(error);}
|
|
158
|
+
if(canonicalJSON(current.measurement.writers)!==canonicalJSON(fixed.plan.writers))throw new PlacementActivationInputError("writer root");
|
|
159
|
+
const proof=createPlacementActivationCaptureProof(owner,fixed);
|
|
160
|
+
const check=()=>{
|
|
161
|
+
assertPlacementActivationOwner(owner);
|
|
162
|
+
try{if(requirePlacementHomeFloor(home)!==fixed.floorHash)throw Error();}catch{throw new PlacementActivationInputError("floor");}
|
|
163
|
+
try{current.attest();}catch(error){writerInputFailure(error);}
|
|
164
|
+
let captured:ReturnType<typeof captureSyncPlacementActivationContext>;
|
|
165
|
+
try{captured=captureSyncPlacementActivationContext(home,{attest:current.attest},proof);}catch(error){if(current.lastRefusal)writerInputFailure(current.lastRefusal);throw new PlacementActivationInputError("source");}
|
|
166
|
+
if(captured.context.policyHash!==fixed.inputHashes.policyHash)throw new PlacementActivationInputError("policy");
|
|
167
|
+
if(captured.context.aliasesHash!==fixed.inputHashes.aliasesHash)throw new PlacementActivationInputError("aliases");
|
|
168
|
+
if(captured.context.sourceHash!==fixed.inputHashes.sourceHash||hashJSON(captured.context)!==fixed.plan.contextHash)throw new PlacementActivationInputError("source");
|
|
169
|
+
try{captured.assertUnchanged();}catch(error){if(current.lastRefusal)writerInputFailure(current.lastRefusal);throw new PlacementActivationInputError("source");}
|
|
170
|
+
assertPlacementActivationOwner(owner);
|
|
171
|
+
};
|
|
172
|
+
authorize(options,fixed);check();writePlacementActivationIntent(home,fixed,{mutation,assertCurrent:check});await options.checkpoint?.("after-intent");check();
|
|
173
|
+
preparePlacementEpoch(home,fixed.epoch,fixed.plan,{mutation,assertCurrent:check});await options.checkpoint?.("after-epoch");check();
|
|
174
|
+
writeSyncPlacementWriterRoots(home,{epochHash,homeHash:fixed.epoch.homeHash,activationHash:fixed.plan.seal,roots:current.measurement.roots},fixed.plan.writers,{mutation,assertCurrent:check});await options.checkpoint?.("after-root-map");check();
|
|
175
|
+
const final=placementActivationFinalMarkers(fixed),markers=readPlacementRuntimeMarkers(home);
|
|
176
|
+
if(canonicalJSON(markers.shards)+"\n"!==final.shards)writeProfileJSON(join(home,"shards.json"),JSON.parse(final.shards));else syncFile(join(home,"shards.json"));
|
|
177
|
+
await options.checkpoint?.("after-shards");check();
|
|
178
|
+
if(canonicalJSON(readPlacementRuntimeMarkers(home).profile)+"\n"!==final.profile)writeProfileJSON(join(home,"profile.json"),JSON.parse(final.profile));else syncFile(join(home,"profile.json"));
|
|
179
|
+
await options.checkpoint?.("after-profile");check();
|
|
180
|
+
ensurePlacementFence(home,epochHash,{mutation,assertCurrent:check});await options.checkpoint?.("after-fence");metadataCheck();return finishCommittedActivation(home,fixed,options,mutation,owner,fixed,report);
|
|
181
|
+
});}catch(error){
|
|
182
|
+
try{options.testOnlyFailure?.(error);}catch{/* observers never affect production outcomes */}
|
|
183
|
+
return rethrowActivationFailure(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -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
|
+
}
|