@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/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.1. 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
"@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
|
|
24
24
|
"@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0",
|
|
25
25
|
"@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0",
|
|
26
|
-
"@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0"
|
|
26
|
+
"@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0",
|
|
27
|
+
"bun-types-host-1-4-2": "npm:bun-types@1.4.2",
|
|
28
|
+
"@staix/agent-mem-v0220": "npm:@staix/agent-mem@0.22.0",
|
|
29
|
+
"@types/bun-host-1-4-2": "npm:@types/bun@1.4.2"
|
|
27
30
|
},
|
|
28
31
|
"exports": {
|
|
29
32
|
".": "./src/index.ts",
|
package/src/cli-agent-reads.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { noteDisplayActor } from "./memory/cli-attribution.ts";
|
|
2
2
|
import { singleLine } from "./memory/style.ts";
|
|
3
3
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
4
|
+
import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
|
|
4
5
|
import { memoryHome } from "./memory/home.ts";
|
|
5
6
|
import type { ParsedCommand } from "./commands-table.ts";
|
|
6
7
|
|
|
@@ -111,7 +112,8 @@ export async function agentCliRead(
|
|
|
111
112
|
return shown;
|
|
112
113
|
}
|
|
113
114
|
return output;
|
|
114
|
-
} catch {
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if(safeProfileLockMessage(error))throw error;
|
|
115
117
|
throw new Error(UNAVAILABLE);
|
|
116
118
|
} finally {
|
|
117
119
|
try { memory?.close(); } catch { /* close failures do not expose partial reads */ }
|
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";
|
|
@@ -12,6 +12,7 @@ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
|
12
12
|
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
13
13
|
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
14
14
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
15
|
+
import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
|
|
15
16
|
import { MCP_TOOLS, serveMcp, writeConfirmed } from "./mcp.ts";
|
|
16
17
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
17
18
|
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
@@ -20,6 +21,7 @@ import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
|
20
21
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
21
22
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
22
23
|
import { isAbsolute, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
23
25
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
24
26
|
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
25
27
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
@@ -313,6 +315,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
313
315
|
}
|
|
314
316
|
// Data-free errors (decided before any store or file is opened) keep their own message.
|
|
315
317
|
if (marked && error instanceof AgentUsageError) throw error;
|
|
318
|
+
const recovery=marked?safeProfileLockMessage(error):undefined;if(recovery)throw new Error(recovery);
|
|
316
319
|
if (marked) {
|
|
317
320
|
// parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
|
|
318
321
|
let usage: Error | undefined, parsed: ReturnType<typeof parseCommand> | undefined;
|
|
@@ -378,6 +381,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
378
381
|
}
|
|
379
382
|
const { definition, options, rawArgs: args } = parsed;
|
|
380
383
|
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
384
|
+
if (parsed.path[0] === "migrate" && options["--sync-placement"] === true) {
|
|
385
|
+
const modes = ["--dry-run","--apply","--resume","--abandon","--deactivate"].filter(key=>options[key]===true);
|
|
386
|
+
if (modes.length>1 || parsed.positionals.length || ["--shards","--public-prune-view","--generation","--deactivate"].some(key=>options[key]!==undefined))
|
|
387
|
+
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.");
|
|
388
|
+
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.");
|
|
389
|
+
} else if (parsed.path[0] === "migrate" && (options["--writer-root"] !== undefined||options["--abandon"]!==undefined)) {
|
|
390
|
+
throw new AgentUsageError("--writer-root and --abandon apply only to --sync-placement; run amem migrate --sync-placement --dry-run to review the inventory.");
|
|
391
|
+
}
|
|
381
392
|
if (parsed.path[0] === "setup") {
|
|
382
393
|
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true
|
|
383
394
|
|| 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 +483,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
472
483
|
}
|
|
473
484
|
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
485
|
if (sub === "migrate") {
|
|
475
|
-
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
486
|
+
const modes = ["--apply", "--dry-run", "--resume", "--abandon", "--deactivate"].filter(key => options[key] === true);
|
|
476
487
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
477
488
|
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
489
|
+
if (options["--sync-placement"] === true) {
|
|
490
|
+
const { activateSyncPlacement } = await import("./memory/sync-placement-activation.ts");
|
|
491
|
+
const result = await activateSyncPlacement({ memoryDir:memoryHome(),mode:options["--abandon"]?"abandon":mode as "dry-run"|"apply"|"resume",
|
|
492
|
+
writerRoots:repeated(argv,"--writer-root"),ownRoot:fileURLToPath(new URL("..",import.meta.url)),
|
|
493
|
+
authorize:()=>cliAttribution()?.actor==="person" });
|
|
494
|
+
if (options["--json"] === true) return JSON.stringify(result,null,2);
|
|
495
|
+
// Metadata only: the source reader and locator are never serialized into this receipt.
|
|
496
|
+
return `sync placement: ${result.mode}; ${result.active?"active":"inactive"}; home-wide, forward-only (no deactivate)\n${JSON.stringify(result,null,2)}`;
|
|
497
|
+
}
|
|
478
498
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
479
499
|
if (options["--public-prune-view"] === true) {
|
|
480
500
|
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 +1082,21 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1062
1082
|
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
1063
1083
|
const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
|
|
1064
1084
|
let output: string;
|
|
1085
|
+
let placementWriters: import("./sync-placement-writer-context.ts").PlacementWriterContext | undefined;
|
|
1065
1086
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
1066
1087
|
try {
|
|
1088
|
+
if (sub === "sync" && (readPlacementRuntimeMarkers(syncHome).profile as { publicationFormat?:string } | undefined)?.publicationFormat === "agent-mem.sync-placement/v1") {
|
|
1089
|
+
const { activePlacementWriterContext } = await import("./sync-placement-writer-context.ts");
|
|
1090
|
+
placementWriters = activePlacementWriterContext(syncHome,fileURLToPath(new URL("..",import.meta.url)));
|
|
1091
|
+
}
|
|
1067
1092
|
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
|
|
1093
|
+
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
1094
|
onBridgeStatus: status => { receipt.bridgeStatus = status; },
|
|
1070
1095
|
// #188: ids-only listing of nearby machine-local rows; policy-refused rows are neither listed nor counted.
|
|
1071
1096
|
forgetNearby: note => forgetNearby({ base: memoryHome(), project: note.project, text: note.text, title: note.title, screen: value => configured.screenStandalone(value) }) }));
|
|
1072
1097
|
} catch (error) {
|
|
1073
1098
|
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
1099
|
+
if (sub === "sync" && placementWriters?.lastRefusal) throw Error(`${placementWriters.lastRefusal.message} ${(error as Error).message}`);
|
|
1074
1100
|
throw error;
|
|
1075
1101
|
}
|
|
1076
1102
|
finally {
|
|
@@ -1082,7 +1108,13 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1082
1108
|
if (sub === "sync") {
|
|
1083
1109
|
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
1084
1110
|
let failure: string | undefined;
|
|
1085
|
-
if (options["--json"]) { try {
|
|
1111
|
+
if (options["--json"]) { try {
|
|
1112
|
+
const result = JSON.parse(output) as { ok?: unknown; error?: unknown };
|
|
1113
|
+
if (result.ok === false) {
|
|
1114
|
+
failure = typeof result.error === "string" ? result.error : "sync failed";
|
|
1115
|
+
if (placementWriters?.lastRefusal) { result.error = `${placementWriters.lastRefusal.message} ${failure}`; output = JSON.stringify(result,null,2); }
|
|
1116
|
+
}
|
|
1117
|
+
} catch { /* not a sync result */ } }
|
|
1086
1118
|
stampSync(failure, stop?.cause());
|
|
1087
1119
|
}
|
|
1088
1120
|
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");
|
package/src/memory/generation.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
|
|
|
9
9
|
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
10
10
|
import { shardDir, userShardDir } from "./shard.ts";
|
|
11
11
|
import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
|
|
12
|
+
import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
|
|
12
13
|
|
|
13
14
|
export interface BoundProfile {
|
|
14
15
|
kind: "shared-v1" | "generation";
|
|
@@ -96,12 +97,14 @@ export function readSelector(memoryDir: string, absoluteDeadline?: number): { se
|
|
|
96
97
|
const current = readProfileJSON(join(memoryDir, "profile.json"));
|
|
97
98
|
if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
|
|
98
99
|
} catch (error) {
|
|
99
|
-
if (error
|
|
100
|
-
if (error instanceof
|
|
100
|
+
if (safeProfileLockMessage(error)) throw error;
|
|
101
|
+
if (error instanceof SyncPlacementRuntimeError) throw profileLockRecoveryError(memoryDir) ?? error;
|
|
102
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw profileLockRecoveryError(memoryDir) ?? error; // A spent wait budget is never restarted.
|
|
101
103
|
if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
|
|
102
|
-
waitForPublicPruneWriter(memoryDir, deadline);
|
|
104
|
+
try { waitForPublicPruneWriter(memoryDir, deadline); } catch (failure) { throw profileLockRecoveryError(memoryDir) ?? failure; }
|
|
103
105
|
}
|
|
104
106
|
}
|
|
107
|
+
const diagnostic=profileLockRecoveryError(memoryDir);if(diagnostic)throw diagnostic;
|
|
105
108
|
return unavailable();
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Read-only error hints, never admission, recovery or proof that an owner is dead. */
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { realPath } from "./platform.ts";
|
|
5
|
+
|
|
6
|
+
const messages={
|
|
7
|
+
shards:"An interrupted or still-running person shard migration retains profile.lock; have the person finish it or run amem migrate --shards --resume.",
|
|
8
|
+
prune:"An interrupted or still-running person public-prune view migration retains profile.lock; have the person finish it or run amem migrate --public-prune-view --resume.",
|
|
9
|
+
activation:"An interrupted or still-running person sync-placement activation retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only while both markers retain their original v2 bytes.",
|
|
10
|
+
renewal:"An interrupted or still-running person writer-inventory renewal retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only before the first commit-log byte.",
|
|
11
|
+
staging:"An interrupted or still-running person writer-inventory renewal retains profile.lock and unpublished intent staging; have the person run amem migrate --sync-placement --abandon, then amem migrate --sync-placement --dry-run before another apply.",
|
|
12
|
+
} as const;
|
|
13
|
+
type Family=keyof typeof messages;
|
|
14
|
+
const recognised=new WeakMap<object,string>();
|
|
15
|
+
/** A privacy-safe exception carries only one of our fixed messages, never a caught message or path. */
|
|
16
|
+
export function rememberProfileLockError(error:object,text:string):void {
|
|
17
|
+
if((Object.values(messages) as readonly string[]).includes(text))recognised.set(error,text);
|
|
18
|
+
}
|
|
19
|
+
export function safeProfileLockMessage(error:unknown):string|undefined {
|
|
20
|
+
return error!==null&&(typeof error==="object"||typeof error==="function")?recognised.get(error):undefined;
|
|
21
|
+
}
|
|
22
|
+
const same=(a:BigIntStats,b:BigIntStats)=>a.dev===b.dev&&a.ino===b.ino&&a.uid===b.uid&&a.mode===b.mode&&a.nlink===b.nlink&&a.size===b.size&&a.mtimeNs===b.mtimeNs&&a.ctimeNs===b.ctimeNs;
|
|
23
|
+
function owned(s:BigIntStats,limit:number,privateFile=true):void {
|
|
24
|
+
if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||s.nlink!==1n||(s.mode&(privateFile?0o077n:0o022n))!==0n||s.size>BigInt(limit))throw Error();
|
|
25
|
+
}
|
|
26
|
+
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==="object"&&!Array.isArray(v);
|
|
27
|
+
const hex=(v:unknown,width:number)=>typeof v==="string"&&v.length===width&&!/[^a-f0-9]/.test(v);
|
|
28
|
+
/** Only called after an existing refusal. Absent lock means no extra marker/intent read at all.
|
|
29
|
+
* ponytail: fixed metadata limits plus20000 home names/256 staging files; a reviewed bounded descriptor inventory is needed to grow this hint. */
|
|
30
|
+
export function profileLockRefusal(home:string):string|undefined {
|
|
31
|
+
try{
|
|
32
|
+
const checks:(()=>void)[]=[];
|
|
33
|
+
const read=(name:string,limit:number,privateFile=true):Record<string,unknown>|undefined=>{
|
|
34
|
+
const file=join(home,name),before=lstatSync(file,{bigint:true,throwIfNoEntry:false});
|
|
35
|
+
if(!before){checks.push(()=>{if(lstatSync(file,{throwIfNoEntry:false}))throw Error();});return;}
|
|
36
|
+
owned(before,limit,privateFile);const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
37
|
+
try{
|
|
38
|
+
const s=fstatSync(fd,{bigint:true});if(!same(before,s))throw Error();
|
|
39
|
+
const bytes=Buffer.alloc(Number(s.size)+1);let count=0;
|
|
40
|
+
while(count<bytes.length){const n=readSync(fd,bytes,count,bytes.length-count,count);if(!n)break;count+=n;}
|
|
41
|
+
if(count!==Number(s.size)||!same(s,fstatSync(fd,{bigint:true}))||!same(s,lstatSync(file,{bigint:true})))throw Error();
|
|
42
|
+
const value:unknown=JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(bytes.subarray(0,count)));if(!object(value))throw Error();
|
|
43
|
+
checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});return value;
|
|
44
|
+
}finally{closeSync(fd);}
|
|
45
|
+
};
|
|
46
|
+
const owner=read("profile.lock",4096);if(!owner)return;
|
|
47
|
+
if(Object.keys(owner).sort().join()!=="pid,signature,token"||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<=0||!hex(owner.signature,64)||!hex(owner.token,32))return;
|
|
48
|
+
const canonical=realPath(home),profile=read("profile.json",262144,false),shards=read("shards.json",262144,false);
|
|
49
|
+
const prune=read("public-prune-view.intent.json",1048576),activation=read("sync-placement-activation.json",1048576),renewal=read("sync-placement-writer-renewal.json",1048576);
|
|
50
|
+
const families=new Set<Family>();
|
|
51
|
+
if(prune){if(prune.schema!=="agent-mem.public-prune-activation/v1"&&prune.schema!=="agent-mem.public-prune-advance/v1"||prune.home!==canonical||!hex(prune.generation,32))return;families.add("prune");}
|
|
52
|
+
if(activation){if(activation.schema!=="agent-mem.sync-placement-activation/v1"||activation.home!==canonical||!object(activation.epoch)||!hex(activation.epoch.generation,32))return;families.add("activation");}
|
|
53
|
+
if(renewal){if(renewal.schema!=="agent-mem.sync-placement-writer-renewal/v1"||renewal.home!==canonical||!object(renewal.revision)||renewal.revision.schema!=="agent-mem.sync-placement-writer-revision/v1"||!hex(renewal.revision.generation,32)||!hex(renewal.revision.epochHash,64))return;families.add("renewal");}
|
|
54
|
+
if(shards?.schema==="agent-mem.shards/v1"&&shards.phase==="captured"&&hex(shards.generation,32))families.add("shards");
|
|
55
|
+
if(!families.size&&profile?.schema==="agent-mem.profile/v3"&&shards?.schema==="agent-mem.shards/v3"&&shards.phase==="active"&&hex(profile.generation,32)&&shards.generation===profile.generation&&hex(profile.placementHead,64)&&profile.placementHead===shards.placementHead){
|
|
56
|
+
const reader=opendirSync(home);let names=0,staging=0,nameBytes=0;
|
|
57
|
+
try{for(;;){const entry=reader.readSync();if(!entry)break;if(++names>20000||(nameBytes+=Buffer.byteLength(entry.name))>8*1024*1024)throw Error();
|
|
58
|
+
const prefix="sync-placement-writer-renewal.json.pending-";if(!entry.name.startsWith(prefix)||!hex(entry.name.slice(prefix.length),64))continue;
|
|
59
|
+
if(++staging>256)throw Error();const file=join(home,entry.name),s=lstatSync(file,{bigint:true});owned(s,1048576);checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});
|
|
60
|
+
}}finally{reader.closeSync();}
|
|
61
|
+
if(staging)families.add("staging");
|
|
62
|
+
}
|
|
63
|
+
for(const check of checks)check();if(families.size!==1)return;
|
|
64
|
+
return messages[[...families][0]!];
|
|
65
|
+
}catch{return undefined;}
|
|
66
|
+
}
|
|
67
|
+
export function profileLockRecoveryError(home:string):Error|undefined {
|
|
68
|
+
const text=profileLockRefusal(home);if(!text)return;const error=new Error(text);error.name="ProfileLockMigrationError";rememberProfileLockError(error,text);return error;
|
|
69
|
+
}
|
|
@@ -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);
|
|
@@ -4,6 +4,7 @@ import { readProfileJSON, writeProfileJSON } from "./generation.ts";
|
|
|
4
4
|
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
5
|
import { processLiveness, processSignature } from "./platform.ts";
|
|
6
6
|
import { waitForPublicPruneWriter } from "./public-prune-view.ts";
|
|
7
|
+
import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
|
|
7
8
|
|
|
8
9
|
const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
|
|
9
10
|
const RENEW_MS = 25_000;
|
|
@@ -52,8 +53,8 @@ function admitReader(memoryDir: string): void {
|
|
|
52
53
|
// Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
|
|
53
54
|
try {
|
|
54
55
|
waitForPublicPruneWriter(memoryDir);
|
|
55
|
-
if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
|
|
56
|
-
} catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
|
|
56
|
+
if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
|
|
57
|
+
} catch (error) { if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** One lease per process/home/namespace, reference-counted across handles.
|
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";
|
package/src/memory/shard.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, write
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
10
10
|
import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
|
|
11
|
+
import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* #15 T1: the per-project shard layout under the user home. One shard per project key at `projects/<key-hash>/`;
|
|
@@ -126,6 +127,7 @@ function shardSnapshot(memoryDir: string, deadline: number) {
|
|
|
126
127
|
let selector: ReturnType<typeof readSelector>;
|
|
127
128
|
try { selector = readSelector(memoryDir, deadline); }
|
|
128
129
|
catch (error) {
|
|
130
|
+
if(safeProfileLockMessage(error))throw error;
|
|
129
131
|
if (error instanceof SyncPlacementRuntimeError) throw error;
|
|
130
132
|
// The writer's spent wait budget or unavailable proof is never restarted here.
|
|
131
133
|
if (error instanceof Error && error.message === "public prune view unavailable") throw error;
|
|
@@ -145,8 +147,10 @@ function shardSnapshot(memoryDir: string, deadline: number) {
|
|
|
145
147
|
throw new Error("curated shard selector changed");
|
|
146
148
|
}
|
|
147
149
|
export function readShardedSelection(memoryDir: string): ShardedSelection {
|
|
150
|
+
try {
|
|
148
151
|
const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
149
152
|
return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
|
|
153
|
+
}catch(error){if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir)??error;}
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
/** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
|
|
@@ -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
|
+
}
|