@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,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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
3
|
+
import { SHARDED_V2_FIELDS } from "./shard.ts";
|
|
4
|
+
import type { SourcePrefix } from "./migrate.ts";
|
|
5
|
+
import type { ShardSyncRealm } from "./shard-sync-format.ts";
|
|
6
|
+
|
|
7
|
+
export const PLACEMENT_FORMAT = "agent-mem.sync-placement/v1";
|
|
8
|
+
export const PLACEMENT_PROFILE_SCHEMA = "agent-mem.profile/v3";
|
|
9
|
+
export const PLACEMENT_SHARDS_SCHEMA = "agent-mem.shards/v3";
|
|
10
|
+
export const PLACEMENT_DESCRIPTOR_SCHEMA = "agent-mem.shard-sync/v3";
|
|
11
|
+
const FOREIGN_FORMAT = "agent-mem.public-prune-witness/v1", METADATA_MAX = 256 * 1024, WIRE_MAX = 64 * 1024, SOURCES_MAX = 20_000;
|
|
12
|
+
|
|
13
|
+
export class SyncPlacementFormatError extends Error {
|
|
14
|
+
readonly code = "sync_placement_format";
|
|
15
|
+
constructor() {
|
|
16
|
+
super("Sync placement metadata is invalid, unsupported or exceeds its bound. Inspect the saved activation plan and use compatible writers before retrying.");
|
|
17
|
+
this.name = "SyncPlacementFormatError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export interface PlacementProfile {
|
|
21
|
+
schema: typeof PLACEMENT_PROFILE_SCHEMA; kind: "generation"; generation: string;
|
|
22
|
+
foreignPruneFormat: typeof FOREIGN_FORMAT; pruneInputSetHash: string;
|
|
23
|
+
publicationFormat: typeof PLACEMENT_FORMAT; placementHead: string;
|
|
24
|
+
}
|
|
25
|
+
export interface PlacementShardsFields {
|
|
26
|
+
generation: string; phase: "active"; selectorRevision: string;
|
|
27
|
+
sourceGeneration?: string; sources: SourcePrefix[]; machine: { id: string; label: string };
|
|
28
|
+
legacyCommit?: string; deadLeasesRemoved?: { lease: string; at: string }[];
|
|
29
|
+
}
|
|
30
|
+
export interface PlacementShards extends PlacementShardsFields {
|
|
31
|
+
schema: typeof PLACEMENT_SHARDS_SCHEMA; publicationFormat: typeof PLACEMENT_FORMAT; placementHead: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PlacementBaseV2 {
|
|
34
|
+
profile: { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: typeof FOREIGN_FORMAT; pruneInputSetHash: string };
|
|
35
|
+
shards: PlacementShardsFields & { schema: "agent-mem.shards/v2" };
|
|
36
|
+
}
|
|
37
|
+
export type PlacementDescriptor = { schema: typeof PLACEMENT_DESCRIPTOR_SCHEMA; publicationFormat: typeof PLACEMENT_FORMAT }
|
|
38
|
+
& ({ scope: "user" } | { scope: "project"; key: string });
|
|
39
|
+
export interface PlacementMarkers { profile: PlacementProfile; shards: PlacementShards }
|
|
40
|
+
const fail = (): never => { throw new SyncPlacementFormatError(); };
|
|
41
|
+
const hex = (value: unknown, width: number): value is string => typeof value === "string" && value.length === width && new RegExp(`^[a-f0-9]{${width}}$`).test(value);
|
|
42
|
+
const has = (value: Record<string, unknown>, key: string): boolean => Object.hasOwn(value, key);
|
|
43
|
+
function record(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
|
|
44
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)) return fail();
|
|
45
|
+
const prototype = Object.getPrototypeOf(value);
|
|
46
|
+
if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(value).length) return fail();
|
|
47
|
+
const keys = Object.getOwnPropertyNames(value), allowed = new Set([...required, ...optional]);
|
|
48
|
+
if (keys.length < required.length || keys.length > allowed.size || required.some(key => !Object.hasOwn(value, key))) return fail();
|
|
49
|
+
const copy: Record<string, unknown> = Object.create(null);
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
52
|
+
if (!allowed.has(key) || !descriptor || !("value" in descriptor) || !descriptor.enumerable) return fail();
|
|
53
|
+
copy[key] = descriptor.value;
|
|
54
|
+
}
|
|
55
|
+
return copy;
|
|
56
|
+
}
|
|
57
|
+
function text(value: unknown, maximum = METADATA_MAX): string {
|
|
58
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > maximum) return fail();
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function integer(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return fail(); return value; }
|
|
62
|
+
function bounded<T>(value: T, maximum = METADATA_MAX): T { if (Buffer.byteLength(canonicalJSON(value) + "\n") > maximum) return fail(); return value; }
|
|
63
|
+
function list(value: unknown, maximum: number): unknown[] {
|
|
64
|
+
if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length) return fail();
|
|
65
|
+
const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
|
|
66
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > maximum || Object.getOwnPropertyNames(value).length !== length + 1) return fail();
|
|
67
|
+
const result: unknown[] = [];
|
|
68
|
+
for (let index = 0; index < length; index++) {
|
|
69
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
70
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) return fail();
|
|
71
|
+
result.push(descriptor.value);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
function sourcePrefixes(value: unknown): SourcePrefix[] {
|
|
76
|
+
const result: SourcePrefix[] = [], seen = new Set<string>(); let bytes = 2;
|
|
77
|
+
for (const input of list(value, SOURCES_MAX)) {
|
|
78
|
+
const row = record(input, ["source", "path", "offset", "hash", "tailBytes"], ["commit", "stamp"]);
|
|
79
|
+
if (typeof row.source !== "string" || !["legacy-local", "legacy-main", "generation"].includes(row.source)) return fail();
|
|
80
|
+
const path = text(row.path);
|
|
81
|
+
if (/^(log|local)\/[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.exec(path)?.[0] !== path || row.source === "legacy-main" && !path.startsWith("log/")) return fail();
|
|
82
|
+
if (!hex(row.hash, 64) || (row.source === "legacy-main" ? !hex(row.commit, 40) : has(row, "commit"))) return fail();
|
|
83
|
+
const source: SourcePrefix = { source: row.source, path, offset: integer(row.offset), hash: row.hash as string, tailBytes: integer(row.tailBytes) };
|
|
84
|
+
if (has(row, "commit")) source.commit = row.commit as string;
|
|
85
|
+
if (has(row, "stamp")) { source.stamp = text(row.stamp); if (source.stamp.length > 1024) return fail(); }
|
|
86
|
+
const key = `${source.source}:${source.path}`; if (seen.has(key)) return fail(); seen.add(key);
|
|
87
|
+
bytes += Buffer.byteLength(canonicalJSON(source)) + 1; if (bytes > METADATA_MAX) return fail(); result.push(source);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function shardFields(value: Record<string, unknown>, profile: { generation: string }): PlacementShardsFields {
|
|
92
|
+
if (value.phase !== "active" || value.generation !== profile.generation || !hex(value.selectorRevision, 64)) return fail();
|
|
93
|
+
const machine = record(value.machine, ["id", "label"]); if (!hex(machine.id, 16)) return fail();
|
|
94
|
+
const result: PlacementShardsFields = { generation: profile.generation, phase: "active", selectorRevision: value.selectorRevision,
|
|
95
|
+
sources: sourcePrefixes(value.sources), machine: { id: machine.id, label: text(machine.label) } };
|
|
96
|
+
if (has(value, "sourceGeneration")) { if (value.sourceGeneration !== profile.generation) return fail(); result.sourceGeneration = profile.generation; }
|
|
97
|
+
if (has(value, "legacyCommit")) { if (!hex(value.legacyCommit, 40)) return fail(); result.legacyCommit = value.legacyCommit; }
|
|
98
|
+
if (has(value, "deadLeasesRemoved")) {
|
|
99
|
+
const audits: { lease: string; at: string }[] = []; let bytes = 2;
|
|
100
|
+
for (const input of list(value.deadLeasesRemoved, 128)) {
|
|
101
|
+
const audit = record(input, ["lease", "at"]), lease = text(audit.lease), at = text(audit.at);
|
|
102
|
+
if (/^[1-9][0-9]*-[a-f0-9]{64}\.json$/.exec(lease)?.[0] !== lease || !Number.isFinite(Date.parse(at)) || new Date(at).toISOString() !== at) return fail();
|
|
103
|
+
const row = { lease, at }; bytes += Buffer.byteLength(canonicalJSON(row)) + 1; if (bytes > METADATA_MAX) return fail(); audits.push(row);
|
|
104
|
+
}
|
|
105
|
+
result.deadLeasesRemoved = audits;
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function checked<T>(work: () => T): T { try { return work(); } catch { return fail(); } }
|
|
110
|
+
export function validatePlacementProfile(value: unknown): PlacementProfile {
|
|
111
|
+
return checked<PlacementProfile>(() => {
|
|
112
|
+
const row = record(value, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash", "publicationFormat", "placementHead"]);
|
|
113
|
+
if (row.schema !== PLACEMENT_PROFILE_SCHEMA || row.kind !== "generation" || !hex(row.generation, 32) || row.foreignPruneFormat !== FOREIGN_FORMAT || !hex(row.pruneInputSetHash, 64) || row.publicationFormat !== PLACEMENT_FORMAT || !hex(row.placementHead, 64)) return fail();
|
|
114
|
+
return bounded({ schema: PLACEMENT_PROFILE_SCHEMA, kind: "generation", generation: row.generation, foreignPruneFormat: FOREIGN_FORMAT,
|
|
115
|
+
pruneInputSetHash: row.pruneInputSetHash, publicationFormat: PLACEMENT_FORMAT, placementHead: row.placementHead });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export function validatePlacementShards(value: unknown, profile: PlacementProfile): PlacementShards {
|
|
119
|
+
return checked<PlacementShards>(() => {
|
|
120
|
+
const bound = validatePlacementProfile(profile), row = record(value, ["schema", "generation", "phase", "selectorRevision", "sources", "machine", "publicationFormat", "placementHead"], ["sourceGeneration", "legacyCommit", "deadLeasesRemoved"]);
|
|
121
|
+
if (row.schema !== PLACEMENT_SHARDS_SCHEMA || row.publicationFormat !== PLACEMENT_FORMAT || row.placementHead !== bound.placementHead || row.selectorRevision !== hashJSON(bound)) return fail();
|
|
122
|
+
return bounded({ ...shardFields(row, bound), schema: PLACEMENT_SHARDS_SCHEMA, publicationFormat: PLACEMENT_FORMAT, placementHead: bound.placementHead });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function validatePlacementDescriptor(value: unknown, expectedRealm?: ShardSyncRealm): PlacementDescriptor {
|
|
126
|
+
return checked<PlacementDescriptor>(() => {
|
|
127
|
+
const row = record(value, ["schema", "scope", "publicationFormat"], ["key"]);
|
|
128
|
+
if (row.schema !== PLACEMENT_DESCRIPTOR_SCHEMA || row.publicationFormat !== PLACEMENT_FORMAT) return fail();
|
|
129
|
+
let result: PlacementDescriptor;
|
|
130
|
+
if (row.scope === "user" && !has(row, "key")) result = { schema: PLACEMENT_DESCRIPTOR_SCHEMA, scope: "user", publicationFormat: PLACEMENT_FORMAT };
|
|
131
|
+
else if (row.scope === "project" && typeof row.key === "string" && row.key.length) result = { schema: PLACEMENT_DESCRIPTOR_SCHEMA, scope: "project", key: text(row.key, WIRE_MAX), publicationFormat: PLACEMENT_FORMAT };
|
|
132
|
+
else return fail();
|
|
133
|
+
if (expectedRealm !== undefined) {
|
|
134
|
+
const expected = record(expectedRealm, ["scope"], ["key"]);
|
|
135
|
+
if (expected.scope !== result.scope || result.scope === "user" && has(expected, "key") || result.scope === "project" && expected.key !== result.key) return fail();
|
|
136
|
+
}
|
|
137
|
+
return bounded(result, WIRE_MAX);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
export function validatePlacementBaseV2(profile: unknown, shards: unknown): PlacementBaseV2 {
|
|
141
|
+
return checked<PlacementBaseV2>(() => {
|
|
142
|
+
const p = record(profile, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"]);
|
|
143
|
+
if (p.schema !== "agent-mem.profile/v2" || p.kind !== "generation" || !hex(p.generation, 32) || p.foreignPruneFormat !== FOREIGN_FORMAT || !hex(p.pruneInputSetHash, 64)) return fail();
|
|
144
|
+
const normalized: PlacementBaseV2["profile"] = { schema: "agent-mem.profile/v2", kind: "generation", generation: p.generation, foreignPruneFormat: FOREIGN_FORMAT, pruneInputSetHash: p.pruneInputSetHash };
|
|
145
|
+
// Read imported constants inside the function: generation/shard guard imports may form a module cycle.
|
|
146
|
+
const s = record(shards, ["schema", "generation", "phase", "selectorRevision", "sources", "machine"], SHARDED_V2_FIELDS.filter(key => !["schema", "generation", "phase", "selectorRevision", "sources", "machine"].includes(key)));
|
|
147
|
+
if (s.schema !== "agent-mem.shards/v2" || s.selectorRevision !== hashJSON(normalized)) return fail();
|
|
148
|
+
return { profile: bounded(normalized), shards: bounded({ ...shardFields(s, normalized), schema: "agent-mem.shards/v2" }) };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Placement upgrade only. Shape/authority checks remain the responsibility of the exact marker-pair validator. */
|
|
152
|
+
export function validatePlacementVersionTransition(fromSchema: unknown, toSchema: unknown): void {
|
|
153
|
+
const families = ["agent-mem.profile", "agent-mem.shards", "agent-mem.shard-sync"];
|
|
154
|
+
for (const family of families) if (toSchema === `${family}/v3` && [1, 2, 3].some(version => fromSchema === `${family}/v${version}`)) return;
|
|
155
|
+
fail();
|
|
156
|
+
}
|
|
157
|
+
export function validatePlacementMarkerTransition(oldProfile: unknown, oldShards: unknown, newProfile: unknown, newShards: unknown): PlacementMarkers {
|
|
158
|
+
return checked<PlacementMarkers>(() => {
|
|
159
|
+
const base = validatePlacementBaseV2(oldProfile, oldShards), profile = validatePlacementProfile(newProfile), shards = validatePlacementShards(newShards, profile);
|
|
160
|
+
const { schema: _p, publicationFormat: _pf, placementHead: _ph, ...profileRest } = profile;
|
|
161
|
+
const { schema: _bp, ...baseProfileRest } = base.profile;
|
|
162
|
+
const { schema: _s, selectorRevision: _sr, publicationFormat: _sf, placementHead: _sh, ...shardsRest } = shards;
|
|
163
|
+
const { schema: _bs, selectorRevision: _bsr, ...baseShardsRest } = base.shards;
|
|
164
|
+
if (canonicalJSON(profileRest) !== canonicalJSON(baseProfileRest) || canonicalJSON(shardsRest) !== canonicalJSON(baseShardsRest)) return fail();
|
|
165
|
+
return { profile, shards };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function decode<T>(bytes: Uint8Array, maximum: number, validate: (value: unknown) => T): T {
|
|
169
|
+
return checked(() => {
|
|
170
|
+
if (types.isProxy(bytes) || !types.isUint8Array(bytes)) return fail();
|
|
171
|
+
const prototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
172
|
+
const intrinsic = (key: string) => Object.getOwnPropertyDescriptor(prototype, key)!.get!.call(bytes);
|
|
173
|
+
const size = intrinsic("byteLength") as number;
|
|
174
|
+
if (!size || size > maximum) return fail();
|
|
175
|
+
const view = new Uint8Array(intrinsic("buffer") as ArrayBuffer, intrinsic("byteOffset") as number, size);
|
|
176
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(view), result = validate(JSON.parse(text));
|
|
177
|
+
if (canonicalJSON(result) + "\n" !== text) return fail(); return result;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export const decodePlacementProfile = (bytes: Uint8Array): PlacementProfile => decode(bytes, METADATA_MAX, validatePlacementProfile);
|
|
181
|
+
export const decodePlacementShards = (bytes: Uint8Array, profile: PlacementProfile): PlacementShards => decode(bytes, METADATA_MAX, value => validatePlacementShards(value, profile));
|
|
182
|
+
export const decodePlacementDescriptor = (bytes: Uint8Array, expectedRealm?: ShardSyncRealm): PlacementDescriptor => decode(bytes, WIRE_MAX, value => validatePlacementDescriptor(value, expectedRealm));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Pure Git object planning. Bytes are unchanged object bodies; no object is installed or published here. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { types } from "node:util";
|
|
4
|
+
import { PUBLIC_PRUNE_REPLAY_MAX_BYTES } from "./public-prune-input-chain.ts";
|
|
5
|
+
import { decodePlacementTransportDescriptor, decodeShardManifest, isShardManifestPath, SHARD_PACK_MAX_BYTES, type ShardSyncRealm } from "./shard-sync-format.ts";
|
|
6
|
+
|
|
7
|
+
export interface PlannedGitObject { type: "blob" | "tree" | "commit"; oid: string; bytes: Buffer; path: string | null }
|
|
8
|
+
export interface PlacementGitObjects { objects: PlannedGitObject[]; tree: string; commit: string }
|
|
9
|
+
export class PlacementGitObjectError extends Error {
|
|
10
|
+
constructor() { super("Sync placement Git objects are invalid or exceed supported bounds; rebuild the exact verified publication plan."); this.name = "PlacementGitObjectError"; }
|
|
11
|
+
}
|
|
12
|
+
function fail(): never { throw new PlacementGitObjectError(); }
|
|
13
|
+
const oid = (v: unknown): v is string => typeof v === "string" && v.length === 40 && /^[a-f0-9]+$/.test(v);
|
|
14
|
+
const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
|
|
15
|
+
const hash = (v: Uint8Array) => createHash("sha256").update(v).digest("hex");
|
|
16
|
+
const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
17
|
+
const getBuffer = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer")!.get!;
|
|
18
|
+
const getOffset = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset")!.get!;
|
|
19
|
+
const getLength = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")!.get!;
|
|
20
|
+
const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!;
|
|
21
|
+
interface TreeNode { directories: Map<string, TreeNode>; blobs: Map<string, string> }
|
|
22
|
+
function node(): TreeNode { return { directories: new Map(), blobs: new Map() }; }
|
|
23
|
+
function object(type: PlannedGitObject["type"], bytes: Buffer, path: string | null): PlannedGitObject {
|
|
24
|
+
return { type, oid: createHash("sha1").update(`${type} ${bytes.length}\0`).update(bytes).digest("hex"), bytes, path };
|
|
25
|
+
}
|
|
26
|
+
/** Exact Git object bodies and identities, with frozen commit metadata and no-op tree reuse.
|
|
27
|
+
* Retained public bytes share the existing replay bound; new source's separate 8MiB allowance is the caller's proof. */
|
|
28
|
+
export function compilePlacementGitObjects(files: ReadonlyMap<string, Uint8Array>, parent: string | null, createdUnixSeconds: number,
|
|
29
|
+
previous?: { tree: string; commit: string }): PlacementGitObjects {
|
|
30
|
+
try { return compile(files, parent, createdUnixSeconds, previous); } catch { return fail(); }
|
|
31
|
+
}
|
|
32
|
+
function compile(files: ReadonlyMap<string, Uint8Array>, parent: string | null, createdUnixSeconds: number,
|
|
33
|
+
previous?: { tree: string; commit: string }): PlacementGitObjects {
|
|
34
|
+
if (!files || typeof files !== "object" || types.isProxy(files) || Object.getPrototypeOf(files) !== Map.prototype
|
|
35
|
+
|| !Number.isSafeInteger(createdUnixSeconds) || createdUnixSeconds < 0 || parent !== null && !oid(parent)) return fail();
|
|
36
|
+
const count = mapSize.call(files);
|
|
37
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > 20000) return fail();
|
|
38
|
+
if (previous !== undefined) {
|
|
39
|
+
if (!previous || typeof previous !== "object" || types.isProxy(previous) || Object.getOwnPropertySymbols(previous).length) return fail();
|
|
40
|
+
const fields = Object.getOwnPropertyDescriptors(previous);
|
|
41
|
+
if (Object.keys(fields).sort().join() !== "commit,tree" || !fields.tree || !fields.commit || !("value" in fields.tree) || !("value" in fields.commit)
|
|
42
|
+
|| !oid(fields.tree.value) || !oid(fields.commit.value)) return fail();
|
|
43
|
+
previous = { tree: fields.tree.value, commit: fields.commit.value };
|
|
44
|
+
}
|
|
45
|
+
const entries: { path: string; bytes: Buffer }[] = []; let total = 0;
|
|
46
|
+
for (const [path, value] of Map.prototype.entries.call(files) as Iterable<[unknown, unknown]>) {
|
|
47
|
+
if (typeof path !== "string" || types.isProxy(value) || !(value instanceof Uint8Array)) return fail();
|
|
48
|
+
const pack = PACK.exec(path), validPack = pack?.[0] === path;
|
|
49
|
+
if (path !== "shard.json" && !validPack && !isShardManifestPath(path)) return fail();
|
|
50
|
+
const length = getLength.call(value) as number, maximum = validPack ? SHARD_PACK_MAX_BYTES : 64 * 1024;
|
|
51
|
+
if (length < 1 || length > maximum || (total += length) > PUBLIC_PRUNE_REPLAY_MAX_BYTES) return fail();
|
|
52
|
+
const bytes = Buffer.from(Buffer.from(getBuffer.call(value) as ArrayBuffer, getOffset.call(value) as number, length));
|
|
53
|
+
if (bytes.at(-1) !== 10 || validPack && hash(bytes) !== pack![2]) return fail();
|
|
54
|
+
if (isShardManifestPath(path)) decodeShardManifest(path, bytes);
|
|
55
|
+
if (path === "shard.json") {
|
|
56
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record<string, unknown>;
|
|
57
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fail();
|
|
58
|
+
const realm: ShardSyncRealm = parsed.scope === "user" ? { scope: "user" }
|
|
59
|
+
: parsed.scope === "project" && typeof parsed.key === "string" ? { scope: "project", key: parsed.key } : fail();
|
|
60
|
+
decodePlacementTransportDescriptor(bytes, realm);
|
|
61
|
+
}
|
|
62
|
+
entries.push({ path, bytes });
|
|
63
|
+
}
|
|
64
|
+
if (entries.length !== count || !entries.some(entry => entry.path === "shard.json")) return fail();
|
|
65
|
+
entries.sort((a,b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
66
|
+
const objects: PlannedGitObject[] = [], trees: PlannedGitObject[] = [], root = node();
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const blob = object("blob", entry.bytes, entry.path); objects.push(blob);
|
|
69
|
+
const parts = entry.path.split("/"); let target = root;
|
|
70
|
+
for (const name of parts.slice(0, -1)) { if (target.blobs.has(name)) return fail(); let next = target.directories.get(name); if (!next) target.directories.set(name, next = node()); target = next; }
|
|
71
|
+
const name = parts.at(-1)!; if (target.directories.has(name) || target.blobs.has(name)) return fail(); target.blobs.set(name, blob.oid);
|
|
72
|
+
}
|
|
73
|
+
const buildTree = (tree: TreeNode, path: string): string => {
|
|
74
|
+
const children: { name: string; oid: string; directory: boolean }[] = [...tree.blobs].map(([name, id]) => ({ name, oid: id, directory: false }));
|
|
75
|
+
for (const [name, directory] of [...tree.directories].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
76
|
+
children.push({ name, oid: buildTree(directory, path ? `${path}/${name}` : name), directory: true });
|
|
77
|
+
children.sort((a,b) => Buffer.compare(Buffer.from(a.name + (a.directory ? "/" : "")), Buffer.from(b.name + (b.directory ? "/" : ""))));
|
|
78
|
+
let length = 0; const chunks: Buffer[] = [];
|
|
79
|
+
for (const child of children) {
|
|
80
|
+
const prefix = Buffer.from(`${child.directory ? "40000" : "100644"} ${child.name}\0`);
|
|
81
|
+
length += prefix.length + 20; if (length > SHARD_PACK_MAX_BYTES) return fail(); chunks.push(prefix, Buffer.from(child.oid, "hex"));
|
|
82
|
+
}
|
|
83
|
+
const result = object("tree", Buffer.concat(chunks, length), path); trees.push(result); return result.oid;
|
|
84
|
+
};
|
|
85
|
+
const tree = buildTree(root, "");
|
|
86
|
+
trees.sort((a,b) => (b.path ? b.path.split("/").length : 0) - (a.path ? a.path.split("/").length : 0)
|
|
87
|
+
|| (a.path! < b.path! ? -1 : a.path! > b.path! ? 1 : 0));
|
|
88
|
+
objects.push(...trees);
|
|
89
|
+
if (previous?.tree === tree) return { objects, tree, commit: previous.commit };
|
|
90
|
+
const identity = `agent-mem <agent-mem@sync.invalid> ${createdUnixSeconds} +0000`;
|
|
91
|
+
const body = Buffer.from(`tree ${tree}\n${parent === null ? "" : `parent ${parent}\n`}author ${identity}\ncommitter ${identity}\n\nmemory: placement shard append\n`);
|
|
92
|
+
if (body.length > 64 * 1024) return fail();
|
|
93
|
+
const commit = object("commit", body, null); objects.push(commit);
|
|
94
|
+
return { objects, tree, commit: commit.oid };
|
|
95
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readActivePlacementBinding } from "./sync-placement-active.ts";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
/** Closed activation metadata admits v3 readers; publication still requires full journal/source/policy proof. */
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { LOCAL_METADATA_MAX_BYTES, legacyProfileMarkerShape, type BoundProfile } from "./generation.ts";
|
|
7
|
+
import { legacyShardMarkerShape } from "./shard.ts";
|
|
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
|
+
|
|
11
|
+
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";
|
|
12
|
+
export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
|
|
13
|
+
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";
|
|
14
|
+
export class SyncPlacementRuntimeError extends Error {
|
|
15
|
+
readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
|
|
16
|
+
constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
|
|
17
|
+
}
|
|
18
|
+
class MarkerChanged extends Error {}
|
|
19
|
+
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;
|
|
20
|
+
function readMarker(file: string): unknown | undefined {
|
|
21
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
22
|
+
let fd: number | undefined;
|
|
23
|
+
try {
|
|
24
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
25
|
+
if (!before) return undefined;
|
|
26
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(LOCAL_METADATA_MAX_BYTES)) throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL);
|
|
27
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
28
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
29
|
+
if (!same(before, opened)) throw new MarkerChanged();
|
|
30
|
+
const bytes = Buffer.alloc(Number(opened.size) + 1); let count = 0;
|
|
31
|
+
while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
|
|
32
|
+
const after = fstatSync(fd, { bigint: true }), named = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
33
|
+
if (count !== Number(opened.size) || !same(opened, after) || !named || named.isSymbolicLink() || !same(opened, named)) throw new MarkerChanged();
|
|
34
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count)));
|
|
35
|
+
} catch (error) { if (error instanceof MarkerChanged && attempt < 5) continue; throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL); }
|
|
36
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
37
|
+
}
|
|
38
|
+
throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL);
|
|
39
|
+
}
|
|
40
|
+
export function readPlacementRuntimeMarkers(home: string): { profile: unknown | undefined; shards: unknown | undefined } {
|
|
41
|
+
return { profile: readMarker(join(home, "profile.json")), shards: readMarker(join(home, "shards.json")) };
|
|
42
|
+
}
|
|
43
|
+
function schema(value: unknown): unknown {
|
|
44
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
45
|
+
return Object.getOwnPropertyDescriptor(value, "schema")?.value;
|
|
46
|
+
}
|
|
47
|
+
function plainMarker(value: unknown): boolean {
|
|
48
|
+
if (value === undefined) return true;
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value) || Object.getOwnPropertySymbols(value).length) return false;
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
52
|
+
return Object.values(Object.getOwnPropertyDescriptors(value)).every(field => "value" in field && field.enumerable);
|
|
53
|
+
}
|
|
54
|
+
/** Pure dispatch, so generation's active selector reader need not recursively call readSelector. */
|
|
55
|
+
export function placementMarkerRefusal(profile: unknown, shards: unknown): string | undefined {
|
|
56
|
+
if (!plainMarker(profile) || !plainMarker(shards)) return PLACEMENT_MARKER_REFUSAL;
|
|
57
|
+
if (schema(profile) === PLACEMENT_PROFILE_SCHEMA || schema(shards) === PLACEMENT_SHARDS_SCHEMA) {
|
|
58
|
+
try { validatePlacementShards(shards, validatePlacementProfile(profile)); }
|
|
59
|
+
catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
60
|
+
return PLACEMENT_STAGE1_REFUSAL;
|
|
61
|
+
}
|
|
62
|
+
if (!legacyProfileMarkerShape(profile) || !legacyShardMarkerShape(shards)) return PLACEMENT_MARKER_REFUSAL;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Selector preflight only recognizes v3. Legacy malformed/split metadata must retain its existing reader/recovery path. */
|
|
66
|
+
// This narrow probe is not a data/transport gate; effects must still use the full runtime guard.
|
|
67
|
+
export function placementSelectorRefusal(home: string): string | undefined {
|
|
68
|
+
// A durable placement fence forbids falling back to raw legacy sync after marker rollback.
|
|
69
|
+
const fenced = placementFencePresent(home);
|
|
70
|
+
const pending = placementActivationPending(home);
|
|
71
|
+
let profile: unknown, shards: unknown;
|
|
72
|
+
try { profile = readMarker(join(home, "profile.json")); } catch { /* The original selector reader reports this legacy failure. */ }
|
|
73
|
+
try { shards = readMarker(join(home, "shards.json")); } catch { /* Ordinary migration still owns malformed split-state recovery. */ }
|
|
74
|
+
if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
|
|
75
|
+
const refusal = placementMarkerRefusal(profile, shards);
|
|
76
|
+
if (refusal !== PLACEMENT_STAGE1_REFUSAL) return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
|
|
77
|
+
try { return readActivePlacementBinding(home, profile, shards) ? undefined : pending ? PLACEMENT_ACTIVATION_PENDING : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
78
|
+
}
|
|
79
|
+
function placementActivationPending(home: string): boolean {
|
|
80
|
+
return lstatSync(join(home,PLACEMENT_ACTIVATION_INTENT),{throwIfNoEntry:false}) !== undefined;
|
|
81
|
+
}
|
|
82
|
+
function placementFencePresent(home: string): boolean {
|
|
83
|
+
const root = lstatSync(join(home, "sync-placement"), { throwIfNoEntry: false });
|
|
84
|
+
if (!root) return false;
|
|
85
|
+
if (root.isSymbolicLink() || !root.isDirectory() || root.uid !== process.getuid?.() || (root.mode & 0o077) !== 0) return true;
|
|
86
|
+
return lstatSync(join(home, "sync-placement", "active.json"), { throwIfNoEntry: false }) !== undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Bounded activation/view metadata only; no data/index open, cache mutation, lock or child/network call.
|
|
89
|
+
* The v3 publication coordinator separately proves the complete journal, selected sources and policy. */
|
|
90
|
+
export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,"kind"|"sharded"|"generation"|"publicationFormat"|"placementHead">): string | undefined {
|
|
91
|
+
try { const fenced = placementFencePresent(home), pending = placementActivationPending(home), markers = readPlacementRuntimeMarkers(home);
|
|
92
|
+
const refusal = placementMarkerRefusal(markers.profile, markers.shards);
|
|
93
|
+
if (refusal === PLACEMENT_STAGE1_REFUSAL) {
|
|
94
|
+
const active=readActivePlacementBinding(home,markers.profile,markers.shards);
|
|
95
|
+
if(!active)return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
|
|
96
|
+
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;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return refusal ?? (pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
|
|
100
|
+
catch { return PLACEMENT_MARKER_REFUSAL; }
|
|
101
|
+
}
|
|
102
|
+
export function assertPlacementRuntime(home: string): void {
|
|
103
|
+
const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(refusal);
|
|
104
|
+
}
|