@staix/agent-mem 0.21.0 → 0.22.1
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 +27 -1
- package/package.json +5 -2
- package/src/cli-agent-reads.ts +3 -1
- package/src/cli.ts +36 -4
- package/src/commands-table.ts +3 -0
- package/src/index.ts +6 -0
- package/src/memory/commands.ts +3 -2
- package/src/memory/generation.ts +6 -3
- package/src/memory/profile-lock-diagnostic.ts +69 -0
- package/src/memory/public-prune-activation.ts +4 -4
- package/src/memory/reader-lease.ts +3 -2
- package/src/memory/shard-sync.ts +1 -2
- package/src/memory/shard.ts +4 -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-capture.ts +104 -23
- package/src/memory/sync-placement-effective-writers.ts +84 -0
- package/src/memory/sync-placement-guard.ts +18 -8
- package/src/memory/sync-placement-inspection.ts +5 -1
- package/src/memory/sync-placement-journal-storage.ts +51 -7
- package/src/memory/sync-placement-journal.ts +11 -4
- package/src/memory/sync-placement-prerequisites.ts +50 -0
- package/src/memory/sync-placement-transport.ts +17 -6
- 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.ts +1 -2
- package/src/setup-toml.ts +3 -2
- package/src/setup.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
|
@@ -18,6 +18,10 @@ import { validatePruneRow, type PruneSourceRow, type PruneRow } from "./prune.ts
|
|
|
18
18
|
import { shardKeyHash, SHARD_SCHEMA } from "./shard.ts";
|
|
19
19
|
import { SyncPlacementJournalError, placementKeyId, type PlacementKey } from "./sync-placement-journal-format.ts";
|
|
20
20
|
import type { Entry } from "./store.ts";
|
|
21
|
+
import { assertPlacementRuntime } from "./sync-placement-guard.ts";
|
|
22
|
+
import { activationSourceMarkers, assertPlacementActivationOwner, type PlacementActivationOwner, type PlacementActivationCaptureProof } from "./sync-placement-activation-proof.ts";
|
|
23
|
+
import { readEffectiveSyncPlacementWriters, writerBoundPlacementContextHash, writerInventoryEvidence, type EffectiveSyncPlacementWriters } from "./sync-placement-effective-writers.ts";
|
|
24
|
+
import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
|
|
21
25
|
|
|
22
26
|
/** Host authentication/compatibility facts are re-read at every effect boundary, never inferred from a session marker. */
|
|
23
27
|
export interface PlacementCapturePort {
|
|
@@ -35,10 +39,22 @@ export interface PlacementCapture {
|
|
|
35
39
|
/** Diagnostic inventory only; these rows never enter the ordinary transport/capture row map implicitly. */
|
|
36
40
|
specialRows: Map<string, PlacementSpecialSourceRow>;
|
|
37
41
|
sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>;
|
|
42
|
+
writerInventory?:EffectiveSyncPlacementWriters;
|
|
38
43
|
assertCurrent(): void;
|
|
39
44
|
/** Under live writer fences, inode/ctime/size checks protect the already-hashed prefixes across callbacks without reparsing rows. */
|
|
40
45
|
assertUnchanged(): void;
|
|
41
46
|
}
|
|
47
|
+
export function placementCaptureContextHash(capture:Pick<PlacementCapture,"context"|"writerInventory">):string{return writerBoundPlacementContextHash(capture.context,capture.writerInventory);}
|
|
48
|
+
export interface PlacementActivationMarkerSnapshot {
|
|
49
|
+
profileText: string; shardsText: string; profileStamp: [string,string,string,string,string]; shardsStamp: [string,string,string,string,string];
|
|
50
|
+
}
|
|
51
|
+
/** Source facts only: no writer attestation, quiescence claim or effect authority. */
|
|
52
|
+
export interface PlacementSourcePreview {
|
|
53
|
+
home: string; base: Omit<SyncPlacementActivationContext, "writers" | "legacySyncQuiesced">;
|
|
54
|
+
activeHead?: string; markerHash: string; markers: PlacementActivationMarkerSnapshot; directories: string[]; prefixes: PlacementPublicationSource[];
|
|
55
|
+
rows: Map<string, PlacementSourceRow>; specialRows: Map<string, PlacementSpecialSourceRow>;
|
|
56
|
+
sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>; assertSourcesUnchanged(): void;
|
|
57
|
+
}
|
|
42
58
|
function fail(code: "changed" | "invalid" | "bound" = "changed"): never { throw new SyncPlacementJournalError(code); }
|
|
43
59
|
const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
|
|
44
60
|
const stamp = (s: BigIntStats) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].map(String);
|
|
@@ -50,7 +66,7 @@ function ownedDirectory(dir: string): BigIntStats {
|
|
|
50
66
|
}
|
|
51
67
|
/** ponytail: capture is capped at 256 metadata objects and 8 MiB/20000 source rows across all realms.
|
|
52
68
|
* Larger homes need a reviewed chunked activation proof; no input is silently omitted at this ceiling. */
|
|
53
|
-
function
|
|
69
|
+
function sourcePreview(homeInput: string, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], policyVersion?: string, ownerProof?: PlacementActivationCaptureProof, sourceOwner?:PlacementActivationOwner): PlacementSourcePreview {
|
|
54
70
|
let selection: PlacementRealm[] | undefined;
|
|
55
71
|
if (selected !== undefined) {
|
|
56
72
|
if (!Array.isArray(selected) || types.isProxy(selected) || selected.length > 256) fail("bound");
|
|
@@ -58,11 +74,8 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
|
|
|
58
74
|
selection = copied.map(realm => validatePlacementKey({ realm, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm);
|
|
59
75
|
if (!selection.length || new Set(selection.map(realm => canonicalJSON(realm))).size !== selection.length) fail();
|
|
60
76
|
}
|
|
61
|
-
// All host callouts precede filesystem capture: a callback cannot alter input after its final proof.
|
|
62
|
-
const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
|
|
63
|
-
if (!attestation) fail();
|
|
64
|
-
if (port.policyVersion && typeof policyVersion !== "string") fail();
|
|
65
77
|
const home = realPath(homeInput), root = ownedDirectory(home);
|
|
78
|
+
if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
|
|
66
79
|
let metadataObjects = 0, metadataBytes = 0;
|
|
67
80
|
const facts: unknown[] = [], sourceChecks: (() => void)[] = [];
|
|
68
81
|
const pendingByPath = new Map(pending.map(item => [canonicalJSON(item.realm)+":"+item.pathHash,item]));
|
|
@@ -90,7 +103,17 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
|
|
|
90
103
|
const input = bytes(file, optional); if (!input) return undefined;
|
|
91
104
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input));
|
|
92
105
|
};
|
|
93
|
-
const
|
|
106
|
+
const markerStart = facts.length;
|
|
107
|
+
const profileBytes = bytes(join(home, "profile.json"))!, shardsBytes = bytes(join(home, "shards.json"))!;
|
|
108
|
+
const actualMarkers: PlacementActivationMarkerSnapshot = { profileText: profileBytes.toString("utf8"), shardsText: shardsBytes.toString("utf8"),
|
|
109
|
+
profileStamp: [...(facts[markerStart] as [string,PlacementActivationMarkerSnapshot["profileStamp"],string])[1]], shardsStamp: [...(facts[markerStart+1] as [string,PlacementActivationMarkerSnapshot["shardsStamp"],string])[1]] };
|
|
110
|
+
const markers = ownerProof ? activationSourceMarkers(ownerProof,home,actualMarkers) : actualMarkers;
|
|
111
|
+
if (ownerProof) {
|
|
112
|
+
facts[markerStart] = [join(home,"profile.json"),markers.profileStamp,digest(Buffer.from(markers.profileText))];
|
|
113
|
+
facts[markerStart+1] = [join(home,"shards.json"),markers.shardsStamp,digest(Buffer.from(markers.shardsText))];
|
|
114
|
+
}
|
|
115
|
+
const decodeMarker = (text: string): unknown => JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(Buffer.from(text)));
|
|
116
|
+
const rawProfile = decodeMarker(markers.profileText) as { schema?: string }, rawShards = decodeMarker(markers.shardsText);
|
|
94
117
|
let activeHead: string | undefined;
|
|
95
118
|
let base: ReturnType<typeof validatePlacementBaseV2>;
|
|
96
119
|
if (rawProfile?.schema === PLACEMENT_PROFILE_SCHEMA) {
|
|
@@ -109,7 +132,11 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
|
|
|
109
132
|
const machine = json(join(home, "machine.json"));
|
|
110
133
|
if (machine?.id !== base.shards.machine.id) fail();
|
|
111
134
|
// An in-flight migration/prune is never accepted as a stable placement source.
|
|
112
|
-
for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT])
|
|
135
|
+
for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT]) {
|
|
136
|
+
if (name === "profile.lock" && ownerProof) continue; // The module-private proof validated this exact live owner and sealed intent.
|
|
137
|
+
if(name==="profile.lock"&&sourceOwner){assertPlacementActivationOwner(sourceOwner,home);continue;}
|
|
138
|
+
if (lstatSync(join(home,name),{throwIfNoEntry:false})) fail();
|
|
139
|
+
}
|
|
113
140
|
const reader = new SegmentReader(16 * 1024 * 1024, { maxSourceBytes: 8 * 1024 * 1024, maxRows: 20000 });
|
|
114
141
|
const rows = new Map<string, PlacementSourceRow>(), specialRows = new Map<string, PlacementSpecialSourceRow>(), sourceRowsByRealm = new Map<string, readonly PruneSourceRow[]>();
|
|
115
142
|
const directories = [home], prefixes: PlacementPublicationSource[] = [];
|
|
@@ -242,29 +269,68 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
|
|
|
242
269
|
if (selection && realms.length !== selection.length) fail();
|
|
243
270
|
const defaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS }); if (!defaults) fail();
|
|
244
271
|
const configuration = loadAmemConfig(home).config;
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
272
|
+
const contextBase: PlacementSourcePreview["base"] = { ...base,
|
|
273
|
+
homeHash: hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]), sourceHash: hashJSON(facts),
|
|
274
|
+
aliasesHash: digest(aliases ?? Buffer.alloc(0)), policyHash: hashJSON({ config: configuration, defaults, policyVersion }), realms };
|
|
275
|
+
// ponytail: passive checks rely on native inode/size/ctime identity for the hashed prefixes.
|
|
276
|
+
// Immutable descriptor-bound source blocks are the upgrade path for hostile same-user raw writers.
|
|
277
|
+
const assertSourcesUnchanged = () => {
|
|
278
|
+
try {
|
|
279
|
+
const currentDefaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
|
|
280
|
+
if (!currentDefaults || hashJSON({ config: configuration, defaults: currentDefaults, policyVersion }) !== contextBase.policyHash) fail();
|
|
281
|
+
const now = ownedDirectory(home); if (now.dev !== root.dev || now.ino !== root.ino) fail();
|
|
282
|
+
for (const check of sourceChecks) check();
|
|
283
|
+
if (ownerProof) activationSourceMarkers(ownerProof,home,actualMarkers);
|
|
284
|
+
if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
|
|
285
|
+
} catch { fail(); }
|
|
286
|
+
};
|
|
287
|
+
return { home, base: contextBase, assertSourcesUnchanged, ...(activeHead ? { activeHead } : {}), markerHash, markers,
|
|
288
|
+
directories: directories.sort(), prefixes, rows, specialRows, sourceRowsByRealm };
|
|
289
|
+
}
|
|
290
|
+
/** Read-only preview shares the source collector, without fabricating a quiescence attestation. */
|
|
291
|
+
export function previewSyncPlacementSources(home: string): PlacementSourcePreview {
|
|
292
|
+
return previewSources(home);
|
|
293
|
+
}
|
|
294
|
+
/** Internal renewal inventory under the exact live home/profile/mutation owner; no marker virtualization. */
|
|
295
|
+
export function previewOwnedSyncPlacementSources(home:string,owner:PlacementActivationOwner):PlacementSourcePreview {
|
|
296
|
+
assertPlacementActivationOwner(owner,home);return previewSources(home,owner);
|
|
297
|
+
}
|
|
298
|
+
function previewSources(home:string,owner?:PlacementActivationOwner):PlacementSourcePreview {
|
|
299
|
+
try { assertPlacementRuntime(home); const preview = sourcePreview(home,undefined,[],undefined,undefined,owner); preview.assertSourcesUnchanged(); return preview; }
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
302
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
303
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
304
|
+
}
|
|
305
|
+
return fail();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function capture(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], ownerProof?: PlacementActivationCaptureProof): Omit<PlacementCapture, "assertCurrent"> {
|
|
309
|
+
// All host callouts precede filesystem capture, including the final passive proof.
|
|
310
|
+
const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
|
|
311
|
+
if (!attestation || port.policyVersion && typeof policyVersion !== "string") fail();
|
|
312
|
+
const source = sourcePreview(home, selected, pending, policyVersion, ownerProof);
|
|
313
|
+
const context: SyncPlacementActivationContext = { ...source.base, writers: attestation.writers, legacySyncQuiesced: attestation.legacySyncQuiesced };
|
|
249
314
|
const validated = planSyncPlacementActivation(context, "0".repeat(64));
|
|
250
315
|
context.writers = validated.writers; context.realms = validated.realms;
|
|
251
|
-
|
|
252
|
-
|
|
316
|
+
const writerInventory=source.activeHead&&writerInventoryEvidence(source.home,source.activeHead)?readEffectiveSyncPlacementWriters(source.home):undefined;
|
|
317
|
+
if(writerInventory&&canonicalJSON(context.writers)!==canonicalJSON(writerInventory.writers))fail();
|
|
253
318
|
const assertUnchanged = () => {
|
|
254
319
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const now=ownedDirectory(home); if(now.dev!==root.dev||now.ino!==root.ino) fail();
|
|
263
|
-
for(const check of sourceChecks)check();
|
|
320
|
+
const currentAttestation = memoryPolicySnapshot(port.attest()), currentVersion = port.policyVersion?.();
|
|
321
|
+
if (!currentAttestation) fail();
|
|
322
|
+
const checked = planSyncPlacementActivation({ ...context, writers: currentAttestation.writers, legacySyncQuiesced: currentAttestation.legacySyncQuiesced }, "0".repeat(64));
|
|
323
|
+
if (canonicalJSON(checked.writers) !== canonicalJSON(context.writers) || currentVersion !== policyVersion) fail();
|
|
324
|
+
writerInventory?.assertCurrent();
|
|
325
|
+
if(source.activeHead&&!writerInventory&&writerInventoryEvidence(source.home,source.activeHead))fail();
|
|
326
|
+
source.assertSourcesUnchanged();
|
|
264
327
|
} catch { fail(); }
|
|
265
328
|
};
|
|
266
|
-
return { home, context, assertUnchanged, ...(activeHead ? { activeHead
|
|
329
|
+
return { home: source.home, context, assertUnchanged, ...(source.activeHead ? { activeHead: source.activeHead } : {}),
|
|
330
|
+
...(writerInventory?{writerInventory}:{}),markerHash: source.markerHash, fingerprint: hashJSON({ context, markerHash: source.markerHash,...(writerInventory?.revisionSequence?{writerRevisionHash:writerInventory.revisionHash}:{}) }), directories: source.directories,
|
|
331
|
+
prefixes: source.prefixes, rows: source.rows, specialRows: source.specialRows, sourceRowsByRealm: source.sourceRowsByRealm };
|
|
267
332
|
}
|
|
333
|
+
|
|
268
334
|
export function captureSyncPlacementContext(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pendingInput: readonly PlacementPendingSource[] = []): PlacementCapture {
|
|
269
335
|
try {
|
|
270
336
|
const selection = selected === undefined ? undefined : memoryPolicySnapshot(selected);
|
|
@@ -282,6 +348,21 @@ export function captureSyncPlacementContext(home: string, port: PlacementCapture
|
|
|
282
348
|
});
|
|
283
349
|
const before = capture(home, port, selection, pending);
|
|
284
350
|
return { ...before, assertCurrent: () => { try { if (capture(home, port, selection, pending).fingerprint !== before.fingerprint) fail(); } catch { fail(); } } };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
353
|
+
if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="staging"||code==="pending")throw new SharedWriterRevisionError(code);}
|
|
354
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
355
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
356
|
+
}
|
|
357
|
+
return fail();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Internal initial-activation capture only. Tokens require the actual profile owner, live home mutation and exact durable intent. */
|
|
362
|
+
export function captureSyncPlacementActivationContext(home: string, port: PlacementCapturePort, proof: PlacementActivationCaptureProof): PlacementCapture {
|
|
363
|
+
try {
|
|
364
|
+
const before = capture(home,port,undefined,[],proof);
|
|
365
|
+
return { ...before, assertCurrent: () => { if (capture(home,port,undefined,[],proof).fingerprint !== before.fingerprint) fail(); } };
|
|
285
366
|
} catch (error) {
|
|
286
367
|
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
287
368
|
if (error instanceof SyncPlacementJournalError) throw error;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Expected inventory metadata only. Callers must still obtain fresh install attestations. */
|
|
2
|
+
import { lstatSync, opendirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hashJSON } from "./generation.ts";
|
|
5
|
+
import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
|
|
6
|
+
import { readActivePlacementBinding, type ActivePlacementBinding } from "./sync-placement-active.ts";
|
|
7
|
+
import { readPlacementWriterJournalAnchors, placementWriterJournalToken } from "./sync-placement-journal-storage.ts";
|
|
8
|
+
import { readWriterHistory, isWriterRenewalIntentTempName } from "./sync-placement-writer-history.ts";
|
|
9
|
+
import { SharedWriterRevisionError, writerRevisionWriters, WRITER_RENEWAL_INTENT_FILE } from "./sync-placement-writer-revision-format.ts";
|
|
10
|
+
import { readInitialSyncPlacementWriterLocator, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
|
|
11
|
+
import { requireWriterRenewalHomeFloor } from "./sync-placement-prerequisites.ts";
|
|
12
|
+
import type { SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
|
|
13
|
+
|
|
14
|
+
export interface EffectiveSyncPlacementWriters {
|
|
15
|
+
epochHash:string;activationHash:string;revisionSequence:number;revisionHash:string;roots?:readonly string[];
|
|
16
|
+
writers:readonly SyncPlacementWriterAttestation[];initialLocatorHash?:string;
|
|
17
|
+
assertCurrent():void;
|
|
18
|
+
}
|
|
19
|
+
function fail():never{throw new SharedWriterRevisionError("changed");}
|
|
20
|
+
function active(home:string):ActivePlacementBinding {
|
|
21
|
+
const markers=readPlacementRuntimeMarkers(home),value=readActivePlacementBinding(home,markers.profile,markers.shards);if(!value)fail();return value;
|
|
22
|
+
}
|
|
23
|
+
const key=(value:ActivePlacementBinding)=>hashJSON({epoch:value.epoch,activation:value.activation,head:value.profile.placementHead,generation:value.profile.generation});
|
|
24
|
+
const fileStamp=(home:string)=>{
|
|
25
|
+
const s=lstatSync(join(home,SYNC_PLACEMENT_WRITER_ROOTS_FILE),{bigint:true,throwIfNoEntry:false});
|
|
26
|
+
return s?[s.dev,s.ino,s.mode,s.uid,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":"):undefined;
|
|
27
|
+
};
|
|
28
|
+
/** A source-only capture can bootstrap its initial fence; do not impose renewal admission on that old path. */
|
|
29
|
+
export function writerInventoryEvidence(home:string,epochHash:string):boolean{
|
|
30
|
+
const scan=(dir:string,maximum:number,match:(name:string)=>boolean)=>{
|
|
31
|
+
const reader=opendirSync(dir);let count=0,found=false;try{for(;;){const e=reader.readSync();if(!e)break;if(++count>maximum)throw new SharedWriterRevisionError("bound");if(match(e.name))found=true;}}finally{reader.closeSync();}return found;
|
|
32
|
+
};
|
|
33
|
+
const outside=scan(home,20000,name=>{
|
|
34
|
+
if(name.startsWith(WRITER_RENEWAL_INTENT_FILE+".pending-")&&!isWriterRenewalIntentTempName(name))throw new SharedWriterRevisionError("invalid");
|
|
35
|
+
return name===WRITER_RENEWAL_INTENT_FILE||isWriterRenewalIntentTempName(name);
|
|
36
|
+
});
|
|
37
|
+
let dir=home;
|
|
38
|
+
for(const part of ["sync-placement","epochs",epochHash]){
|
|
39
|
+
dir=join(dir,part);const s=lstatSync(dir,{throwIfNoEntry:false});if(!s)return outside;
|
|
40
|
+
if(!s.isDirectory()||s.isSymbolicLink()||s.uid!==process.getuid?.()||(s.mode&0o077)!==0)throw new SharedWriterRevisionError("invalid");
|
|
41
|
+
}
|
|
42
|
+
return scan(dir,255,name=>name.startsWith("writer-"))||outside;
|
|
43
|
+
}
|
|
44
|
+
export function readEffectiveSyncPlacementWriters(home:string):EffectiveSyncPlacementWriters {
|
|
45
|
+
const binding=active(home),bindingKey=key(binding),epochHash=binding.profile.placementHead;
|
|
46
|
+
writerInventoryEvidence(home,epochHash);
|
|
47
|
+
const history=readWriterHistory(home,{epochHash,activationHash:binding.activation.seal,homeHash:binding.epoch.homeHash,generation:binding.epoch.generation});
|
|
48
|
+
const initialStamp=fileStamp(home),initial=initialStamp===undefined?undefined:readInitialSyncPlacementWriterLocator(home);
|
|
49
|
+
if(history.sequence){
|
|
50
|
+
requireWriterRenewalHomeFloor(home);
|
|
51
|
+
if(!initial||history.current?.initialLocatorHash!==initial.seal)fail();
|
|
52
|
+
}
|
|
53
|
+
let anchorToken:string|undefined;
|
|
54
|
+
// ponytail: native inode/size/ctime identity permits prefix reuse; hostile same-user rewrites need immutable snapshot blocks.
|
|
55
|
+
const anchors=()=>{
|
|
56
|
+
if(!history.sequence)return;
|
|
57
|
+
const token=placementWriterJournalToken(home,epochHash);if(token===anchorToken)return;
|
|
58
|
+
const journal=readPlacementWriterJournalAnchors(home,epochHash);
|
|
59
|
+
if(journal.epoch.homeHash!==binding.epoch.homeHash||journal.activation.seal!==binding.activation.seal)fail();
|
|
60
|
+
for(const anchor of history.anchors){
|
|
61
|
+
if(anchor.sequence>journal.sequence||(anchor.sequence===0?epochHash:journal.records[anchor.sequence-1]?.seal)!==anchor.headHash)fail();
|
|
62
|
+
}
|
|
63
|
+
if(placementWriterJournalToken(home,epochHash)!==token)fail();anchorToken=token;
|
|
64
|
+
};
|
|
65
|
+
const assertCurrent=()=>{
|
|
66
|
+
history.assertCurrent();if(key(active(home))!==bindingKey||fileStamp(home)!==initialStamp)fail();
|
|
67
|
+
if(initial&&readInitialSyncPlacementWriterLocator(home).seal!==initial.seal)fail();
|
|
68
|
+
if(history.sequence)requireWriterRenewalHomeFloor(home);
|
|
69
|
+
anchors();history.assertCurrent();
|
|
70
|
+
};
|
|
71
|
+
anchors();assertCurrent();
|
|
72
|
+
const roots=history.current?.roots.map(root=>root.root)??initial?.roots.map(root=>root.root);
|
|
73
|
+
return{epochHash,activationHash:binding.activation.seal,revisionSequence:history.sequence,revisionHash:history.headHash,
|
|
74
|
+
writers:history.current?writerRevisionWriters(history.current):binding.activation.writers,
|
|
75
|
+
...(roots?{roots}:{}),...(initial?{initialLocatorHash:initial.seal}:{}),assertCurrent};
|
|
76
|
+
}
|
|
77
|
+
/** Locators only; these paths and expected pins never replace a fresh callback. */
|
|
78
|
+
export function readEffectiveSyncPlacementWriterRoots(home:string):string[]{
|
|
79
|
+
const value=readEffectiveSyncPlacementWriters(home);if(!value.roots)throw new SharedWriterRevisionError("invalid");value.assertCurrent();return[...value.roots];
|
|
80
|
+
}
|
|
81
|
+
/** Keep existing no-revision artifacts byte-identical while binding renewed attempts to their committed head. */
|
|
82
|
+
export function writerBoundPlacementContextHash(context:unknown,inventory?:Pick<EffectiveSyncPlacementWriters,"revisionSequence"|"revisionHash">):string {
|
|
83
|
+
return inventory?.revisionSequence?hashJSON(["agent-mem.sync-placement-writer-bound-context/v1",context,inventory.revisionHash]):hashJSON(context);
|
|
84
|
+
}
|
|
@@ -6,12 +6,15 @@ import { join } from "node:path";
|
|
|
6
6
|
import { LOCAL_METADATA_MAX_BYTES, legacyProfileMarkerShape, type BoundProfile } from "./generation.ts";
|
|
7
7
|
import { legacyShardMarkerShape } from "./shard.ts";
|
|
8
8
|
import { PLACEMENT_PROFILE_SCHEMA, PLACEMENT_SHARDS_SCHEMA, validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
|
|
9
|
+
import { PLACEMENT_ACTIVATION_INTENT } from "./sync-placement-activation-path.ts";
|
|
10
|
+
import { profileLockRefusal, rememberProfileLockError } from "./profile-lock-diagnostic.ts";
|
|
9
11
|
|
|
10
12
|
export const PLACEMENT_STAGE1_REFUSAL = "sync placement v3 requires matching durable activation metadata; keep the current compatible profile active until person-authorized activation is complete";
|
|
11
13
|
export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
|
|
14
|
+
export const PLACEMENT_ACTIVATION_PENDING = "sync placement activation is pending or incomplete; have the person inspect retained evidence and run amem migrate --sync-placement --resume, or --abandon only while both markers retain their original v2 bytes";
|
|
12
15
|
export class SyncPlacementRuntimeError extends Error {
|
|
13
16
|
readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
|
|
14
|
-
constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
|
|
17
|
+
constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable";rememberProfileLockError(this,reason); }
|
|
15
18
|
}
|
|
16
19
|
class MarkerChanged extends Error {}
|
|
17
20
|
const same = (left: BigIntStats, right: BigIntStats) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
@@ -63,15 +66,22 @@ export function placementMarkerRefusal(profile: unknown, shards: unknown): strin
|
|
|
63
66
|
/** Selector preflight only recognizes v3. Legacy malformed/split metadata must retain its existing reader/recovery path. */
|
|
64
67
|
// This narrow probe is not a data/transport gate; effects must still use the full runtime guard.
|
|
65
68
|
export function placementSelectorRefusal(home: string): string | undefined {
|
|
69
|
+
const refusal=selectorRefusal(home);return refusal?profileLockRefusal(home)??refusal:undefined;
|
|
70
|
+
}
|
|
71
|
+
function selectorRefusal(home:string):string|undefined {
|
|
66
72
|
// A durable placement fence forbids falling back to raw legacy sync after marker rollback.
|
|
67
73
|
const fenced = placementFencePresent(home);
|
|
74
|
+
const pending = placementActivationPending(home);
|
|
68
75
|
let profile: unknown, shards: unknown;
|
|
69
76
|
try { profile = readMarker(join(home, "profile.json")); } catch { /* The original selector reader reports this legacy failure. */ }
|
|
70
77
|
try { shards = readMarker(join(home, "shards.json")); } catch { /* Ordinary migration still owns malformed split-state recovery. */ }
|
|
71
|
-
if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
|
|
78
|
+
if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
|
|
72
79
|
const refusal = placementMarkerRefusal(profile, shards);
|
|
73
|
-
if (refusal !== PLACEMENT_STAGE1_REFUSAL) return refusal;
|
|
74
|
-
try { return readActivePlacementBinding(home, profile, shards) ? undefined : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
80
|
+
if (refusal !== PLACEMENT_STAGE1_REFUSAL) return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
|
|
81
|
+
try { return readActivePlacementBinding(home, profile, shards) ? undefined : pending ? PLACEMENT_ACTIVATION_PENDING : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
82
|
+
}
|
|
83
|
+
function placementActivationPending(home: string): boolean {
|
|
84
|
+
return lstatSync(join(home,PLACEMENT_ACTIVATION_INTENT),{throwIfNoEntry:false}) !== undefined;
|
|
75
85
|
}
|
|
76
86
|
function placementFencePresent(home: string): boolean {
|
|
77
87
|
const root = lstatSync(join(home, "sync-placement"), { throwIfNoEntry: false });
|
|
@@ -82,17 +92,17 @@ function placementFencePresent(home: string): boolean {
|
|
|
82
92
|
/** Bounded activation/view metadata only; no data/index open, cache mutation, lock or child/network call.
|
|
83
93
|
* The v3 publication coordinator separately proves the complete journal, selected sources and policy. */
|
|
84
94
|
export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,"kind"|"sharded"|"generation"|"publicationFormat"|"placementHead">): string | undefined {
|
|
85
|
-
try { const fenced = placementFencePresent(home), markers = readPlacementRuntimeMarkers(home);
|
|
95
|
+
try { const fenced = placementFencePresent(home), pending = placementActivationPending(home), markers = readPlacementRuntimeMarkers(home);
|
|
86
96
|
const refusal = placementMarkerRefusal(markers.profile, markers.shards);
|
|
87
97
|
if (refusal === PLACEMENT_STAGE1_REFUSAL) {
|
|
88
98
|
const active=readActivePlacementBinding(home,markers.profile,markers.shards);
|
|
89
|
-
if(!active)return refusal;
|
|
99
|
+
if(!active)return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
|
|
90
100
|
if(bound && (bound.kind!=="generation"||bound.sharded!==true||bound.generation!==active.profile.generation||bound.publicationFormat!==active.profile.publicationFormat||bound.placementHead!==active.profile.placementHead)) return PLACEMENT_MARKER_REFUSAL;
|
|
91
101
|
return undefined;
|
|
92
102
|
}
|
|
93
|
-
return refusal ?? (fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
|
|
103
|
+
return refusal ?? (pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
|
|
94
104
|
catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
95
105
|
}
|
|
96
106
|
export function assertPlacementRuntime(home: string): void {
|
|
97
|
-
const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(refusal);
|
|
107
|
+
const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(profileLockRefusal(home)??refusal);
|
|
98
108
|
}
|
|
@@ -25,6 +25,7 @@ import type { PlacementPublicationDecisionReason } from "./sync-placement-public
|
|
|
25
25
|
import type { ShardSyncRealm, ShardSyncRow } from "./shard-sync-format.ts";
|
|
26
26
|
import type { MemoryStore } from "./store.ts";
|
|
27
27
|
import { captureInspectionPendingPrunes } from "./sync-placement-inspection-pending.ts";
|
|
28
|
+
import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
|
|
28
29
|
|
|
29
30
|
export interface SyncPlacementInspectionOptions {
|
|
30
31
|
screen?: RowScreen;
|
|
@@ -119,7 +120,7 @@ export async function inspectSyncPlacement(store: MemoryStore, input: SyncPlacem
|
|
|
119
120
|
const port: PlacementCapturePort = { attest: options.placementAttestation, ...(options.policyVersion ? { policyVersion: options.policyVersion } : {}) };
|
|
120
121
|
const capture = captureSyncPlacementContext(home, port, targets.map(target => physical(target.realm)));
|
|
121
122
|
captured = true;
|
|
122
|
-
if (capture.activeHead !== active.profile.placementHead || canonicalJSON(capture.context.writers) !== canonicalJSON(active.activation.writers)) fail("attestation-unavailable");
|
|
123
|
+
if (capture.activeHead !== active.profile.placementHead || canonicalJSON(capture.context.writers) !== canonicalJSON(capture.writerInventory?.writers??active.activation.writers)) fail("attestation-unavailable");
|
|
123
124
|
phase = "journal-unavailable"; const retained = captureInspectionJournal(home, active.profile.placementHead);
|
|
124
125
|
const check = () => {
|
|
125
126
|
if (binding() !== beforeBinding) fail("binding-changed"); retained.assertCurrent(); capture.assertUnchanged();
|
|
@@ -219,6 +220,9 @@ export async function inspectSyncPlacement(store: MemoryStore, input: SyncPlacem
|
|
|
219
220
|
} catch (error) {
|
|
220
221
|
let reason: SyncPlacementInspectionIncompleteReason = phase;
|
|
221
222
|
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
223
|
+
if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;
|
|
224
|
+
if(code==="staging"||code==="pending")return{complete:false,reason:"journal-unavailable",nextStep:new SharedWriterRevisionError(code).message};
|
|
225
|
+
}
|
|
222
226
|
if (error instanceof Incomplete) reason = error.reason;
|
|
223
227
|
else if (error instanceof FormatFloorError) {
|
|
224
228
|
if (error.code === "format_floor") reason = "reader-upgrade-required";
|
|
@@ -14,6 +14,8 @@ import { PLACEMENT_ACTIVE_FENCE_SCHEMA, PLACEMENT_JOURNAL_MAX_RECORDS, PLACEMENT
|
|
|
14
14
|
import { validatePlacementPublicationRoot, validatePlacementPublicationBundle, validatePlacementPublicationFact, publicationFileNames,
|
|
15
15
|
PLACEMENT_PUBLICATION_RECORD_MAX_BYTES, type PlacementPublicationRoot, type PlacementPublicationBundle, type PlacementPublicationFact } from "./sync-placement-publication-plan.ts";
|
|
16
16
|
import { PlacementProjection } from "./sync-placement-projection.ts";
|
|
17
|
+
import { WRITER_RENEWAL_INTENT_FILE, WRITER_RENEWAL_INTENT_MAX_BYTES, WRITER_COMMIT_LOG, writerHistoryFileLimit, writerRevisionFile } from "./sync-placement-writer-revision-format.ts";
|
|
18
|
+
import { isWriterRenewalIntentTempName } from "./sync-placement-writer-history.ts";
|
|
17
19
|
|
|
18
20
|
export type PlacementStoragePhase = "epoch-written" | "epoch-fsync" | "activation-written" | "activation-fsync" | "fence-written" | "fence-fsync"
|
|
19
21
|
| "intent-written" | "intent-fsync" | "chunk-written" | "chunk-fsync" | "intent-cleared"
|
|
@@ -53,7 +55,18 @@ function names(dir: string, maximum: number): string[] {
|
|
|
53
55
|
finally { reader.closeSync(); }
|
|
54
56
|
return result.sort();
|
|
55
57
|
}
|
|
56
|
-
function
|
|
58
|
+
function renewalIntentObjects(home:string):{count:number;token:unknown[]} {
|
|
59
|
+
const token:unknown[]=[];
|
|
60
|
+
for(const name of names(home,20000)){
|
|
61
|
+
if(name!==WRITER_RENEWAL_INTENT_FILE&&!isWriterRenewalIntentTempName(name))continue;
|
|
62
|
+
const s=lstatSync(join(home,name),{bigint:true});
|
|
63
|
+
if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||(s.mode&0o777n)!==0o600n||![1n,2n].includes(s.nlink))fail();
|
|
64
|
+
if(s.size>BigInt(WRITER_RENEWAL_INTENT_MAX_BYTES))fail("bound");
|
|
65
|
+
token.push([name,String(s.dev),String(s.ino),String(s.size),String(s.nlink),String(s.mtimeNs),String(s.ctimeNs)]);
|
|
66
|
+
}
|
|
67
|
+
return{count:token.length,token};
|
|
68
|
+
}
|
|
69
|
+
function inventory(p: Paths): { files: Map<string, BigIntStats>; token: string; renewalObjects:number } {
|
|
57
70
|
const parents: unknown[] = [];
|
|
58
71
|
for (const dir of [p.home, p.root, p.epochs, p.dir]) { const stat = state(dir, true); parents.push([dir, stat ? [String(stat.dev), String(stat.ino)] : null]); }
|
|
59
72
|
if (state(p.root, true)) for (const name of names(p.root, 2)) {
|
|
@@ -63,15 +76,17 @@ function inventory(p: Paths): { files: Map<string, BigIntStats>; token: string }
|
|
|
63
76
|
const files = new Map<string, BigIntStats>();
|
|
64
77
|
if (state(p.dir, true)) for (const name of names(p.dir, PLACEMENT_JOURNAL_MAX_OBJECTS - 1)) {
|
|
65
78
|
const chunk = /^records-\d{5}\.jsonl$/.test(name);
|
|
66
|
-
|
|
79
|
+
const writerLimit=writerHistoryFileLimit(name);
|
|
80
|
+
if (!chunk && !publicationName(name) && writerLimit===undefined && !["epoch.json", "activation.json", "append-intent.json"].includes(name)) fail();
|
|
67
81
|
const stat = state(join(p.dir, name))!;
|
|
68
|
-
if (stat.size > BigInt(chunk || /-chunk-\d{5}\.json$/.test(name) ? PLACEMENT_JOURNAL_CHUNK_MAX_BYTES : PLACEMENT_JOURNAL_ROOT_MAX_BYTES)) fail("bound");
|
|
82
|
+
if (stat.size > BigInt(writerLimit??(chunk || /-chunk-\d{5}\.json$/.test(name) ? PLACEMENT_JOURNAL_CHUNK_MAX_BYTES : PLACEMENT_JOURNAL_ROOT_MAX_BYTES))) fail("bound");
|
|
69
83
|
files.set(name, stat);
|
|
70
84
|
}
|
|
71
85
|
// One global active.json slot is always reserved, whether that fence exists yet or not.
|
|
72
|
-
|
|
86
|
+
const renewal=renewalIntentObjects(p.home);
|
|
87
|
+
if (files.size + 1 + renewal.count > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
|
|
73
88
|
const fence = state(join(p.root, "active.json"));
|
|
74
|
-
return { files, token: canonicalJSON({ parents, files: [...files].map(([name, stat]) => [name, String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]),
|
|
89
|
+
return { files, renewalObjects:renewal.count, token: canonicalJSON({ parents, renewal:renewal.token, files: [...files].map(([name, stat]) => [name, String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]),
|
|
75
90
|
fence: fence ? [String(fence.dev), String(fence.ino), String(fence.size), String(fence.mtimeNs), String(fence.ctimeNs)] : null }) };
|
|
76
91
|
}
|
|
77
92
|
function readOwned(file: string, maximum: number): Buffer | undefined {
|
|
@@ -152,7 +167,7 @@ function snapshot(p: Paths): Snapshot {
|
|
|
152
167
|
if ((records.length > 0 || pendingBytes !== undefined) && (!activeFence || fencePending || activeFence.epochHash !== p.hash
|
|
153
168
|
|| activeFence.homeHash !== epoch.homeHash || activeFence.generation !== epoch.generation)) fail();
|
|
154
169
|
if (inventory(p).token !== before.token) fail("changed");
|
|
155
|
-
return { epoch, activation: plan, records, projection, headHash: projection.headHash, sequence: projection.sequence, objects: before.files.size + 1, pending: !!pending || intentPending || fencePending, chunks, intentPending, fencePending, ...(pending ? { pendingRecordSequence: pending.record.sequence, pendingRecord: validatePlacementRecord(pending.record) } : {}), ...(pending ? { intent: pending } : {}) };
|
|
170
|
+
return { epoch, activation: plan, records, projection, headHash: projection.headHash, sequence: projection.sequence, objects: before.files.size + 1 + before.renewalObjects, pending: !!pending || intentPending || fencePending, chunks, intentPending, fencePending, ...(pending ? { pendingRecordSequence: pending.record.sequence, pendingRecord: validatePlacementRecord(pending.record) } : {}), ...(pending ? { intent: pending } : {}) };
|
|
156
171
|
}
|
|
157
172
|
export function readPlacementJournal(home: string, epochHash: string): PlacementJournalRead {
|
|
158
173
|
return protectedIO(() => {
|
|
@@ -168,12 +183,41 @@ export function readPlacementJournal(home: string, epochHash: string): Placement
|
|
|
168
183
|
if (inventory(p).token !== before) fail("changed"); return result;
|
|
169
184
|
});
|
|
170
185
|
}
|
|
186
|
+
/** Writer history only needs the authoritative row prefix, including during owned publication-root writes.
|
|
187
|
+
* Full transport/recovery still uses readPlacementJournal and its reservation proof. */
|
|
188
|
+
export function placementWriterJournalToken(home:string,epochHash:string):string {
|
|
189
|
+
return protectedIO(()=>{
|
|
190
|
+
const p=paths(home,epochHash),dir=state(p.dir,true);if(!dir)fail("changed");
|
|
191
|
+
const rows:unknown[]=[["directory",String(dir.dev),String(dir.ino),String(dir.mode),String(dir.uid)]];
|
|
192
|
+
for(const name of names(p.dir,PLACEMENT_JOURNAL_MAX_OBJECTS-1))if(name.startsWith("records-")||name==="append-intent.json"){
|
|
193
|
+
const s=state(join(p.dir,name));if(!s)fail("changed");
|
|
194
|
+
rows.push([name,String(s.dev),String(s.ino),String(s.mode),String(s.uid),String(s.nlink),String(s.size),String(s.mtimeNs),String(s.ctimeNs)]);
|
|
195
|
+
}
|
|
196
|
+
return canonicalJSON(rows);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
export function readPlacementWriterJournalAnchors(home:string,epochHash:string):Pick<PlacementJournalRead,"epoch"|"activation"|"records"|"sequence"|"headHash"> {
|
|
200
|
+
return protectedIO(()=>{
|
|
201
|
+
const read=snapshot(paths(home,epochHash));
|
|
202
|
+
return{epoch:read.epoch,activation:read.activation,records:read.records,sequence:read.sequence,headHash:read.headHash};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
171
205
|
function current(p: Paths, opts: PlacementStorageOptions, required: readonly string[] = []): void {
|
|
172
206
|
try { assertMutationHandle(opts.mutation, p.home); } catch { return fail("busy"); }
|
|
173
207
|
try { opts.assertCurrent(); } catch (error) { if (knownError(error)) throw error; return fail("changed"); }
|
|
174
208
|
try { assertMutationHandle(opts.mutation, p.home); } catch { return fail("busy"); }
|
|
175
209
|
const found = inventory(p), names = new Set([...found.files.keys(), ...required, ...reservedPublicationJournalNames(p)]);
|
|
176
|
-
if
|
|
210
|
+
if(found.renewalObjects)fail("pending");
|
|
211
|
+
if (names.size + 1 + found.renewalObjects > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
|
|
212
|
+
}
|
|
213
|
+
/** One shared budget includes the outside intent and its transient immutable-publication companion. */
|
|
214
|
+
export function writerRenewalCapacity(home:string,epochHash:string,sequence:number):{objects:number;remainingObjects:number} {
|
|
215
|
+
return protectedIO(()=>{
|
|
216
|
+
const p=paths(home,epochHash),found=inventory(p),next=new Set([...found.files.keys(),...reservedPublicationJournalNames(p),WRITER_COMMIT_LOG,writerRevisionFile(sequence)]);
|
|
217
|
+
const objects=next.size+1+Math.max(2,found.renewalObjects);
|
|
218
|
+
if(objects>PLACEMENT_JOURNAL_MAX_OBJECTS)fail("bound");
|
|
219
|
+
return{objects,remainingObjects:PLACEMENT_JOURNAL_MAX_OBJECTS-objects};
|
|
220
|
+
});
|
|
177
221
|
}
|
|
178
222
|
function writer<T>(home: string, epochHash: string, opts: PlacementStorageOptions, work: (p: Paths) => T, incompleteAttempt?: string, preflight?: readonly string[] | ((p: Paths) => readonly string[])): T {
|
|
179
223
|
return protectedIO(() => {
|
|
@@ -10,11 +10,12 @@ import { outboundPolicyEvaluator, type RowScreen } from "./sync-screen.ts";
|
|
|
10
10
|
import { assertPlacementRuntime } from "./sync-placement-guard.ts";
|
|
11
11
|
import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
12
12
|
import { planSyncPlacementActivation, validatePlacementPlan, validateSyncPlacementActivation, type SyncPlacementActivationPlan } from "./sync-placement-plan.ts";
|
|
13
|
-
import { captureSyncPlacementContext, type PlacementCapturePort, type PlacementCapture } from "./sync-placement-capture.ts";
|
|
13
|
+
import { captureSyncPlacementContext, placementCaptureContextHash, type PlacementCapturePort, type PlacementCapture } from "./sync-placement-capture.ts";
|
|
14
14
|
import { PLACEMENT_EPOCH_SCHEMA, placementEpochHash, placementKeyId, validatePlacementEpoch, validatePlacementKey, validatePlacementEvent,
|
|
15
15
|
SyncPlacementJournalError, type PlacementEpoch, type PlacementKey, type PlacementCommittedOutcome, type PlacementAmbiguousOutcome } from "./sync-placement-journal-format.ts";
|
|
16
16
|
import { rebuildPlacementProjection } from "./sync-placement-projection.ts";
|
|
17
17
|
import { preparePlacementEpoch, readPlacementJournal, appendPlacementEvent } from "./sync-placement-journal-storage.ts";
|
|
18
|
+
import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
|
|
18
19
|
|
|
19
20
|
export interface PlacementJournalPreview { epoch: PlacementEpoch; plan: SyncPlacementActivationPlan }
|
|
20
21
|
export interface PlacementAuthorizationBinding {
|
|
@@ -28,7 +29,13 @@ export interface PlacementJournalPort extends PlacementCapturePort {
|
|
|
28
29
|
}
|
|
29
30
|
function fail(code: "invalid" | "changed" | "authorization" | "policy" | "local" | "conflict" | "pending" | "busy" = "invalid"): never { throw new SyncPlacementJournalError(code); }
|
|
30
31
|
function protectedCall<T>(work: () => T): T {
|
|
31
|
-
try { return work(); } catch (error) {
|
|
32
|
+
try { return work(); } catch (error) {
|
|
33
|
+
if(error&&typeof error==="object"&&!types.isProxy(error)){
|
|
34
|
+
if(error instanceof SyncPlacementJournalError)throw error;
|
|
35
|
+
if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="staging"||code==="pending")throw new SharedWriterRevisionError(code);}
|
|
36
|
+
}
|
|
37
|
+
return fail("changed");
|
|
38
|
+
}
|
|
32
39
|
}
|
|
33
40
|
function person(port: PlacementJournalPort, action: "prepare" | "hold" | "release", seal: string, details: Omit<PlacementAuthorizationBinding, "action" | "seal"> = {}): void {
|
|
34
41
|
let authorized = false;
|
|
@@ -86,7 +93,7 @@ function active(home: string, input: PlacementEffectRequest, port: PlacementCapt
|
|
|
86
93
|
if (capture.activeHead !== input.epochHash || journal.epoch.homeHash !== capture.context.homeHash
|
|
87
94
|
|| journal.epoch.generation !== (capture.context.profile as { generation: string }).generation
|
|
88
95
|
|| journal.activation.targetProfile.placementHead !== input.epochHash
|
|
89
|
-
|| canonicalJSON(capture.context.writers) !== canonicalJSON(journal.activation.writers)
|
|
96
|
+
|| canonicalJSON(capture.context.writers) !== canonicalJSON(capture.writerInventory?.writers??journal.activation.writers)
|
|
90
97
|
|| canonicalJSON(capture.context.realms.map(({ bindingHash: _hash, ...identity }) => identity))
|
|
91
98
|
!== canonicalJSON(journal.activation.realms.map(({ bindingHash: _hash, ...identity }) => identity))) fail("changed");
|
|
92
99
|
const markers = readPlacementRuntimeMarkers(capture.home), profile = validatePlacementProfile(markers.profile);
|
|
@@ -116,7 +123,7 @@ function effect(home: string, raw: PlacementEffectRequest, port: PlacementJourna
|
|
|
116
123
|
if (!outboundPolicyEvaluator(capture.home, port.screen)(source.row as unknown as Record<string, unknown>).ok) fail("policy");
|
|
117
124
|
partial = { ...fields, kind: "release" };
|
|
118
125
|
} else partial = { ...fields, kind: decision.kind, outcome: decision.outcome };
|
|
119
|
-
const effectHash = hashJSON({ event: partial, expectedHead: input.expectedHead, markerHash: capture.markerHash, contextHash:
|
|
126
|
+
const effectHash = hashJSON({ event: partial, expectedHead: input.expectedHead, markerHash: capture.markerHash, contextHash: placementCaptureContextHash(capture) });
|
|
120
127
|
const event = validatePlacementEvent({ ...partial, effectHash });
|
|
121
128
|
if (decision.kind === "release" || decision.kind === "hold" && decision.mode === "person") person(port, decision.kind, effectHash, { epochHash: input.epochHash, key: Object.freeze({ ...input.key, realm: Object.freeze({ ...input.key.realm }) }),
|
|
122
129
|
expectedHead: input.expectedHead, policyHash: capture.context.policyHash });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Activation preflight only. Never migrates, opens an index, raises a floor or creates a home. */
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { realPath } from "./platform.ts";
|
|
4
|
+
import { hashJSON } from "./generation.ts";
|
|
5
|
+
import { assertFormatFloor, readFormatFloors } from "./formats.ts";
|
|
6
|
+
import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
|
|
7
|
+
import { readActivePlacementBinding, type ActivePlacementBinding } from "./sync-placement-active.ts";
|
|
8
|
+
import { PLACEMENT_PROFILE_SCHEMA, validatePlacementBaseV2, type PlacementBaseV2 } from "./sync-placement-format.ts";
|
|
9
|
+
|
|
10
|
+
export const PLACEMENT_ACTIVATION_MIN_READER = "0.21.0";
|
|
11
|
+
const preview = "amem migrate --sync-placement --dry-run";
|
|
12
|
+
function requireHomeFloor(home: string,minReader:string): string {
|
|
13
|
+
assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
|
|
14
|
+
const floors = readFormatFloors(home), minimum = minReader.split(".").map(Number);
|
|
15
|
+
const adequate = floors?.some(row => {
|
|
16
|
+
if (row.surface !== "home" || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(row.minReader)) return false;
|
|
17
|
+
const parts = row.minReader.split(".").map(Number);
|
|
18
|
+
for (let i = 0; i < 3; i++) if (parts[i] !== minimum[i]) return parts[i]! > minimum[i]!;
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
if (!adequate) throw Error(`Sync placement requires a person-authored home floor of ${minReader} or later in formats.json; have the person append that record, then rerun ${preview}.`);
|
|
22
|
+
return hashJSON(floors);
|
|
23
|
+
}
|
|
24
|
+
export function requirePlacementHomeFloor(home:string):string{return requireHomeFloor(home,PLACEMENT_ACTIVATION_MIN_READER);}
|
|
25
|
+
export function requireWriterRenewalHomeFloor(home:string):string{return requireHomeFloor(home,"0.22.0");}
|
|
26
|
+
export interface PlacementActivationPrerequisites { home: string; floorHash: string; base?: PlacementBaseV2; active?: ActivePlacementBinding }
|
|
27
|
+
export function requirePlacementActivationPrerequisites(input: string): PlacementActivationPrerequisites {
|
|
28
|
+
let home: string;
|
|
29
|
+
try { home = realPath(input); const stat = lstatSync(home); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw Error(); }
|
|
30
|
+
catch { throw Error("Sync placement memory home is unavailable or unsafe; select an existing private memory home and rerun amem migrate --sync-placement --dry-run."); }
|
|
31
|
+
assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
|
|
32
|
+
const markers = readPlacementRuntimeMarkers(home);
|
|
33
|
+
const shards = markers.shards as { schema?: string; phase?: string } | undefined;
|
|
34
|
+
const profile = markers.profile as { schema?: string } | undefined;
|
|
35
|
+
if (!shards || shards.schema === "agent-mem.shards/v1" && shards.phase !== "active") {
|
|
36
|
+
throw Error("Sync placement requires an active shard home; run amem migrate --shards --dry-run and complete that person-reviewed migration first.");
|
|
37
|
+
}
|
|
38
|
+
if (shards.schema === "agent-mem.shards/v1") {
|
|
39
|
+
throw Error("Sync placement requires the v2 public-prune view; run amem migrate --public-prune-view --dry-run and complete that person-reviewed migration first.");
|
|
40
|
+
}
|
|
41
|
+
if (profile?.schema === PLACEMENT_PROFILE_SCHEMA) {
|
|
42
|
+
const active = readActivePlacementBinding(home, markers.profile, markers.shards);
|
|
43
|
+
if (!active) throw Error("Sync placement activation is incomplete; inspect retained evidence and run amem migrate --sync-placement --resume.");
|
|
44
|
+
return { home, floorHash: requirePlacementHomeFloor(home), active };
|
|
45
|
+
}
|
|
46
|
+
let base: PlacementBaseV2;
|
|
47
|
+
try { base = validatePlacementBaseV2(markers.profile, markers.shards); }
|
|
48
|
+
catch { throw Error("Sync placement base metadata is invalid or unsupported; inspect the active shard/public-prune metadata before rerunning amem migrate --sync-placement --dry-run."); }
|
|
49
|
+
return { home, floorHash: requirePlacementHomeFloor(home), base };
|
|
50
|
+
}
|