@staix/agent-mem 0.21.0 → 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 +27 -1
- package/package.json +1 -1
- package/src/cli.ts +34 -4
- package/src/commands-table.ts +3 -0
- package/src/index.ts +6 -0
- package/src/memory/commands.ts +3 -2
- package/src/memory/public-prune-activation.ts +4 -4
- package/src/memory/shard-sync.ts +1 -2
- 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 +12 -6
- 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.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.22.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -555,3 +555,29 @@ Private cursors under `hook-transcripts/` store only a session hash, file identi
|
|
|
555
555
|
The follow-up bound is a benchmark parameter, not a production retry loop. Backlogs advance on later Stop/SessionEnd calls of the same session. Anything unread after its last hook remains uncaptured, so a cold install on a long session keeps only what its remaining hooks can scan; committed rows are not lost.
|
|
556
556
|
|
|
557
557
|
Search gives these events the existing 1.25 conversation weight and session grouping. The brief selects up to two newest screened main-session Recap/Compact rows first, then fills from the existing chronological rows, under the same record, byte and token caps. With no summaries the output is unchanged. See [verification evidence](docs/bench/transcript-summaries-201.md).
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
## Initial sync placement activation
|
|
561
|
+
|
|
562
|
+
Available from 0.22.0. 0.21.0 contains the placement protocol but not this command.
|
|
563
|
+
|
|
564
|
+
A person can activate per-row outbound placement on one whole memory home. It is forward-only, and never runs migrations, changes privacy policy or raises a reader floor automatically. Real-home activation still needs the person's separate authorization.
|
|
565
|
+
|
|
566
|
+
1. Preview and complete the existing shard and public-prune migrations with `amem migrate --shards --dry-run` and `amem migrate --public-prune-view --dry-run` (then their separately reviewed `--apply` steps).
|
|
567
|
+
2. On a 0.22 installation, have the person append the existing `home` floor at 0.22.0 in `formats.json`, preserving prior records, and close cooperating memory reader handles. This is recommended at activation and required before the first writer renewal, so old 0.21 readers refuse before opening a renewable home.
|
|
568
|
+
3. Declare every local amem installation that writes this home, including an agent-hub library installation even if setup has no receipt for it. The CLI and current setup-receipt roots are mandatory members.
|
|
569
|
+
|
|
570
|
+
```sh
|
|
571
|
+
amem migrate --sync-placement --dry-run --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
|
|
572
|
+
amem migrate --sync-placement --apply --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
|
|
573
|
+
# Only for an interrupted initial activation, using its retained frozen inputs:
|
|
574
|
+
amem migrate --sync-placement --resume --json
|
|
575
|
+
# If inputs changed and BOTH marker files still have their original v2 bytes:
|
|
576
|
+
amem migrate --sync-placement --abandon --json
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Dry-run shows root versions/pins and safe policy counts, never row text or patterns, and writes nothing. Initial writer errors name the root category and cause, with upgrade/setup instructions. If a pre-marker interruption retained an intent and its inputs changed, person `--abandon` removes only its owned initial metadata so a fresh preview/apply can follow. It cannot remove a v3 marker, active fence or journal history. After apply, choose the private remote on the first v3 sync (`amem sync --remote <private-git-url>`); ordinary and cadence sync then read the immutable root locator and measure the installs afresh. Allowed originals can publish while declined originals remain in the local append-only history, with counts in the sync receipt. No writer roots need to be repeated on sync.
|
|
580
|
+
|
|
581
|
+
On an active home, the same dry-run/apply commands explicitly renew the writer inventory on its existing epoch (#282). Declare the complete new 0.22.0-or-later roots after updating host pins and managed setup receipts. Ordinary and cadence sync then locate the new inventory and measure it afresh; original activation data, holds and prior receipts are unchanged. Pending publication or source-recovery work must first be finished with the inventoried install, for example after `dot ai rollback amem` or restoring the host's previous pin.
|
|
582
|
+
|
|
583
|
+
An interrupted renewal resumes only its exact intent. Before commit append starts, person `--abandon` can discard its unpublished metadata; after the first commit-log byte, only forward resume is supported. Unpublished intent staging requires explicit `--abandon`, then a fresh dry-run; it is never cleaned automatically. Compatible ordinary reads remain available while only renewal metadata is pending. After a crash, a retained `profile.lock` still blocks new reader admission until person `--resume` or, before commit append, `--abandon`. Never edit activation files, downgrade markers or start another epoch to discard a hold. See the [renewal contract](docs/contracts.md#same-epoch-writer-inventory-renewal-282). Real-home renewal and release remain separately authorized person operations.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertFormatFloor } from "./memory/formats.ts";
|
|
2
|
-
import { placementRuntimeRefusal } from "./memory/sync-placement-guard.ts";
|
|
2
|
+
import { placementRuntimeRefusal, readPlacementRuntimeMarkers } from "./memory/sync-placement-guard.ts";
|
|
3
3
|
import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
|
|
4
4
|
import { AgentUsageError, agentCliRead } from "./cli-agent-reads.ts";
|
|
5
5
|
import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
|
|
@@ -20,6 +20,7 @@ import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
|
20
20
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
21
21
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
23
24
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
24
25
|
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
25
26
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
@@ -378,6 +379,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
378
379
|
}
|
|
379
380
|
const { definition, options, rawArgs: args } = parsed;
|
|
380
381
|
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
382
|
+
if (parsed.path[0] === "migrate" && options["--sync-placement"] === true) {
|
|
383
|
+
const modes = ["--dry-run","--apply","--resume","--abandon","--deactivate"].filter(key=>options[key]===true);
|
|
384
|
+
if (modes.length>1 || parsed.positionals.length || ["--shards","--public-prune-view","--generation","--deactivate"].some(key=>options[key]!==undefined))
|
|
385
|
+
throw new AgentUsageError("Sync placement is a home-wide forward-only migration; use only --sync-placement with --dry-run, --apply, --resume or pre-marker --abandon and the writer roots, never combine migration families or deactivate.");
|
|
386
|
+
if (actor === "agent") throw new AgentUsageError("Sync placement activation requires a person; ask the person to run amem migrate --sync-placement --dry-run and review the home-wide forward-only transition.");
|
|
387
|
+
} else if (parsed.path[0] === "migrate" && (options["--writer-root"] !== undefined||options["--abandon"]!==undefined)) {
|
|
388
|
+
throw new AgentUsageError("--writer-root and --abandon apply only to --sync-placement; run amem migrate --sync-placement --dry-run to review the inventory.");
|
|
389
|
+
}
|
|
381
390
|
if (parsed.path[0] === "setup") {
|
|
382
391
|
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true
|
|
383
392
|
|| options["--adopt-current"] === true && (options["--apply"] !== true || parsed.positionals[0] === "console")) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--root <abs path>] [--apply [--adopt-current]|--undo]");
|
|
@@ -472,9 +481,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
472
481
|
}
|
|
473
482
|
if ((options["--history"] === true || options["--as-of"] !== undefined) && (options["--all-projects"] === true || options["--tier"] === "repo")) throw Error("historical recall selects this active project's curated store; cross-project/repo history is unavailable");
|
|
474
483
|
if (sub === "migrate") {
|
|
475
|
-
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
484
|
+
const modes = ["--apply", "--dry-run", "--resume", "--abandon", "--deactivate"].filter(key => options[key] === true);
|
|
476
485
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
477
486
|
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
487
|
+
if (options["--sync-placement"] === true) {
|
|
488
|
+
const { activateSyncPlacement } = await import("./memory/sync-placement-activation.ts");
|
|
489
|
+
const result = await activateSyncPlacement({ memoryDir:memoryHome(),mode:options["--abandon"]?"abandon":mode as "dry-run"|"apply"|"resume",
|
|
490
|
+
writerRoots:repeated(argv,"--writer-root"),ownRoot:fileURLToPath(new URL("..",import.meta.url)),
|
|
491
|
+
authorize:()=>cliAttribution()?.actor==="person" });
|
|
492
|
+
if (options["--json"] === true) return JSON.stringify(result,null,2);
|
|
493
|
+
// Metadata only: the source reader and locator are never serialized into this receipt.
|
|
494
|
+
return `sync placement: ${result.mode}; ${result.active?"active":"inactive"}; home-wide, forward-only (no deactivate)\n${JSON.stringify(result,null,2)}`;
|
|
495
|
+
}
|
|
478
496
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
479
497
|
if (options["--public-prune-view"] === true) {
|
|
480
498
|
if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
|
|
@@ -1062,15 +1080,21 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1062
1080
|
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
1063
1081
|
const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
|
|
1064
1082
|
let output: string;
|
|
1083
|
+
let placementWriters: import("./sync-placement-writer-context.ts").PlacementWriterContext | undefined;
|
|
1065
1084
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
1066
1085
|
try {
|
|
1086
|
+
if (sub === "sync" && (readPlacementRuntimeMarkers(syncHome).profile as { publicationFormat?:string } | undefined)?.publicationFormat === "agent-mem.sync-placement/v1") {
|
|
1087
|
+
const { activePlacementWriterContext } = await import("./sync-placement-writer-context.ts");
|
|
1088
|
+
placementWriters = activePlacementWriterContext(syncHome,fileURLToPath(new URL("..",import.meta.url)));
|
|
1089
|
+
}
|
|
1067
1090
|
output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
|
|
1068
|
-
sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
1091
|
+
sync: { ...(placementWriters ? { placementAttestation:placementWriters.attest } : {}), screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
1069
1092
|
onBridgeStatus: status => { receipt.bridgeStatus = status; },
|
|
1070
1093
|
// #188: ids-only listing of nearby machine-local rows; policy-refused rows are neither listed nor counted.
|
|
1071
1094
|
forgetNearby: note => forgetNearby({ base: memoryHome(), project: note.project, text: note.text, title: note.title, screen: value => configured.screenStandalone(value) }) }));
|
|
1072
1095
|
} catch (error) {
|
|
1073
1096
|
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
1097
|
+
if (sub === "sync" && placementWriters?.lastRefusal) throw Error(`${placementWriters.lastRefusal.message} ${(error as Error).message}`);
|
|
1074
1098
|
throw error;
|
|
1075
1099
|
}
|
|
1076
1100
|
finally {
|
|
@@ -1082,7 +1106,13 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1082
1106
|
if (sub === "sync") {
|
|
1083
1107
|
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
1084
1108
|
let failure: string | undefined;
|
|
1085
|
-
if (options["--json"]) { try {
|
|
1109
|
+
if (options["--json"]) { try {
|
|
1110
|
+
const result = JSON.parse(output) as { ok?: unknown; error?: unknown };
|
|
1111
|
+
if (result.ok === false) {
|
|
1112
|
+
failure = typeof result.error === "string" ? result.error : "sync failed";
|
|
1113
|
+
if (placementWriters?.lastRefusal) { result.error = `${placementWriters.lastRefusal.message} ${failure}`; output = JSON.stringify(result,null,2); }
|
|
1114
|
+
}
|
|
1115
|
+
} catch { /* not a sync result */ } }
|
|
1086
1116
|
stampSync(failure, stop?.cause());
|
|
1087
1117
|
}
|
|
1088
1118
|
if (captureImport) {
|
package/src/commands-table.ts
CHANGED
|
@@ -116,6 +116,9 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
116
116
|
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
117
117
|
"--shards": flag("Migrate the curated home into project and user shards"),
|
|
118
118
|
"--public-prune-view": flag("Enable the forward-only v2 public prune view on an active split"),
|
|
119
|
+
"--sync-placement": flag("Person-only forward-only placement activation on this memory home; no automatic migration or renewal"),
|
|
120
|
+
"--abandon": flag("Person-only: discard an interrupted sync-placement attempt only before either v3 marker is installed"),
|
|
121
|
+
"--writer-root": { description: "Repeatable absolute amem install root in the complete local writer inventory", value: "path" },
|
|
119
122
|
} },
|
|
120
123
|
status: { description: "Report shard migration and pending old-layout appends", options: json },
|
|
121
124
|
doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
|
package/src/index.ts
CHANGED
|
@@ -72,3 +72,9 @@ export * from "./memory/sync-placement-journal-format.ts";
|
|
|
72
72
|
export * from "./memory/sync-placement-projection.ts";
|
|
73
73
|
export * from "./memory/sync-placement-journal.ts";
|
|
74
74
|
export type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
|
|
75
|
+
export { measureSyncPlacementWriters, SyncPlacementWriterError } from "./memory/sync-placement-writers.ts";
|
|
76
|
+
export type { MeasuredSyncPlacementWriterRoot, SyncPlacementWriterMeasurement, SyncPlacementWriterErrorCode } from "./memory/sync-placement-writers.ts";
|
|
77
|
+
export { readSyncPlacementWriterRoots } from "./memory/sync-placement-writer-roots.ts";
|
|
78
|
+
export { readEffectiveSyncPlacementWriters, readEffectiveSyncPlacementWriterRoots } from "./memory/sync-placement-effective-writers.ts";
|
|
79
|
+
export type { EffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
|
|
80
|
+
export { readSetupWriterRoots, SetupWriterRootsError } from "./setup.ts";
|
package/src/memory/commands.ts
CHANGED
|
@@ -206,11 +206,12 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
206
206
|
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
207
207
|
if (json) return JSON.stringify(r, null, 2);
|
|
208
208
|
if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
|
|
209
|
+
const placement = r.placementPublication, placementText = placement ? `; placement ${placement.outcome}, ${placement.exported} exported, ${placement.withheld} withheld this attempt` : "";
|
|
209
210
|
if (r.pruneApplication) {
|
|
210
211
|
const a = r.pruneApplication;
|
|
211
|
-
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
212
|
+
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${placementText}${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
212
213
|
}
|
|
213
|
-
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
214
|
+
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${placementText}${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
214
215
|
}
|
|
215
216
|
case "dedupe": {
|
|
216
217
|
const apply = rest.includes("--apply");
|
|
@@ -80,7 +80,7 @@ function realmBinding(home: string, realms: Realm[]): void {
|
|
|
80
80
|
for (const realm of realms) guard(metadata(realm.profile) === realm.metadata);
|
|
81
81
|
}
|
|
82
82
|
/** Atomic create-once publication, recovering only this exact complete staging pair. */
|
|
83
|
-
function
|
|
83
|
+
export function writeImmutableActivationMetadata(file: string, text: string): void {
|
|
84
84
|
const base = dirname(file); if (!lstatSync(base, { throwIfNoEntry: false })) { mkdirSync(base, { mode: 0o700 }); fsyncDirectory(dirname(base)); } owned(base, true);
|
|
85
85
|
const bytes = Buffer.from(text), temp = file + ".pending-" + createHash("sha256").update(bytes).digest("hex");
|
|
86
86
|
if (lstatSync(file, { throwIfNoEntry: false })) {
|
|
@@ -231,7 +231,7 @@ export async function activatePublicPruneView(options: PublicPruneActivationOpti
|
|
|
231
231
|
const dirs = [...new Set([home, ...intent.realms.map(realm => realm.profile.dir)])].sort();
|
|
232
232
|
for (const dir of dirs) { assertNoLiveReaders(dir, {includeShards:true}); const release = acquireMutation(dir); guard(release); releases.push(release); }
|
|
233
233
|
validateAdvance(intent);
|
|
234
|
-
if (pending)
|
|
234
|
+
if (pending) writeImmutableActivationMetadata(intentFile, canonicalJSON(intent) + "\n");
|
|
235
235
|
if (!pending) {
|
|
236
236
|
guard(readProfileBytes(home) === intent.previousProfile && raw(join(home, "shards.json")) === intent.previousShards && aliases(home) === intent.aliases);
|
|
237
237
|
realmBinding(home, intent.realms);
|
|
@@ -240,14 +240,14 @@ export async function activatePublicPruneView(options: PublicPruneActivationOpti
|
|
|
240
240
|
}) }, home);
|
|
241
241
|
}
|
|
242
242
|
const viewHash = publicPruneViewHash(intent.view);
|
|
243
|
-
|
|
243
|
+
writeImmutableActivationMetadata(join(home, "public-prune-input-sets", viewHash + ".json"), canonicalJSON(intent.view) + "\n");
|
|
244
244
|
readPublicPruneViewForActivation(home, viewHash, intent.generation);
|
|
245
245
|
let profile = readProfileBytes(home), shards = raw(join(home, "shards.json"));
|
|
246
246
|
guard((profile === intent.previousProfile || profile === intent.finalProfile) && (shards === intent.previousShards || shards === intent.finalShards));
|
|
247
247
|
guard(profile !== intent.finalProfile || shards === intent.finalShards);
|
|
248
248
|
verify(intent, profile, shards); for (const realm of intent.realms) syncIndex(nextProfile(realm, intent));
|
|
249
249
|
if (!pending && profile === intent.finalProfile && shards === intent.finalShards) return { mode: options.mode, generation: intent.generation, active: true, alreadyActive: true, realms: intent.realms.length, viewHash, maskBuildCheck: "verified" };
|
|
250
|
-
|
|
250
|
+
writeImmutableActivationMetadata(intentFile, canonicalJSON(intent) + "\n");
|
|
251
251
|
await options.checkpoint?.("after-intent"); verify(intent, profile, shards);
|
|
252
252
|
if (shards !== intent.finalShards) { writeProfileJSON(join(home, "shards.json"), JSON.parse(intent.finalShards)); shards = intent.finalShards; }
|
|
253
253
|
await options.checkpoint?.("after-shards"); verify(intent, profile, shards);
|
package/src/memory/shard-sync.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { syncPlacementMemory } from "./sync-placement-transport.ts";
|
|
2
1
|
import { ShardSyncApplications, ShardSyncApplicationError } from "./shard-sync-applications.ts";
|
|
3
2
|
import { ShardHistoryAuditor, ShardHistoryError, shardHistoryRefusal } from "./shard-sync-history.ts";
|
|
4
3
|
import { validatePlacementProfile, type PlacementProfile } from "./sync-placement-format.ts";
|
|
@@ -446,7 +445,7 @@ async function prepareHolds(targets: readonly Target[], root: string, url: strin
|
|
|
446
445
|
export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void, testOnlyPhase?: (phase: SyncPhase) => void): Promise<SyncResult> {
|
|
447
446
|
const placementRefusal = placementRuntimeRefusal(store.profile.home, store.profile);
|
|
448
447
|
if (placementRefusal) return { ok: false, pushed: false, entries: 0, error: placementRefusal };
|
|
449
|
-
if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return syncPlacementMemory(store, opts);
|
|
448
|
+
if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return (await import("./sync-placement-transport.ts")).syncPlacementMemory(store, opts);
|
|
450
449
|
let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
|
|
451
450
|
const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
|
|
452
451
|
const v2 = store.profile.foreignPruneFormat === "agent-mem.public-prune-witness/v1";
|
|
@@ -88,8 +88,9 @@ export function endLikeSignal(name: string): void {
|
|
|
88
88
|
*/
|
|
89
89
|
function syncRepository(home: string): string | undefined {
|
|
90
90
|
try {
|
|
91
|
-
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
92
91
|
const { selector } = readSelector(home);
|
|
92
|
+
if (selector?.schema === "agent-mem.profile/v3") return join(home,"shard-sync-placement","objects.git","config");
|
|
93
|
+
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
93
94
|
if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
|
|
94
95
|
return join(home, ".git", "config");
|
|
95
96
|
} catch { return undefined; }
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Person coordinator's precommit-only metadata cleanup. No rollback, source rewrite, renewal or package measurement. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, opendirSync, readSync, rmdirSync, unlinkSync, type BigIntStats } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { canonicalJSON } from "./generation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
9
|
+
import { placementEpochHash, SyncPlacementJournalError, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
|
|
10
|
+
import { validatePlacementActivationIntent, readPlacementActivationIntent, clearPlacementActivationIntent, PLACEMENT_ACTIVATION_INTENT_FILE, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
|
|
11
|
+
import { planSyncPlacementWriterRoots, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
|
|
12
|
+
import { syncPlacementWriterId } from "./sync-placement-writers.ts";
|
|
13
|
+
import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
|
|
14
|
+
|
|
15
|
+
export interface PlacementAbandonOptions { mutation: MutationHandle; assertCurrent(): void }
|
|
16
|
+
export class PlacementActivationAbandonError extends Error {
|
|
17
|
+
readonly code: "forward" | "metadata" | "bound";
|
|
18
|
+
constructor(code: "forward" | "metadata" | "bound" = "metadata") {
|
|
19
|
+
super(code === "forward"
|
|
20
|
+
? "activation markers or active fence changed; use forward --resume with a compatible recovery release"
|
|
21
|
+
: code === "bound" ? "activation cleanup metadata exceeds the supported bound; inspect it with a compatible recovery release"
|
|
22
|
+
: "activation cleanup metadata is unsafe, changed or unknown; inspect it with a compatible recovery release before deleting anything");
|
|
23
|
+
this.name = "PlacementActivationAbandonError"; this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
|
|
27
|
+
const bytes = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
|
|
28
|
+
const hash = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
29
|
+
const identity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode].map(String).join(":");
|
|
30
|
+
const stamp = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String).join(":");
|
|
31
|
+
function owned(s: BigIntStats, directory = false, links: readonly bigint[] = [1n], marker = false): void {
|
|
32
|
+
if (s.isSymbolicLink() || s.uid !== BigInt(process.getuid?.() ?? -1)
|
|
33
|
+
|| (directory ? !s.isDirectory() || (s.mode & 0o077n) !== 0n : !s.isFile() || !links.includes(s.nlink) || (marker ? (s.mode & 0o022n) !== 0n : (s.mode & 0o777n) !== 0o600n))) fail();
|
|
34
|
+
}
|
|
35
|
+
interface Member { stat: BigIntStats; expected: Buffer }
|
|
36
|
+
interface State { files: Map<string, Member>; dirs: Map<string, BigIntStats> }
|
|
37
|
+
function readMember(file: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n], marker = false): Member | undefined {
|
|
38
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
|
|
39
|
+
owned(before, false, links, marker); if (before.size > BigInt(expected.length)) fail("bound");
|
|
40
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const opened = fstatSync(fd, { bigint: true }); if (stamp(opened) !== stamp(before)) fail("changed");
|
|
43
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let at = 0;
|
|
44
|
+
while (at < data.length) { const n = readSync(fd, data, at, data.length - at, at); if (!n) break; at += n; }
|
|
45
|
+
if (at !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(file, { bigint: true })) !== stamp(opened)) fail("changed");
|
|
46
|
+
if ((!prefix && at !== expected.length) || !data.subarray(0, at).equals(expected.subarray(0, at))) fail("conflict");
|
|
47
|
+
return { stat: opened, expected };
|
|
48
|
+
} finally { closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
function directory(path: string): BigIntStats | undefined {
|
|
51
|
+
const value = lstatSync(path, { bigint: true, throwIfNoEntry: false }); if (value) owned(value, true); return value;
|
|
52
|
+
}
|
|
53
|
+
function names(path: string, maximum: number): string[] {
|
|
54
|
+
const result: string[] = [], iterator = opendirSync(path); let size = 2;
|
|
55
|
+
try { for (;;) { const entry = iterator.readSync(); if (!entry) break; if (result.length >= maximum) fail("bound");
|
|
56
|
+
size += Buffer.byteLength(JSON.stringify(entry.name)) + 1; if (size > SYNC_SCREEN_MAX_BYTES) fail("bound"); result.push(entry.name);
|
|
57
|
+
} } finally { iterator.closeSync(); } return result.sort();
|
|
58
|
+
}
|
|
59
|
+
/** Called only after person approval and under activation's existing live profile/readers/sync/mutation fences. */
|
|
60
|
+
export function abandonPlacementActivation(homeInput: string, input: PlacementActivationIntent, options: PlacementAbandonOptions): void {
|
|
61
|
+
try {
|
|
62
|
+
const intent = validatePlacementActivationIntent(input), initialHome = lstatSync(homeInput, { bigint: true }); owned(initialHome, true);
|
|
63
|
+
const home = realPath(homeInput); if (home !== intent.home || identity(directory(home)!) !== identity(initialHome)) fail("changed");
|
|
64
|
+
const epochHash = placementEpochHash(intent.epoch), storage = join(home, "sync-placement"), epochs = join(storage, "epochs"), epochDir = join(epochs, epochHash);
|
|
65
|
+
const intentFile = join(home, PLACEMENT_ACTIVATION_INTENT_FILE), intentBytes = bytes(intent), intentTemp = intentFile + ".pending-" + hash(intentBytes);
|
|
66
|
+
const roots = intent.roots.map(root => {
|
|
67
|
+
const writerId = syncPlacementWriterId(root), writer = intent.plan.writers.find(value => value.writerId === writerId); if (!writer) fail("conflict");
|
|
68
|
+
return { root, writerId, version: writer.version, pin: writer.pin };
|
|
69
|
+
});
|
|
70
|
+
const locatorBytes = bytes(planSyncPlacementWriterRoots({ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, roots },
|
|
71
|
+
{ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, writers: intent.plan.writers }));
|
|
72
|
+
const locator = join(home, SYNC_PLACEMENT_WRITER_ROOTS_FILE), locatorTemp = locator + ".pending-" + hash(locatorBytes);
|
|
73
|
+
const expectedEpoch = new Map([["epoch.json", bytes(intent.epoch)], ["activation.json", bytes(intent.plan)]]);
|
|
74
|
+
let clearing = false;
|
|
75
|
+
const proof = () => {
|
|
76
|
+
assertMutationHandle(options.mutation, home); options.assertCurrent(); assertMutationHandle(options.mutation, home);
|
|
77
|
+
if (identity(directory(home)!) !== identity(initialHome)) fail("changed");
|
|
78
|
+
try {
|
|
79
|
+
if (!readMember(join(home, "profile.json"), Buffer.from(intent.previousProfile), false, [1n], true)
|
|
80
|
+
|| !readMember(join(home, "shards.json"), Buffer.from(intent.previousShards), false, [1n], true)) throw new PlacementActivationAbandonError("forward");
|
|
81
|
+
} catch { throw new PlacementActivationAbandonError("forward"); }
|
|
82
|
+
if (directory(storage) && lstatSync(join(storage, "active.json"), { throwIfNoEntry: false })) throw new PlacementActivationAbandonError("forward");
|
|
83
|
+
const actual = readPlacementActivationIntent(home);
|
|
84
|
+
if (actual ? canonicalJSON(actual) !== canonicalJSON(intent) : !clearing) fail("changed");
|
|
85
|
+
};
|
|
86
|
+
const scan = (): State => {
|
|
87
|
+
const files = new Map<string, Member>(), dirs = new Map<string, BigIntStats>([[home, directory(home)!]]);
|
|
88
|
+
for (const path of [storage, epochs, epochDir]) { const stat = directory(path); if (stat) dirs.set(path, stat); }
|
|
89
|
+
if (dirs.has(storage)) for (const name of names(storage, 2)) if (name !== "epochs") fail("corrupt");
|
|
90
|
+
const knownTemps = new Set([intentTemp, locatorTemp]);
|
|
91
|
+
for (const name of names(home, SYNC_SCREEN_MAX_ROWS)) if (name.startsWith(PLACEMENT_ACTIVATION_INTENT_FILE + ".pending-") || name.startsWith(SYNC_PLACEMENT_WRITER_ROOTS_FILE + ".pending-"))
|
|
92
|
+
if (!knownTemps.has(join(home, name))) fail("corrupt");
|
|
93
|
+
const add = (path: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n]) => { const value = readMember(path, expected, prefix, links); if (value) files.set(path, value); return value; };
|
|
94
|
+
const final = add(locator, locatorBytes, false, [1n, 2n]), temp = add(locatorTemp, locatorBytes, true, [1n, 2n]);
|
|
95
|
+
if (final?.stat.nlink === 2n || temp?.stat.nlink === 2n) {
|
|
96
|
+
if (!final || !temp || final.stat.size !== BigInt(locatorBytes.length) || temp.stat.size !== BigInt(locatorBytes.length) || stamp(final.stat) !== stamp(temp.stat)) fail("corrupt");
|
|
97
|
+
}
|
|
98
|
+
if (dirs.has(epochDir)) for (const name of names(epochDir, 3)) { const expected = expectedEpoch.get(name); if (!expected) fail("corrupt"); add(join(epochDir, name), expected, true); }
|
|
99
|
+
const current = add(intentFile, intentBytes, false, [1n, 2n]), companion = add(intentTemp, intentBytes, false, [1n, 2n]);
|
|
100
|
+
if (current?.stat.nlink === 2n || companion) {
|
|
101
|
+
if (!current || !companion || current.stat.nlink !== 2n || companion.stat.nlink !== 2n || stamp(current.stat) !== stamp(companion.stat)) fail("corrupt");
|
|
102
|
+
}
|
|
103
|
+
if (!current && !clearing) fail("changed"); return { files, dirs };
|
|
104
|
+
};
|
|
105
|
+
proof(); let state = scan();
|
|
106
|
+
const check = () => {
|
|
107
|
+
proof(); const next = scan();
|
|
108
|
+
const expectedFiles = [...state.files.keys()].filter(path => !clearing || path !== intentFile).sort(), foundFiles = [...next.files.keys()].filter(path => !clearing || path !== intentFile).sort();
|
|
109
|
+
if (canonicalJSON(expectedFiles) !== canonicalJSON(foundFiles) || canonicalJSON([...state.dirs.keys()].sort()) !== canonicalJSON([...next.dirs.keys()].sort())) fail("changed");
|
|
110
|
+
for (const [path, before] of state.files) { const after = next.files.get(path); if (clearing && path === intentFile && !after) continue; if (!after || stamp(before.stat) !== stamp(after.stat)) fail("changed"); }
|
|
111
|
+
for (const [path, before] of state.dirs) if (identity(before) !== identity(next.dirs.get(path)!)) fail("changed");
|
|
112
|
+
};
|
|
113
|
+
const sync = (path: string) => {
|
|
114
|
+
check(); const expected = state.dirs.get(path); if (!expected) fail("changed"); const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
115
|
+
try { if (identity(fstatSync(fd, { bigint: true })) !== identity(expected)) fail("changed"); fsyncSync(fd); } finally { closeSync(fd); } check();
|
|
116
|
+
};
|
|
117
|
+
const remove = (path: string) => {
|
|
118
|
+
if (!state.files.has(path)) return; check(); const before = state.files.get(path)!; unlinkSync(path); state.files.delete(path);
|
|
119
|
+
for (const [peer, item] of state.files) if (item.stat.dev === before.stat.dev && item.stat.ino === before.stat.ino) {
|
|
120
|
+
const now = lstatSync(peer, { bigint: true }); owned(now);
|
|
121
|
+
if (before.stat.nlink !== 2n || now.nlink !== 1n || now.dev !== item.stat.dev || now.ino !== item.stat.ino || now.uid !== item.stat.uid || now.mode !== item.stat.mode || now.size !== item.stat.size || now.mtimeNs !== item.stat.mtimeNs) fail("changed");
|
|
122
|
+
item.stat = now;
|
|
123
|
+
}
|
|
124
|
+
sync(dirname(path));
|
|
125
|
+
};
|
|
126
|
+
remove(locatorTemp); remove(locator);
|
|
127
|
+
for (const name of ["activation.json", "epoch.json"]) remove(join(epochDir, name));
|
|
128
|
+
if (state.dirs.has(epochDir)) { check(); if (names(epochDir, 1).length) fail("changed"); rmdirSync(epochDir); state.dirs.delete(epochDir); sync(epochs); }
|
|
129
|
+
remove(intentTemp);
|
|
130
|
+
// A prior owner may have unlinked a member or removed this epoch before its directory fsync.
|
|
131
|
+
// Reassert every retained parent before the intent can acknowledge that cleanup as complete.
|
|
132
|
+
for (const path of [epochs, storage, home]) if (state.dirs.has(path)) sync(path);
|
|
133
|
+
check(); clearing = true;
|
|
134
|
+
clearPlacementActivationIntent(home, intent, { mutation: options.mutation, assertCurrent: check }); state.files.delete(intentFile); check();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementActivationAbandonError) throw error;
|
|
137
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
|
|
138
|
+
const value = Object.getOwnPropertyDescriptor(error, "code"); if (value && "value" in value && ["invalid", "corrupt", "bound", "conflict", "pending", "changed", "authorization", "policy", "local", "busy"].includes(value.value)) throw new PlacementActivationAbandonError(value.value === "bound" ? "bound" : "metadata");
|
|
139
|
+
}
|
|
140
|
+
throw new PlacementActivationAbandonError();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared person-migration fences. No serialized value grants ownership. */
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { withProfileLock } from "./generation.ts";
|
|
5
|
+
import { acquireMutation, type MutationHandle } from "./mutation.ts";
|
|
6
|
+
import { generationSyncLock } from "./generation-sync.ts";
|
|
7
|
+
import { assertNoLiveReaders } from "./reader-lease.ts";
|
|
8
|
+
import { createPlacementActivationOwner } from "./sync-placement-activation-proof.ts";
|
|
9
|
+
export class PlacementInventoryFenceError extends Error{
|
|
10
|
+
constructor(readonly causeClass:"readers"|"busy"){super(`Sync placement migration is ${causeClass}; close cooperating readers/writers, then run amem migrate --sync-placement --dry-run before retrying.`);this.name="PlacementInventoryFenceError";}
|
|
11
|
+
}
|
|
12
|
+
function fail(cause:"readers"|"busy"):never{throw new PlacementInventoryFenceError(cause);}
|
|
13
|
+
export async function withPlacementActivationFences<T>(home:string,directories:readonly string[],work:(mutation:MutationHandle,owner:ReturnType<typeof createPlacementActivationOwner>)=>Promise<T>):Promise<T> {
|
|
14
|
+
return withProfileLock(home,async()=>{
|
|
15
|
+
const releases:(()=>void)[]=[];
|
|
16
|
+
try {
|
|
17
|
+
const transports=["shard-sync","shard-sync-placement"].map(name=>join(home,name)).filter(dir=>lstatSync(dir,{throwIfNoEntry:false})!==undefined);
|
|
18
|
+
const dirs=[...new Set([home,...directories,...transports])].sort();let mutation:MutationHandle|undefined;
|
|
19
|
+
for(const dir of dirs){
|
|
20
|
+
try{assertNoLiveReaders(dir,{includeShards:true});}catch{fail("readers");}
|
|
21
|
+
const handle=acquireMutation(dir);if(!handle)fail("busy");releases.push(handle);if(dir===home)mutation=handle;
|
|
22
|
+
}
|
|
23
|
+
if(!mutation)fail("busy");
|
|
24
|
+
for(const dir of [home,...transports]){const release=generationSyncLock(dir);if(!release)fail("busy");releases.push(release);}
|
|
25
|
+
const owner=createPlacementActivationOwner(home,mutation);return await work(mutation,owner);
|
|
26
|
+
}finally{for(const release of releases.reverse())release();}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Internal activation-owner proof. No serialized object or boolean grants lock bypass authority. */
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
5
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
6
|
+
import { processSignature, realPath } from "./platform.ts";
|
|
7
|
+
import type { PlacementActivationMarkerSnapshot } from "./sync-placement-capture.ts";
|
|
8
|
+
import { readPlacementActivationIntent, validatePlacementActivationIntent, placementActivationFinalMarkers, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
|
|
9
|
+
|
|
10
|
+
declare const ownerBrand: unique symbol, captureBrand: unique symbol;
|
|
11
|
+
export interface PlacementActivationOwner { readonly [ownerBrand]: true }
|
|
12
|
+
export interface PlacementActivationCaptureProof { readonly [captureBrand]: true }
|
|
13
|
+
interface Owner { home: string; mutation: MutationHandle; root: BigIntStats; lock: string; lockStamp: string }
|
|
14
|
+
const owners = new WeakMap<PlacementActivationOwner, Owner>();
|
|
15
|
+
const captures = new WeakMap<PlacementActivationCaptureProof, { owner: PlacementActivationOwner; intent: PlacementActivationIntent }>();
|
|
16
|
+
const stamp = (s: BigIntStats) => [s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
|
|
17
|
+
function fail(): never { throw Error("Sync placement activation ownership or retained evidence changed; close cooperating writers and run amem migrate --sync-placement --resume after person inspection."); }
|
|
18
|
+
function lockBytes(home: string): { text: string; stamp: string } {
|
|
19
|
+
const file = join(home,"profile.lock"), before = lstatSync(file,{bigint:true});
|
|
20
|
+
if (!before.isFile() || before.isSymbolicLink() || before.uid !== BigInt(process.getuid?.() ?? -1) || (before.mode & 0o077n) !== 0n || before.nlink !== 1n || before.size < 1n || before.size > 4096n) fail();
|
|
21
|
+
const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
22
|
+
try {
|
|
23
|
+
const opened = fstatSync(fd,{bigint:true}); if (stamp(opened) !== stamp(before)) fail();
|
|
24
|
+
const out = Buffer.alloc(Number(opened.size)); let at = 0;
|
|
25
|
+
while (at < out.length) { const n = readSync(fd,out,at,out.length-at,at); if (!n) fail(); at += n; }
|
|
26
|
+
if (stamp(fstatSync(fd,{bigint:true})) !== stamp(opened) || stamp(lstatSync(file,{bigint:true})) !== stamp(opened)) fail();
|
|
27
|
+
return { text: new TextDecoder("utf-8",{fatal:true}).decode(out), stamp: stamp(opened) };
|
|
28
|
+
} finally { closeSync(fd); }
|
|
29
|
+
}
|
|
30
|
+
export function createPlacementActivationOwner(homeInput: string, mutation: MutationHandle): PlacementActivationOwner {
|
|
31
|
+
try {
|
|
32
|
+
const home = realPath(homeInput); assertMutationHandle(mutation,home);
|
|
33
|
+
const root = lstatSync(home,{bigint:true}), lock = lockBytes(home), value = JSON.parse(lock.text) as Record<string,unknown>;
|
|
34
|
+
if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== BigInt(process.getuid?.() ?? -1) || (root.mode & 0o077n) !== 0n
|
|
35
|
+
|| Object.keys(value).sort().join() !== "pid,signature,token" || value.pid !== process.pid || value.signature !== processSignature(process.pid)
|
|
36
|
+
|| typeof value.signature !== "string" || !value.signature || typeof value.token !== "string" || !/^[a-f0-9]{32}$/.test(value.token)) fail();
|
|
37
|
+
const token = Object.freeze({}) as PlacementActivationOwner;
|
|
38
|
+
owners.set(token,{home,mutation,root,lock:lock.text,lockStamp:lock.stamp}); return token;
|
|
39
|
+
} catch { return fail(); }
|
|
40
|
+
}
|
|
41
|
+
export function assertPlacementActivationOwner(token: PlacementActivationOwner, home?: string): void {
|
|
42
|
+
try {
|
|
43
|
+
const owner = owners.get(token); if (!owner) fail(); assertMutationHandle(owner.mutation,owner.home);
|
|
44
|
+
if(home!==undefined&&realPath(home)!==owner.home)fail();
|
|
45
|
+
const root = lstatSync(owner.home,{bigint:true}), lock = lockBytes(owner.home);
|
|
46
|
+
if (!root.isDirectory() || root.isSymbolicLink() || root.dev !== owner.root.dev || root.ino !== owner.root.ino || root.uid !== owner.root.uid || (root.mode & 0o077n) !== 0n
|
|
47
|
+
|| lock.text !== owner.lock || lock.stamp !== owner.lockStamp) fail();
|
|
48
|
+
assertMutationHandle(owner.mutation,owner.home);
|
|
49
|
+
} catch { fail(); }
|
|
50
|
+
}
|
|
51
|
+
export function createPlacementActivationCaptureProof(owner: PlacementActivationOwner, input: PlacementActivationIntent): PlacementActivationCaptureProof {
|
|
52
|
+
assertPlacementActivationOwner(owner);
|
|
53
|
+
const state = owners.get(owner)!;
|
|
54
|
+
const intent = validatePlacementActivationIntent(input), actual = readPlacementActivationIntent(state.home);
|
|
55
|
+
if (!intent || !actual || canonicalJSON(intent) !== canonicalJSON(actual)
|
|
56
|
+
|| actual.home !== state.home || actual.epoch.homeHash !== hashJSON([state.home,String(state.root.dev),String(state.root.ino),String(state.root.uid)])) fail();
|
|
57
|
+
const token = Object.freeze({}) as PlacementActivationCaptureProof; captures.set(token,{owner,intent:actual}); return token;
|
|
58
|
+
}
|
|
59
|
+
export function activationSourceMarkers(token: PlacementActivationCaptureProof, home: string, markers: { profileText: string; shardsText: string; profileStamp: readonly string[]; shardsStamp: readonly string[] }): PlacementActivationMarkerSnapshot {
|
|
60
|
+
const scope = captures.get(token); if (!scope) fail(); assertPlacementActivationOwner(scope.owner);
|
|
61
|
+
const state = owners.get(scope.owner)!, actual = readPlacementActivationIntent(home), intent = scope.intent;
|
|
62
|
+
if (home !== state.home || !actual || canonicalJSON(actual) !== canonicalJSON(intent)) fail();
|
|
63
|
+
const { profileText, shardsText } = markers;
|
|
64
|
+
const final = placementActivationFinalMarkers(intent), oldProfile = profileText === intent.previousProfile, oldShards = shardsText === intent.previousShards;
|
|
65
|
+
if ((!oldProfile && profileText !== final.profile) || (!oldShards && shardsText !== final.shards) || !oldProfile && shardsText !== final.shards) fail();
|
|
66
|
+
if (oldProfile && canonicalJSON(markers.profileStamp) !== canonicalJSON(intent.profileStamp) || oldShards && canonicalJSON(markers.shardsStamp) !== canonicalJSON(intent.shardsStamp)) fail();
|
|
67
|
+
return { profileText:intent.previousProfile,shardsText:intent.previousShards,profileStamp:[...intent.profileStamp],shardsStamp:[...intent.shardsStamp] };
|
|
68
|
+
}
|