@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
package/src/setup.ts
CHANGED
|
@@ -10,11 +10,11 @@ import { JCODE_CAPTURE_EVENTS, putJcodeHooks } from "./setup-jcode.ts";
|
|
|
10
10
|
const jcodeHookCommand = (bun: string, entry: string) => `${quote(bun)} --smol ${quote(entry)} hook jcode`;
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
-
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync, type BigIntStats } from "node:fs";
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { isDeepStrictEqual } from "node:util";
|
|
17
|
+
import { isDeepStrictEqual, types } from "node:util";
|
|
18
18
|
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
19
19
|
import { memoryHome } from "./memory/home.ts";
|
|
20
20
|
import { label, PLAIN, singleLine, type Style } from "./memory/style.ts";
|
|
@@ -209,20 +209,16 @@ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mo
|
|
|
209
209
|
}
|
|
210
210
|
function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "jcode" ? ["jcode-hooks-v1"] : []), ...(["claude", "codex", "kimi", "pi", "qwen", "jcode"].includes(target) ? ["root-hooks-v2"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
|
|
211
211
|
function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
|
|
212
|
+
function decodeReceipt(value: Record<string, unknown>, target: SetupTarget, count: number, strict = false): Receipt {
|
|
213
|
+
if (strict && Object.keys(value).some(key => !["version", "target", "intent", "phase", "changes", "createdDirectory"].includes(key))) throw Error("setup receipt invalid");
|
|
215
214
|
if (value.version !== 1 && value.version !== 2) throw Error("setup receipt version is unsupported; run setup or undo with the release that wrote it");
|
|
216
|
-
if (value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) ||
|
|
215
|
+
if (value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || strict && value.intent.length !== 64 || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== count && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
|
|
217
216
|
for (let i = 0; i < value.changes.length; i++) {
|
|
218
|
-
const change = record(value.changes[i]);
|
|
219
|
-
if (target
|
|
220
|
-
&& resolveSetupPaths({ ...c.env, CLAUDE_CONFIG_DIR: dirname(change.path) }, c.home).redirects.some(row => row.from === change.path && row.to === currentPath))
|
|
221
|
-
throw Error("setup receipt was written from an Orca Claude profile; rerun setup from that Orca profile");
|
|
222
|
-
if (change.path !== currentPath && !legacyRedirect || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
|
|
217
|
+
const change = record(value.changes[i]);
|
|
218
|
+
if (change.key !== `${target}:${i}` || strict && (Object.keys(change).some(key => !["key", "path", "before", "after", "start"].includes(key)) || typeof change.path !== "string" || !isAbsolute(change.path) || change.path.length > 4096 || change.path.includes("\0"))) throw Error("setup receipt target mismatch");
|
|
223
219
|
for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
|
|
224
220
|
const v = record(change[key]);
|
|
225
|
-
if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
221
|
+
if (strict && Object.keys(v).sort().join() !== "bytes,mode" || !Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || strict && (v.bytes.length > Math.ceil(MAX / 3) * 4 || v.bytes.length / 4 * 3 - (v.bytes.endsWith("==") ? 2 : v.bytes.endsWith("=") ? 1 : 0) > MAX) || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
226
222
|
}
|
|
227
223
|
}
|
|
228
224
|
if (value.createdDirectory !== undefined) {
|
|
@@ -231,6 +227,19 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
|
|
|
231
227
|
}
|
|
232
228
|
return value as unknown as Receipt;
|
|
233
229
|
}
|
|
230
|
+
function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
|
|
231
|
+
const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
|
|
232
|
+
const value = decodeReceipt(record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8"))), target, c.paths[target].length);
|
|
233
|
+
if (requireRuntime && value.intent !== intent(c, target)) throw Error("setup receipt does not match this installation");
|
|
234
|
+
for (let i = 0; i < value.changes.length; i++) {
|
|
235
|
+
const change = value.changes[i]!, currentPath = c.paths[target][i], legacyRedirect = c.redirects.some(row => row.target === target && row.from === change.path && row.to === currentPath);
|
|
236
|
+
if (target === "claude" && i === 0 && typeof change.path === "string" && change.path !== currentPath && !legacyRedirect
|
|
237
|
+
&& resolveSetupPaths({ ...c.env, CLAUDE_CONFIG_DIR: dirname(change.path) }, c.home).redirects.some(row => row.from === change.path && row.to === currentPath))
|
|
238
|
+
throw Error("setup receipt was written from an Orca Claude profile; rerun setup from that Orca profile");
|
|
239
|
+
if (change.path !== currentPath && !legacyRedirect) throw Error("setup receipt target mismatch");
|
|
240
|
+
}
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
234
243
|
function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
|
|
235
244
|
if (target === "jcode") return JCODE_CAPTURE_EVENTS.map(event => ({ event, command: jcodeHookCommand(c.bun, c.entry), timeout: 1 }));
|
|
236
245
|
if (target === "opencode" || target === "gemini" || target === "agy") return [];
|
|
@@ -407,9 +416,14 @@ function previousRuntimes(target: SetupTarget, images: readonly Image[], bun: st
|
|
|
407
416
|
return [...found.values()];
|
|
408
417
|
}
|
|
409
418
|
/** The runtime of a generated `<bun> --smol <entry> mcp` server record, if the record is exactly that shape. */
|
|
410
|
-
function launcher(value: unknown): { bun: string; entry: string } | undefined {
|
|
419
|
+
function launcher(value: unknown, target?: SetupTarget): { bun: string; entry: string } | undefined {
|
|
411
420
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
412
|
-
const server = value as Record<string, unknown
|
|
421
|
+
const server = value as Record<string, unknown>;
|
|
422
|
+
if (target === "opencode") {
|
|
423
|
+
if (server.type !== "local" || !Array.isArray(server.command) || server.command.length !== 4) return;
|
|
424
|
+
return launcher({ command: server.command[0], args: server.command.slice(1) });
|
|
425
|
+
}
|
|
426
|
+
const args = server.args;
|
|
413
427
|
return typeof server.command === "string" && isAbsolute(server.command) && Array.isArray(args) && args.length === 3
|
|
414
428
|
&& args[0] === "--smol" && typeof args[1] === "string" && isAbsolute(args[1]) && args[2] === "mcp" ? { bun: server.command, entry: args[1] } : undefined;
|
|
415
429
|
}
|
|
@@ -720,3 +734,76 @@ export async function runSetup(target: string, mode: SetupPlan["mode"], options:
|
|
|
720
734
|
const result = mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options);
|
|
721
735
|
return options.json ? JSON.stringify(result, null, 2) : setupResultText(result, s);
|
|
722
736
|
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
/** Fixed safe category only; receipt text, native paths and external error messages are never diagnostics. */
|
|
740
|
+
export class SetupWriterRootsError extends Error {
|
|
741
|
+
readonly code = "setup_writer_roots_unavailable";
|
|
742
|
+
constructor(readonly category: "unsafe" | "changed" | "bound" | "unsupported" | "pending" | "unavailable") {
|
|
743
|
+
super(`Setup writer inventory ${category}; have the person inspect setup receipts and rerun setup before activation.`);
|
|
744
|
+
this.name = "SetupWriterRootsError";
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/** Current managed MCP AFTER images only. Native configuration paths and historical BEFORE/START images are never opened. */
|
|
748
|
+
export function readSetupWriterRoots(home: string): string[] {
|
|
749
|
+
function fail(category: SetupWriterRootsError["category"]): never { throw new SetupWriterRootsError(category); }
|
|
750
|
+
try {
|
|
751
|
+
const owned = (stat: BigIntStats) => { if (stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (stat.mode & 0o077n) !== 0n) fail("unsafe"); };
|
|
752
|
+
const stamp = (stat: BigIntStats) => JSON.stringify([stat.dev, stat.ino, stat.uid, stat.mode, stat.nlink, stat.size, stat.mtimeNs, stat.ctimeNs].map(String));
|
|
753
|
+
const base = lstatSync(home, { bigint: true, throwIfNoEntry: false }); if (!base) return []; owned(base); if (!base.isDirectory()) fail("unsafe");
|
|
754
|
+
const directory = join(home, "setup"), initial = lstatSync(directory, { bigint: true, throwIfNoEntry: false });
|
|
755
|
+
if (!initial) return [];
|
|
756
|
+
owned(initial); if (!initial.isDirectory()) fail("unsafe");
|
|
757
|
+
// ponytail:256 setup namespace entries and doctor's existing32MiB aggregate receipt-byte ceiling.
|
|
758
|
+
// A reviewed metadata-only setup inventory is required before increasing either ceiling.
|
|
759
|
+
const files: { name: string; stat: BigIntStats }[] = [], namespace: string[] = [], iterator = opendirSync(directory);
|
|
760
|
+
let entries = 0, totalBytes = 0;
|
|
761
|
+
try { for (;;) {
|
|
762
|
+
const entry = iterator.readSync(); if (!entry) break;
|
|
763
|
+
namespace.push(entry.name);
|
|
764
|
+
if (++entries > 256) fail("bound"); if (entry.name === "setup.lock") fail("pending");
|
|
765
|
+
if (!entry.name.endsWith(".json")) continue; // Retained .json.stale-* and unregistered temps are historical, never current authority.
|
|
766
|
+
const stat = lstatSync(join(directory, entry.name), { bigint: true }); owned(stat); if (!stat.isFile() || stat.nlink !== 1n) fail("unsafe");
|
|
767
|
+
if (stat.size > BigInt(JOURNAL_MAX) || totalBytes + Number(stat.size) > 32 * 1024 * 1024) fail("bound");
|
|
768
|
+
totalBytes += Number(stat.size); files.push({ name: entry.name, stat });
|
|
769
|
+
} } finally { iterator.closeSync(); }
|
|
770
|
+
const roots = new Set<string>(), launchers: { entry: string; root: string }[] = [];
|
|
771
|
+
for (const file of files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
772
|
+
const target = (file.name.startsWith("agy-") && /^agy-[a-f0-9]{64}\.json$/.test(file.name) ? "agy" : file.name.slice(0, -5)) as SetupTarget;
|
|
773
|
+
if (!SETUP_TARGETS.includes(target) || target === "agy" && !/^agy-[a-f0-9]{64}\.json$/.test(file.name)) fail("unsupported");
|
|
774
|
+
const path = join(directory, file.name), fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
775
|
+
let text: string;
|
|
776
|
+
try {
|
|
777
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened); if (!opened.isFile() || opened.nlink !== 1n || stamp(opened) !== stamp(file.stat)) fail("changed");
|
|
778
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let count = 0;
|
|
779
|
+
while (count < data.length) { const n = readSync(fd, data, count, data.length - count, count); if (!n) break; count += n; }
|
|
780
|
+
if (count !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(path, { bigint: true })) !== stamp(opened)) fail("changed");
|
|
781
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(0, count));
|
|
782
|
+
} finally { closeSync(fd); }
|
|
783
|
+
const counts: Record<SetupTarget, number> = { claude: 2, codex: 2, kimi: 2, pi: 2, qwen: 1, jcode: 2, opencode: 1, gemini: 1, agy: 1 };
|
|
784
|
+
let value: Receipt;
|
|
785
|
+
try { value = decodeReceipt(record(JSON.parse(text)), target, counts[target], true); } catch { return fail("unsupported"); }
|
|
786
|
+
if (value.phase !== "applied") fail("pending");
|
|
787
|
+
const runtime = launcher(mcpRecord(target, value.changes[0]!.after), target);
|
|
788
|
+
if (!runtime || runtime.bun.includes("\0") || runtime.entry.length > 4096 || runtime.entry.includes("\0") || !["/bin/amem.js", "/src/cli.ts"].some(suffix => runtime.entry.endsWith(suffix))
|
|
789
|
+
|| target === "opencode" && [runtime.bun, runtime.entry].some(value => /\{(?:env|file):/.test(value))) fail("unsupported");
|
|
790
|
+
const root = realPath(dirname(dirname(runtime.entry))); if (!lstatSync(root).isDirectory()) fail("unavailable");
|
|
791
|
+
roots.add(root); launchers.push({ entry: runtime.entry, root });
|
|
792
|
+
}
|
|
793
|
+
const finalHome = lstatSync(home, { bigint: true }); owned(finalHome);
|
|
794
|
+
if (!finalHome.isDirectory() || stamp(lstatSync(directory, { bigint: true })) !== stamp(initial) || finalHome.dev !== base.dev || finalHome.ino !== base.ino) fail("changed");
|
|
795
|
+
const finalNames: string[] = [], finalDirectory = opendirSync(directory);
|
|
796
|
+
try { for (;;) { const entry = finalDirectory.readSync(); if (!entry) break; if (finalNames.length >= namespace.length) fail("changed"); finalNames.push(entry.name); } }
|
|
797
|
+
finally { finalDirectory.closeSync(); }
|
|
798
|
+
if (JSON.stringify(finalNames.sort()) !== JSON.stringify(namespace.sort())) fail("changed");
|
|
799
|
+
for (const file of files) if (stamp(lstatSync(join(directory, file.name), { bigint: true })) !== stamp(file.stat)) fail("changed");
|
|
800
|
+
for (const runtime of launchers) if (realPath(dirname(dirname(runtime.entry))) !== runtime.root) fail("changed");
|
|
801
|
+
return [...roots].sort();
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SetupWriterRootsError) {
|
|
804
|
+
const category = Object.getOwnPropertyDescriptor(error, "category");
|
|
805
|
+
if (category && "value" in category && ["unsafe", "changed", "bound", "unsupported", "pending", "unavailable"].includes(category.value)) throw new SetupWriterRootsError(category.value);
|
|
806
|
+
}
|
|
807
|
+
throw new SetupWriterRootsError("unavailable");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** CLI adapter only. Expected locators are never returned as fresh writer facts. */
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { realPath } from "./memory/platform.ts";
|
|
4
|
+
import { canonicalJSON } from "./memory/generation.ts";
|
|
5
|
+
import { readSetupWriterRoots } from "./setup.ts";
|
|
6
|
+
import { measureSyncPlacementWriters, extractSyncPlacementWriterFailure, type SyncPlacementWriterFailureDetail, type SyncPlacementWriterMeasurement } from "./memory/sync-placement-writers.ts";
|
|
7
|
+
import { readEffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
|
|
8
|
+
import { requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
|
|
9
|
+
import type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
|
|
10
|
+
import { SharedWriterRevisionError } from "./memory/sync-placement-writer-revision-format.ts";
|
|
11
|
+
|
|
12
|
+
export type PlacementWriterRootCategory = "running CLI" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
|
|
13
|
+
export type PlacementWriterContextPhase = "initial" | "active";
|
|
14
|
+
const failureDetails:Record<SyncPlacementWriterFailureDetail,string>={
|
|
15
|
+
"below-minimum":"release is below 0.21.0", "wrong-package":"package is not @staix/agent-mem", "writable-runtime":"runtime code is group- or world-writable", unreadable:"runtime files are unreadable or missing",
|
|
16
|
+
"invalid-version":"package version is not a supported plain release", "invalid-inventory":"root inventory is invalid or duplicated", "unsafe-runtime":"runtime files have unsafe types or symlinks",
|
|
17
|
+
"missing-runtime":"required bin/amem.js or src/index.ts is missing",bound:"inventory exceeds a supported bound",changed:"install changed during measurement",
|
|
18
|
+
};
|
|
19
|
+
function writerContextMessage(category:PlacementWriterRootCategory,causeClass:"missing"|"changed"|"unavailable",phase:PlacementWriterContextPhase,detail?:SyncPlacementWriterFailureDetail,renewalState?:"pending"|"staging"):string {
|
|
20
|
+
if(category==="writer renewal")return new SharedWriterRevisionError(renewalState==="staging"?"staging":"pending").message;
|
|
21
|
+
if(category==="active locator") return `Sync placement initial root locator is ${causeClass} (active locator category); have the person inspect sync-placement-writer-roots.json and the matching activation metadata with a compatible recovery release, then run amem migrate --sync-placement --dry-run.`;
|
|
22
|
+
if(category==="home floor") return `Sync placement reader-floor proof is ${causeClass} (home floor category); have the person inspect formats.json and use a reader compatible with its recorded floor, then run amem migrate --sync-placement --dry-run.`;
|
|
23
|
+
const cause=detail?`; ${failureDetails[detail]}`:"";
|
|
24
|
+
if(phase==="initial")return `Sync placement initial writer proof is ${causeClass} (${category} category${cause}); ${category==="setup receipt"?"upgrade or repair the managed amem install and its host exact pin, then rerun amem setup <target> --apply":"upgrade or repair the amem install and its host exact pin"}. Declare the complete writer roots and rerun amem migrate --sync-placement --dry-run.`;
|
|
25
|
+
return `Sync placement writer proof is ${causeClass} (${category} category${cause}); restore the inventoried install, or have the person run amem migrate --sync-placement --dry-run and --apply with the complete --writer-root inventory after installing the required 0.22.0 home floor.`;
|
|
26
|
+
}
|
|
27
|
+
export class PlacementWriterContextError extends Error {
|
|
28
|
+
constructor(readonly category: PlacementWriterRootCategory, readonly causeClass: "missing" | "changed" | "unavailable",readonly phase:PlacementWriterContextPhase="active",readonly detail?:SyncPlacementWriterFailureDetail,readonly renewalState?:"pending"|"staging") {
|
|
29
|
+
super(writerContextMessage(category,causeClass,phase,detail,renewalState)); this.name = "PlacementWriterContextError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function safeRefusal(error: unknown, category: PlacementWriterRootCategory,phase:PlacementWriterContextPhase="active"): PlacementWriterContextError {
|
|
33
|
+
if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="pending"||code==="staging")return new PlacementWriterContextError("writer renewal","unavailable",phase,undefined,code);}
|
|
34
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementWriterContextError) {
|
|
35
|
+
const fields=Object.getOwnPropertyDescriptors(error),root=fields.category,cause=fields.causeClass,detail=fields.detail;
|
|
36
|
+
const category:unknown=root&&"value"in root?root.value:undefined,causeClass:unknown=cause&&"value"in cause?cause.value:undefined;
|
|
37
|
+
if (typeof category==="string" && ["running CLI","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
|
|
38
|
+
&& typeof causeClass==="string" && ["missing","changed","unavailable"].includes(causeClass)) {
|
|
39
|
+
const ownDetail=detail&&"value"in detail&&typeof detail.value==="string"&&Object.hasOwn(failureDetails,detail.value)?detail.value as SyncPlacementWriterFailureDetail:undefined;
|
|
40
|
+
const state=fields.renewalState?.value;
|
|
41
|
+
return new PlacementWriterContextError(category as PlacementWriterRootCategory,causeClass as PlacementWriterContextError["causeClass"],phase,ownDetail,state==="staging"||state==="pending"?state:undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return new PlacementWriterContextError(category,"unavailable",phase);
|
|
45
|
+
}
|
|
46
|
+
const fail = (category: PlacementWriterRootCategory, cause: PlacementWriterContextError["causeClass"],phase:PlacementWriterContextPhase="active",detail?:SyncPlacementWriterFailureDetail): never => { throw new PlacementWriterContextError(category,cause,phase,detail); };
|
|
47
|
+
function required(home: string, ownRoot: string,phase:PlacementWriterContextPhase="active") {
|
|
48
|
+
let own: string, receipt: string[];
|
|
49
|
+
try { own = realPath(ownRoot); } catch { return fail("running CLI","unavailable",phase,"unreadable"); }
|
|
50
|
+
try { receipt = readSetupWriterRoots(home); } catch { return fail("setup receipt","unavailable",phase,"unreadable"); }
|
|
51
|
+
return { own, receipt, roots: [...new Set([own,...receipt])].sort() };
|
|
52
|
+
}
|
|
53
|
+
function rootCategory(root:string|undefined,needed:ReturnType<typeof required>):PlacementWriterRootCategory {
|
|
54
|
+
if(root===needed.own)return "running CLI";return root&&needed.receipt.includes(root)?"setup receipt":"declared writer";
|
|
55
|
+
}
|
|
56
|
+
function measurementRefusal(error:unknown,roots:readonly string[],needed:ReturnType<typeof required>,phase:PlacementWriterContextPhase):PlacementWriterContextError {
|
|
57
|
+
const detail=extractSyncPlacementWriterFailure(error);let root:string|undefined;
|
|
58
|
+
if(detail?.rootIndex!==undefined){try{root=realPath(roots[detail.rootIndex]!);}catch{/* Missing noncanonical declared path is still unavailable, never fresh evidence. */}}
|
|
59
|
+
return new PlacementWriterContextError(rootCategory(root,needed),detail?.code==="changed"?"changed":"unavailable",phase,detail?.detail??"unreadable");
|
|
60
|
+
}
|
|
61
|
+
function measureComplete(home: string, roots: readonly string[], ownRoot: string,phase:PlacementWriterContextPhase="active"): SyncPlacementWriterMeasurement {
|
|
62
|
+
const before = required(home,ownRoot,phase);let result: SyncPlacementWriterMeasurement;
|
|
63
|
+
try { result = measureSyncPlacementWriters(roots); } catch(error) { throw measurementRefusal(error,roots,before,phase); }
|
|
64
|
+
if (!result.roots.some(row => row.root === before.own)) fail("running CLI","missing",phase);
|
|
65
|
+
if (before.receipt.some(root => !result.roots.some(row => row.root === root))) fail("setup receipt","missing",phase);
|
|
66
|
+
const after = required(home,ownRoot,phase);
|
|
67
|
+
if (after.own !== before.own) fail("running CLI","changed",phase,"changed");
|
|
68
|
+
if (canonicalJSON(after.receipt) !== canonicalJSON(before.receipt)) fail("setup receipt","changed",phase,"changed");
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
export interface PlacementWriterContext {
|
|
72
|
+
measurement: SyncPlacementWriterMeasurement;
|
|
73
|
+
attest: PlacementCapturePort["attest"];
|
|
74
|
+
lastRefusal?: PlacementWriterContextError;
|
|
75
|
+
}
|
|
76
|
+
/** Preview creates no quiescence claim. The coordinator invokes attest only while its live apply fences are held. */
|
|
77
|
+
export function initialPlacementWriterContext(home: string, roots: readonly string[], ownRoot: string): PlacementWriterContext {
|
|
78
|
+
const selected = roots.length ? [...roots] : required(home,ownRoot,"initial").roots;
|
|
79
|
+
const measurement = measureComplete(home,selected,ownRoot,"initial"), canonical = measurement.roots.map(row=>row.root);
|
|
80
|
+
const floor = requirePlacementHomeFloor(home);
|
|
81
|
+
const context: PlacementWriterContext = { measurement, attest: () => {
|
|
82
|
+
try {
|
|
83
|
+
if (requirePlacementHomeFloor(home) !== floor) return fail("home floor","changed","initial");
|
|
84
|
+
const current = measureComplete(home,canonical,ownRoot,"initial");
|
|
85
|
+
if (canonicalJSON(current.writers) !== canonicalJSON(measurement.writers)) {
|
|
86
|
+
const changed=current.roots.find(row=>!measurement.writers.some(old=>old.writerId===row.writerId&&old.version===row.version&&old.pin===row.pin));
|
|
87
|
+
return fail(rootCategory(changed?.root,required(home,ownRoot,"initial")),"changed","initial","changed");
|
|
88
|
+
}
|
|
89
|
+
return { writers: current.writers, legacySyncQuiesced: true };
|
|
90
|
+
} catch (error) { context.lastRefusal = safeRefusal(error,"declared writer","initial"); throw context.lastRefusal; }
|
|
91
|
+
} }; return context;
|
|
92
|
+
}
|
|
93
|
+
/** Active callbacks re-read the bound locator AND measure every package anew. No activation or renewal occurs. */
|
|
94
|
+
export function activePlacementWriterContext(home: string, ownRoot: string): PlacementWriterContext {
|
|
95
|
+
const read = () => {
|
|
96
|
+
requirePlacementHomeFloor(home);
|
|
97
|
+
const effective=readEffectiveSyncPlacementWriters(home);
|
|
98
|
+
const roots=effective.roots;if(!roots)return fail("active locator","unavailable");
|
|
99
|
+
const needed = required(home,ownRoot);
|
|
100
|
+
if(!roots.includes(needed.own))return fail("running CLI","missing");
|
|
101
|
+
if(needed.receipt.some(root=>!roots.includes(root)))return fail("setup receipt","missing");
|
|
102
|
+
const measurement = measureComplete(home,roots,ownRoot);
|
|
103
|
+
if (canonicalJSON(measurement.writers) !== canonicalJSON(effective.writers)) {
|
|
104
|
+
const changed=measurement.roots.find(row=>!effective.writers.some(old=>old.writerId===row.writerId&&old.version===row.version&&old.pin===row.pin));
|
|
105
|
+
return fail(rootCategory(changed?.root,needed),"changed","active","changed");
|
|
106
|
+
}
|
|
107
|
+
effective.assertCurrent();
|
|
108
|
+
return measurement;
|
|
109
|
+
};
|
|
110
|
+
const measurement = read(), context: PlacementWriterContext = { measurement, attest: () => {
|
|
111
|
+
try { return { writers: read().writers, legacySyncQuiesced: true }; }
|
|
112
|
+
catch (error) { context.lastRefusal = safeRefusal(error,"active locator"); throw context.lastRefusal; }
|
|
113
|
+
} }; return context;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Person preview only: incomplete measurements remain explicit and never become an attestation. */
|
|
117
|
+
export function previewActivePlacementWriters(home: string, ownRoot: string, requested: readonly string[] = []) {
|
|
118
|
+
const effective=readEffectiveSyncPlacementWriters(home),mapped=effective.roots;if(!mapped)return fail("active locator","unavailable");
|
|
119
|
+
const selected = requested.length ? [...requested] : mapped;
|
|
120
|
+
const expected = effective.writers;
|
|
121
|
+
let measurement: SyncPlacementWriterMeasurement;
|
|
122
|
+
try { measurement = measureSyncPlacementWriters(selected); }
|
|
123
|
+
catch { return { complete:false, matches:false, roots:[] as SyncPlacementWriterMeasurement["roots"], expected, missingCategories:["declared writer"] as PlacementWriterRootCategory[], differences:expected.map(row=>({writerId:row.writerId,state:"unverified" as const})),refusalCategory:"declared writer" as PlacementWriterRootCategory }; }
|
|
124
|
+
const needed = required(home,ownRoot), missingCategories: PlacementWriterRootCategory[] = [];
|
|
125
|
+
if (!measurement.roots.some(row=>row.root===needed.own)) missingCategories.push("running CLI");
|
|
126
|
+
if (needed.receipt.some(root=>!measurement.roots.some(row=>row.root===root))) missingCategories.push("setup receipt");
|
|
127
|
+
const differences = expected.map(row=>{
|
|
128
|
+
const actual = measurement.writers.find(value=>value.writerId===row.writerId);
|
|
129
|
+
return {writerId:row.writerId,state:!actual?"missing":canonicalJSON(row)===canonicalJSON(actual)?"unchanged":"changed"};
|
|
130
|
+
});
|
|
131
|
+
for (const row of measurement.writers) if (!expected.some(value=>value.writerId===row.writerId)) differences.push({writerId:row.writerId,state:"added"});
|
|
132
|
+
const matches=missingCategories.length===0&&differences.every(row=>row.state==="unchanged");
|
|
133
|
+
const changed=measurement.roots.find(row=>{const old=expected.find(value=>value.writerId===row.writerId);return !old||old.pin!==row.pin||old.version!==row.version;});
|
|
134
|
+
const refusalCategory:PlacementWriterRootCategory=missingCategories[0]??(changed?.root===needed.own?"running CLI":changed&&needed.receipt.includes(changed.root)?"setup receipt":"declared writer");
|
|
135
|
+
return {complete:true,matches,roots:measurement.roots,expected,missingCategories,differences,refusalCategory};
|
|
136
|
+
}
|