@staix/agent-mem 0.19.0 → 0.20.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 +4 -4
- package/package.json +1 -1
- package/src/cli.ts +55 -2
- package/src/commands-table.ts +5 -0
- package/src/hook-contract.ts +5 -2
- package/src/hook-rss-wire.ts +2 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +271 -55
- package/src/hook-transcript.ts +22 -6
- package/src/hooks.ts +121 -60
- package/src/memory/doctor.ts +32 -2
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-view.ts +6 -2
- package/src/session-brief-worker.ts +105 -54
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.20.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -292,7 +292,7 @@ amem capacity apply --split [--confirm] # each shard's share of the global cap
|
|
|
292
292
|
- `capacity` also prints a per-shard split of the global disk-share budget. Each shard's weight halves every 14 days since its last capture, so recently used projects keep more capture and cold ones shrink first; the split never exceeds the budget, gives every shard at least 1 MB, keeps every shard at what it holds whenever the total fits, never proposes a retention shorter than a shard's idle time plus 30 days, and says when the budget forces a shard below what it holds. When the budget holds fewer whole MB than there are shards, no split is proposed and `capacity` says why (`splitRefused`). It proposes capture bounds only, and never evicts curated notes, goals or work lists. `capacity apply --split` previews, then `--confirm` writes exactly the previewed values to each shard's `config.json`, and only if the preview is under an hour old and the set of shards and every shard's config are unchanged since then (a `--disk-share` given only to `--confirm` changes nothing that is written).
|
|
293
293
|
- amem lists vault candidates and never writes the vault.
|
|
294
294
|
|
|
295
|
-
Resource budgets are acceptance criteria, measured by `bun scripts/measure-budget.ts` in CI on Ubuntu and macOS: a hook-like append (including the spool-and-exit path under contention) must stay under 150 ms p95 wall time and 100 MB
|
|
295
|
+
Resource budgets are acceptance criteria, measured by `bun scripts/measure-budget.ts` in CI on Ubuntu and macOS: a hook-like append (including the spool-and-exit path under contention) must stay under 150 ms p95 wall time and 100 MB RSS (R2), the search path under 100 MB idle and 200 MB peak RSS (R3), and no amem process may remain after exit (R1). For the actual-hook scripts (`measure-hooks-budget.ts`, `measure-transcript-budget.ts`, `measure-full-spool-budget.ts`) R2 is three gates: a 150 MB ceiling on every invocation, a regression gate of +10% over a committed per-runner p95 baseline (`docs/bench/r2-baseline.json`), and a 300 ms per-invocation maximum on the handler's own wall, with the outer CLI wall reported only (the transcript harness reports it until #226); see [docs/contracts.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/contracts.md) (#228). The script prints a machine-readable verdict per budget and its CI log is also the FTS5 availability evidence for Linux (R5). The AC4 retrieval re-check of the frozen P0 query set is in [docs/p1-ac4-recheck.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/p1-ac4-recheck.md).
|
|
296
296
|
|
|
297
297
|
## Claude Code hooks and SessionStart context
|
|
298
298
|
|
|
@@ -314,13 +314,13 @@ Before optional work starts, the parent screens the complete hook input and boun
|
|
|
314
314
|
|
|
315
315
|
Pending capture has an ordinary soft tier of 256 files/2 MiB and a separate private reserve of 32 files/256 KiB, admitted before optional recovery. Replay is globally oldest-first across both tiers, at most 32 files/256 KiB per drain. A record replays only when its original non-null scope positively matches. Null scope is never adopted later, even with a physical cwd receipt: protecting project privacy takes priority over recovering an unattested event. Alias/profile/shard changes invalidate the derived identity cache and reader binding without changing pending scope. Positive fresh native project identity may still supply known-work context when pending scope is unknown; it does not authorize replay of those records.
|
|
316
316
|
|
|
317
|
-
|
|
317
|
+
Known-format pending records whose cwd is positively absent can retire after one hour (capture and file modification time must both exceed the grace). Other undrainable records keep the seven-day rule; a changed device/root/scope alone never proves cwd removal. Fair-share pressure can retire known-format records with a counted `scope_capacity` loss, while unknown/newer schemas retain seven-day protection. Each retirement persists numeric reason/count metrics before removing the body; a failed receipt leaves it pending. Retired bodies never become replay or context sources, and records are never rebound to the admitting project. Malformed publications still use bounded owned-file cleanup. See [the spool admission contract](docs/contracts.md#pending-spool-identity-and-admission-217) for identity, N-1 and retention details.
|
|
318
318
|
|
|
319
319
|
If both replayable tiers fill, or SessionStart's reserve alone fills, the current event can be dropped. The library receipt reports `dropped: true`, `dropReason: "spool_full"`, `dropCount: 1`, and the metric reports `captureDrops: 1` with `captureDropReason: "spool_full"`. Non-SessionStart native stdout remains empty. Native SessionStart emits only `hookSpecificOutput`, placing a bounded notice with reason counts from the last 200 metric samples after its initial untrusted-data header inside `additionalContext`, not in new top-level fields. These are rolling-window counts, not all-time totals. The header stays first even when the brief is otherwise unavailable, and the combined context stays within its byte bound. Finite-capacity and retirement losses are observable rather than reported as captured. Exact-once replay applies to positively bound retained records that can progress; unlimited lossless intake is not promised. Soft-cap overshoot, unpublished `.writing/` leftovers and power-loss durability retain the [contracts ceiling](docs/contracts.md#p3-hook-and-native-intake-boundaries-13).
|
|
320
320
|
|
|
321
321
|
SessionStart prioritizes known open work and defers unsafe curated refresh with a fixed coverage reason. Native capture reads the newest 128 rows through an existing read-only time index and marks excluded older history partial, instead of rejecting a larger store. It never creates schema/FTS or repairs indexes. Missing/locked/incompatible sources are explicitly unavailable. The standalone `sessionStartBrief()` retains all its original sources. Recovery failure cannot discard a computed brief. Work, reader-lease and capture host ports use zero busy-wait and cooperative deadline checks; ordinary library callers retain their original defaults, including 3,000 ms waits. No watcher, resident worker, model/network call, embedding job or vault access is added. Host policy callbacks remain unsupported by `runHook`, while the standalone brief composes them normally.
|
|
322
322
|
|
|
323
|
-
SessionStart admits directly to the reserve with at most 32 metadata entries and performs no active-tier scan,
|
|
323
|
+
SessionStart admits directly to the reserve with at most 32 metadata entries and performs no active-tier scan or replay. Both tiers use per-cwd fair shares (64 active records, 8 reserve records), grouping together all HOME/scope variants of a cwd. Admission can retire native records from an overrepresented cwd, counting each loss as `scope_capacity` before removal. Accepted summaries survive same-cwd pressure; new summaries backpressure without advancing their transcript cursor. A fully verified foreign all-summary inventory can use counted `summary_capacity` eviction; otherwise it stays pending for replay. All admission maintenance shares a 32-file/256 KiB body-read bound; unknown/newer schemas retain their seven-day protection. Non-SessionStart hooks maintain and drain both tiers. This is bounded reserve admission, not O(1) accounting; an atomic slot-reservation protocol is the upgrade. Parent brief storage still contains synchronous filesystem/SQLite work between cooperative deadline checks; asynchronous/deadline-aware storage remains the upgrade, not a larger handler limit. The R2 limits remain 150 ms p95 and 100 MB summed main/helper peak RSS, with a 140 ms handler budget. Functional tests construct a programmatic host-only deadline; the actual executable exposes no environment or CLI deadline extension. Its exec-only boot-clock handoff can only shorten the deadline and is cleared before hook import. Dedicated budget scripts retain the actual CLI, original sample counts, cold attempt 0 and timeout/reaper assertions. The [P3 receipt](docs/p3-hook-budget-2026-10-23.md) identifies prior-head evidence separately from current acceptance; the current rework has no newly claimed both-OS green CI result.
|
|
324
324
|
|
|
325
325
|
A private admission cursor rotates bounded inspection across pending records (at most 32 files/256 KiB per pass), so a healthy first slice cannot indefinitely hide later removed/unknown entries from retirement checks. Git include/conditional identity dependencies are traversed within depth four and 16 files. Pending scope hashes relevant semantic project-identity values rather than unrelated full-config or HEAD churn; derived cache validity may additionally hash raw configuration/HEAD bytes. Unconditional and `gitdir` includes bind semantic identity values. Dynamic `onbranch`/`hasconfig` or unknown conditional trees without identity keys bind nothing; if they contain origin/rewrite/worktree identity keys, scope stays unknown. Native read-only identity may still supply known work, but it never adopts that pending capture; counted `scope_unknown` retirement needs seven days. This conservative ceiling is upgraded by evaluating `onbranch` against HEAD at drain time, not by adding a condition interpreter in this PR.
|
|
326
326
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { vaultCandidates, vaultCandidatesText, vaultCandidatesAllText } from "./
|
|
|
17
17
|
import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
18
18
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
19
19
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
-
import { join } from "node:path";
|
|
20
|
+
import { isAbsolute, join } from "node:path";
|
|
21
21
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
22
22
|
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
23
23
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
@@ -136,6 +136,39 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
|
|
|
136
136
|
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** #217 PR2: pure checks only (no home, source or store access), so a bad invocation is refused before anything is read. */
|
|
140
|
+
function assertSpoolReplayArguments(path: readonly string[], options: Record<string, unknown>): void {
|
|
141
|
+
if (path[1] !== "replay") throw new AgentUsageError("usage: amem spool replay [--json] [--source <absolute dir>] | amem spool replay --apply --plan <file>");
|
|
142
|
+
const apply = options["--apply"] === true, planFile = options["--plan"], source = options["--source"];
|
|
143
|
+
if (apply && typeof planFile !== "string") throw new AgentUsageError("spool replay --apply needs --plan <saved preview>; run `amem spool replay --json > plan.json` first");
|
|
144
|
+
if (!apply && planFile !== undefined) throw new AgentUsageError("--plan is only used with --apply; drop it to preview, or add --apply to apply it");
|
|
145
|
+
if (apply && source !== undefined) throw new AgentUsageError("--source is fixed by the saved plan; drop --source when applying");
|
|
146
|
+
if (options["--confirm-unverifiable"] === true && !apply) throw new AgentUsageError("--confirm-unverifiable only applies with --apply --plan; preview first, review the mappings, then apply");
|
|
147
|
+
if (typeof planFile === "string" && (!planFile || planFile.includes("\0"))) throw new AgentUsageError("--plan is empty or contains a NUL byte; pass the saved preview's path");
|
|
148
|
+
if (source !== undefined) {
|
|
149
|
+
if (typeof source !== "string" || !source || source.includes("\0")) throw new AgentUsageError("--source is empty or contains a NUL byte; pass an absolute path to a private directory");
|
|
150
|
+
if (!isAbsolute(source)) throw new AgentUsageError("--source is a relative path; pass the absolute path of the parked directory");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Body-free preview text: counts by class, then each record's cwd, current project and class for explicit confirmation. */
|
|
154
|
+
function replayPreviewText(plan: { records: { name: string; cwd: string; project: string | null; class: string; event: string | null }[]; counts: Record<string, number>; digest: string; needsConfirmation: { cwd: string; project: string; count: number }[] }): string {
|
|
155
|
+
const lines = [`spool replay preview (no record bodies shown; plan ${plan.digest.slice(0, 12)})`,
|
|
156
|
+
...Object.entries(plan.counts).filter(([, n]) => n > 0).map(([cls, n]) => ` ${cls}: ${n}`)];
|
|
157
|
+
const byCwd = new Map<string, { project: string | null; classes: Map<string, number> }>();
|
|
158
|
+
for (const record of plan.records) {
|
|
159
|
+
const row = byCwd.get(record.cwd) ?? { project: record.project, classes: new Map() };
|
|
160
|
+
row.classes.set(record.class, (row.classes.get(record.class) ?? 0) + 1); byCwd.set(record.cwd, row);
|
|
161
|
+
}
|
|
162
|
+
for (const [cwd, row] of byCwd) lines.push(` ${cwd || "(unreadable record)"} -> ${row.project ?? "(project unresolved)"}: ${[...row.classes].map(([c, n]) => `${c} ${n}`).join(", ")}`);
|
|
163
|
+
if (plan.needsConfirmation.length) {
|
|
164
|
+
lines.push("Needs confirmation (decision A: this boot cannot prove the original identity; each maps by its own cwd):");
|
|
165
|
+
for (const group of plan.needsConfirmation) lines.push(` ${group.cwd} -> ${group.project}: ${group.count}`);
|
|
166
|
+
lines.push("To apply these mappings too, after checking each one: amem spool replay --json > plan.json && amem spool replay --apply --plan plan.json --confirm-unverifiable");
|
|
167
|
+
}
|
|
168
|
+
lines.push("Without --confirm-unverifiable only replayable records are applied: amem spool replay --json > plan.json && amem spool replay --apply --plan plan.json");
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
/** Administrative metadata is checked as a complete value, never by stripping individual fields. */
|
|
140
173
|
function assertAgentAdministrative(value: unknown, policy: ReturnType<typeof configuredPolicy>): void {
|
|
141
174
|
if (cliAttribution()?.actor !== "agent") return;
|
|
@@ -340,7 +373,9 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
340
373
|
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
341
374
|
} });
|
|
342
375
|
};
|
|
343
|
-
|
|
376
|
+
// #217 PR2: pure argument refusals for spool replay run first, before the administrative policy read touches the home.
|
|
377
|
+
if (sub === "spool") assertSpoolReplayArguments(parsed.path, options);
|
|
378
|
+
if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate", "spool"].includes(sub)) administrative({ command: parsed.path, arguments: args });
|
|
344
379
|
if (sub === "consolidate") {
|
|
345
380
|
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
346
381
|
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
@@ -669,6 +704,24 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
669
704
|
}
|
|
670
705
|
return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, builtin ? `built-in fixture ${file}` : file ?? "built-in fixture");
|
|
671
706
|
}
|
|
707
|
+
if (sub === "spool") {
|
|
708
|
+
// #217 PR2. Every argument refusal runs before any home, source or store access; both actors take this guarded
|
|
709
|
+
// preview/apply path (no marker-only ban, no generic TTY gate). The replay module is loaded only here.
|
|
710
|
+
// Argument refusals already ran in assertSpoolReplayArguments, before any home access.
|
|
711
|
+
const apply = options["--apply"] === true, planFile = options["--plan"], source = options["--source"];
|
|
712
|
+
const replay = await import("./hook-spool-replay.ts");
|
|
713
|
+
if (!apply) {
|
|
714
|
+
const plan = administrative(await replay.planHookSpoolReplay({ base: memoryHome(), ...(typeof source === "string" ? { source } : {}) }));
|
|
715
|
+
return options["--json"] === true ? JSON.stringify(plan, null, 2) : replayPreviewText(plan);
|
|
716
|
+
}
|
|
717
|
+
let saved: unknown;
|
|
718
|
+
try { saved = JSON.parse(readFileSync(planFile as string, "utf8")); }
|
|
719
|
+
catch { throw new Error(`cannot read the replay plan ${planFile as string}; run \`amem spool replay --json > plan.json\` and pass that file`); }
|
|
720
|
+
const plan = administrative(replay.parseReplayPlan(saved));
|
|
721
|
+
const receipt = administrative(await replay.applyHookSpoolReplay({ base: memoryHome(), plan, confirmUnverifiable: options["--confirm-unverifiable"] === true }));
|
|
722
|
+
return options["--json"] === true ? JSON.stringify(receipt, null, 2)
|
|
723
|
+
: `replayed ${receipt.applied} (${receipt.spooled} via the capture spool), retired ${receipt.retired}, retained ${receipt.retained}, ${receipt.absent} absent (completed by an earlier run of this plan, or removed or drained by someone else; nothing was appended for it); ${receipt.outcomes.filter(o => o.outcome === "skipped").length} skipped by the plan`;
|
|
724
|
+
}
|
|
672
725
|
if (sub === "capacity") {
|
|
673
726
|
const share = options["--disk-share"];
|
|
674
727
|
if (share !== undefined && (!Number.isFinite(Number(share)) || Number(share) <= 0 || Number(share) > 1)) throw new Error("--disk-share must be a number in (0, 1]");
|
package/src/commands-table.ts
CHANGED
|
@@ -43,6 +43,11 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
43
43
|
usage: { description: "Totals and the largest project shards", options: { ...json, "--limit": text("Largest shards to list (1..100)"), "--all": flag("Include forgotten projects") } },
|
|
44
44
|
eval: { description: "Replay a local question set against ranked store.search and report recall@k and MRR (no model, no network; the built-in synthetic set without --set, `--set ko` for the built-in Korean one)", options: { ...json,
|
|
45
45
|
"--set": { description: "`ko` (the built-in Korean set) or a question set file (amem.eval/v1); with entries it is self-contained, without them its ids name entries of this project", value: "path" }, "--k": text("Cutoffs, comma separated (default 1,5,10; each 1..100)") } },
|
|
46
|
+
spool: { description: "Off-hook hook-spool maintenance (#217)", options: json, commands: {
|
|
47
|
+
replay: { description: "Preview (read-only, no record bodies) or apply a replay of orphaned or parked hook records; --apply takes the saved preview", mutates: "apply", options: { ...json,
|
|
48
|
+
"--source": { description: "Absolute path of a private parked directory to read instead of this home's hook spool", value: "path" },
|
|
49
|
+
"--apply": flag("Apply the saved preview given with --plan"), "--confirm-unverifiable": flag("With --apply: also replay the saved plan's 'needs confirmation' cwd -> project mappings (records this boot cannot prove)"), "--plan": { description: "Saved preview from `amem spool replay --json`", value: "path" } } },
|
|
50
|
+
} },
|
|
46
51
|
gc: { description: "Apply each shard's retention and cap across shards in bounded batches (preview without --confirm)", mutates: "confirm", options: {
|
|
47
52
|
...json, "--confirm": flag("Evict now; without it, preview only"), "--batches": text("evict() windows per shard in this run (1..1000, default 1)") } },
|
|
48
53
|
project: { description: "Per-project lifecycle and portable import/export (#15 D3, F17): one project only", options: json, commands: {
|
package/src/hook-contract.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"] as const;
|
|
2
2
|
export type HookEvent = typeof HOOK_EVENTS[number];
|
|
3
3
|
export const HOOK_INPUT_BYTES = 64 * 1024, HOOK_CONTEXT_BYTES = 1200, HOOK_OUTPUT_BYTES = 8192;
|
|
4
|
-
|
|
4
|
+
/** `unavailable` (#221): the scan threw, so no progress is known; unlike the others it is a fault, never accepted progress. */
|
|
5
|
+
export type HookTranscriptStopReason = "cap" | "deadline" | "spool_full" | "unavailable";
|
|
6
|
+
export const HOOK_TRANSCRIPT_STOP_REASONS: readonly HookTranscriptStopReason[] = ["cap", "deadline", "spool_full", "unavailable"];
|
|
7
|
+
export interface HookTranscriptProgress { offset?: number; stopReason?: HookTranscriptStopReason }
|
|
5
8
|
export function isHookTranscriptProgress(value: unknown): value is HookTranscriptProgress {
|
|
6
9
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
7
10
|
const row = value as Record<string, unknown>;
|
|
8
11
|
return Object.keys(row).every(key => key === "offset" || key === "stopReason")
|
|
9
12
|
&& (row.offset !== undefined || row.stopReason !== undefined)
|
|
10
13
|
&& (row.offset === undefined || Number.isSafeInteger(row.offset) && (row.offset as number) >= 0)
|
|
11
|
-
&& (row.stopReason === undefined ||
|
|
14
|
+
&& (row.stopReason === undefined || (HOOK_TRANSCRIPT_STOP_REASONS as readonly string[]).includes(row.stopReason as string));
|
|
12
15
|
}
|
package/src/hook-rss-wire.ts
CHANGED
|
@@ -47,6 +47,8 @@ export class HookWorkerRss {
|
|
|
47
47
|
}
|
|
48
48
|
this.invalid = true;
|
|
49
49
|
}
|
|
50
|
+
/** #226 Q2: a killed child that is not reaped is accounted from its last live sample only when one exists (Linux /proc). */
|
|
51
|
+
hasLiveSample(): boolean { return !!(this.image.hwmKB || this.image.virtualKB); }
|
|
50
52
|
captureLive(read: typeof processImageMemory = processImageMemory): void {
|
|
51
53
|
const retain = (before: Image | undefined, after: Image): Image => ({
|
|
52
54
|
hwmKB: Math.max(before?.hwmKB ?? 0, after.hwmKB ?? 0) || undefined,
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #217 PR2: off-hook replay of orphaned or parked hook-spool records. CLI-only and lazily loaded: no hook imports it, it
|
|
3
|
+
* starts no resident process and makes no network or model call.
|
|
4
|
+
*
|
|
5
|
+
* Preview (`planHookSpoolReplay`) is read-only and body-free: it creates no directory, cache, database or index and changes
|
|
6
|
+
* no mode. It binds everything the apply step depends on. Apply (`applyHookSpoolReplay`) takes that saved plan, revalidates
|
|
7
|
+
* the whole binding before any effect, and replays only records the plan classified `replayable`.
|
|
8
|
+
*
|
|
9
|
+
* Decision A (owner, 2026-11-02): an opaque legacy root carries no writer or algorithm tag, so an unknown historical device
|
|
10
|
+
* hash does not prove a replacement. Such a record is `physical_identity_unverifiable` (plus `attribution_unproven`); the
|
|
11
|
+
* preview shows its recorded cwd, current project and count, and it is never guessed, rebound or retired automatically.
|
|
12
|
+
* Decision B: the saved plan is the resume token. The claim suffix is hash(plan digest, publication), so the same plan
|
|
13
|
+
* recognizes and resumes its own claims; any other claim (another plan's, or a live hook drainer's random one) is reported
|
|
14
|
+
* `claimed_elsewhere` and left alone. No
|
|
15
|
+
* durable mapping store exists.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type Stats } from "node:fs";
|
|
19
|
+
import { isAbsolute, join } from "node:path";
|
|
20
|
+
import { hookCaptureScope, hookLegacyCaptureScope, hookSpoolReplayInternals as spool, type HookSpoolOwnedFile } from "./hook-spool.ts";
|
|
21
|
+
import { aliasedKeys, resolveAlias } from "./memory/aliases.ts";
|
|
22
|
+
import { CaptureStore, type CaptureInput } from "./memory/capture.ts";
|
|
23
|
+
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
24
|
+
import { projectState } from "./memory/lifecycle.ts";
|
|
25
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
26
|
+
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
27
|
+
import { memoryProjectKey } from "./memory/project.ts";
|
|
28
|
+
|
|
29
|
+
export const REPLAY_PLAN_SCHEMA = "agent-mem.hook-spool-replay-plan/v1";
|
|
30
|
+
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
31
|
+
|
|
32
|
+
export type ReplayClass =
|
|
33
|
+
| "replayable" // decoded, scope verified on this boot, project resolved and active, policy-screened
|
|
34
|
+
| "physical_identity_unverifiable" // decision A: scope mismatch only; applied solely with --confirm-unverifiable
|
|
35
|
+
| "attribution_unproven" // project identity could not be resolved strictly from the record's own cwd
|
|
36
|
+
| "cwd_missing" // the recorded cwd no longer exists; never rebound
|
|
37
|
+
| "project_inactive" // the resolved project is archived or forgotten
|
|
38
|
+
| "policy_refused" // the record or its capture input fails the configured policy
|
|
39
|
+
| "unknown_schema" // #199: a newer or unknown schema stays pending, untouched
|
|
40
|
+
| "undecodable" // malformed or oversized: left for the hook path's own retirement rules
|
|
41
|
+
| "claimed_elsewhere"; // decision B: another plan's or a live drainer's claim, left alone
|
|
42
|
+
|
|
43
|
+
export interface ReplayPlanRecord {
|
|
44
|
+
/** Publication canonical name (without any `.draining-` suffix) and where it was observed. */
|
|
45
|
+
name: string; tier: "active" | "capacity" | "source";
|
|
46
|
+
/** File identity and raw-byte digest bound at preview. */
|
|
47
|
+
dev: number; ino: number; size: number; mtimeMs: number; digest: string;
|
|
48
|
+
class: ReplayClass; reasons: ReplayClass[];
|
|
49
|
+
/** Shown for confirmation, never a body: the recorded cwd, the current project for it, and the scope the hook path would compute now. */
|
|
50
|
+
cwd: string; project: string | null; scope: string | null; event: string | null;
|
|
51
|
+
/** Present when this plan already holds the claim (a resumed plan). */
|
|
52
|
+
claimedBy?: "this_plan";
|
|
53
|
+
/** Decision A: an unverifiable record whose cwd exists, whose strict project resolves and is active, and that passed the
|
|
54
|
+
* full policy screen. Applied only with explicit confirmation of this plan's cwd -> project mapping. */
|
|
55
|
+
confirmable?: true;
|
|
56
|
+
}
|
|
57
|
+
export interface HookSpoolReplayPlan {
|
|
58
|
+
schema: typeof REPLAY_PLAN_SCHEMA;
|
|
59
|
+
/** Destination home, snapshotted once and used for replay, policy, attribution and receipts alike. */
|
|
60
|
+
home: string;
|
|
61
|
+
source: { kind: "home" | "explicit"; dir: string; dev: number; ino: number }[];
|
|
62
|
+
/** Policy-input revisions (home config, each repository config) and alias/lifecycle revisions bound at preview. */
|
|
63
|
+
revisions: { aliases: string; lifecycle: string; policy: Record<string, string> };
|
|
64
|
+
records: ReplayPlanRecord[];
|
|
65
|
+
counts: Record<ReplayClass, number>;
|
|
66
|
+
/** Decision A: the cwd -> project mappings (with counts) an explicit --confirm-unverifiable would apply, bound in the digest. */
|
|
67
|
+
needsConfirmation: { cwd: string; project: string; count: number }[];
|
|
68
|
+
/** Digest over everything above; the resume token and the claim-suffix seed. */
|
|
69
|
+
digest: string;
|
|
70
|
+
}
|
|
71
|
+
export interface HookSpoolReplayReceipt {
|
|
72
|
+
plan: string;
|
|
73
|
+
applied: number; spooled: number; retired: number; retained: number;
|
|
74
|
+
/** Planned publications no longer present (a resumed plan's completed records, or ones a drainer took): no effect. */
|
|
75
|
+
absent: number;
|
|
76
|
+
/** Per publication outcome; `applied_cleanup_failed` is still applied, never reported as not applied. */
|
|
77
|
+
outcomes: { name: string; outcome: "applied" | "spooled" | "applied_cleanup_failed" | "retained" | "skipped" | "absent"; reason?: string }[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every error says what failed and what to do next (REVIEW.md). */
|
|
81
|
+
export class ReplayError extends Error {
|
|
82
|
+
constructor(readonly code: string, cause: string, next: string) { super(`${cause}; ${next}`); this.name = "ReplayError"; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const zeroCounts = (): Record<ReplayClass, number> => ({ replayable: 0, physical_identity_unverifiable: 0, attribution_unproven: 0, cwd_missing: 0,
|
|
86
|
+
project_inactive: 0, policy_refused: 0, unknown_schema: 0, undecodable: 0, claimed_elsewhere: 0 });
|
|
87
|
+
|
|
88
|
+
/** Pure argument and source checks, run before any home, source or store access. */
|
|
89
|
+
export function assertReplaySource(source: string): void {
|
|
90
|
+
if (typeof source !== "string" || !source || source.includes("\0")) throw new ReplayError("replay_source_invalid", "the replay source is empty or contains a NUL byte", "pass an absolute path to a private directory");
|
|
91
|
+
if (!isAbsolute(source)) throw new ReplayError("replay_source_relative", "the replay source is a relative path", "pass the absolute path, for example ~/.agenthub/hook-spool-orphans-20261102 expanded");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function directoryIdentity(dir: string, kind: "home" | "explicit"): { kind: "home" | "explicit"; dir: string; dev: number; ino: number } | undefined {
|
|
95
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
96
|
+
if (!stat) return undefined;
|
|
97
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ReplayError("replay_source_not_directory", `${dir} is not a real directory`, "pass a directory, not a file or a symbolic link");
|
|
98
|
+
if (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw new ReplayError("replay_source_not_private", `${dir} is not a private directory owned by you`, "use the directory as amem created it (mode 0700); do not loosen its permissions");
|
|
99
|
+
return { kind, dir: realPath(dir), dev: stat.dev, ino: stat.ino };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bounded directory listing of publication names (and `.draining-` claims), without reading bodies. */
|
|
103
|
+
function listPublications(dir: string): { entry: string; stat: Stats }[] {
|
|
104
|
+
const rows: { entry: string; stat: Stats }[] = [], reader = opendirSync(dir);
|
|
105
|
+
try {
|
|
106
|
+
for (let scanned = 0; scanned < spool.MAX_FILES * 4; scanned++) {
|
|
107
|
+
const item = reader.readSync(); if (!item) break;
|
|
108
|
+
if (!spool.namePattern.test(item.name)) continue;
|
|
109
|
+
const stat = lstatSync(join(dir, item.name), { throwIfNoEntry: false });
|
|
110
|
+
if (stat && stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) rows.push({ entry: item.name, stat });
|
|
111
|
+
}
|
|
112
|
+
} finally { reader.closeSync(); }
|
|
113
|
+
return rows.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.entry.localeCompare(b.entry));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Small, bounded read of a control file's bytes for a revision hash; absent means empty. */
|
|
117
|
+
function fileRevision(file: string): string {
|
|
118
|
+
try {
|
|
119
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
120
|
+
if (!stat) return hash("absent");
|
|
121
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) return hash("unusable");
|
|
122
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
123
|
+
try { const bytes = Buffer.alloc(fstatSync(fd).size); readSync(fd, bytes, 0, bytes.length, 0); return hash(bytes); } finally { closeSync(fd); }
|
|
124
|
+
} catch { return hash("unreadable"); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Strict, read-only project identity for the record's own cwd. It never touches the hook identity cache. */
|
|
128
|
+
function strictProject(cwd: string): string | undefined {
|
|
129
|
+
try { return memoryProjectKey(cwd, { repositoryIsolation: true, strictGitErrors: true }); }
|
|
130
|
+
catch (error) { if (exhausted(error)) throw resourceFailure(error)!; return undefined; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One inspected publication. No descriptor outlives its inspection (I3): apply re-opens each record when it reaches it. */
|
|
134
|
+
interface Inspected { record: ReplayPlanRecord; path?: string }
|
|
135
|
+
/** Descriptor or process-table exhaustion (or any other non-race open failure) must fail the run loudly, never look like a vanished file. */
|
|
136
|
+
const RACE_CODES = new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
|
|
137
|
+
const exhausted = (error: unknown) => ["EMFILE", "ENFILE"].includes(String((error as { code?: unknown } | null)?.code));
|
|
138
|
+
function resourceFailure(error: unknown): ReplayError | undefined {
|
|
139
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
140
|
+
if (typeof code !== "string" || RACE_CODES.has(code)) return undefined;
|
|
141
|
+
return new ReplayError("replay_resource_exhausted", `reading the spool failed with ${code}${code === "EMFILE" || code === "ENFILE" ? " (too many open files)" : ""}`,
|
|
142
|
+
"raise the open-file limit (ulimit -n) or move part of the source into another private directory, then preview again");
|
|
143
|
+
}
|
|
144
|
+
async function inspect(home: string, dir: string, tier: ReplayPlanRecord["tier"], entry: string, stat: Stats, planDigest: string | undefined): Promise<Inspected> {
|
|
145
|
+
const match = spool.namePattern.exec(entry)!, name = match[1]!;
|
|
146
|
+
const claimSuffix = /\.draining-([a-f0-9]{24})$/.exec(entry)?.[1];
|
|
147
|
+
const base: Omit<ReplayPlanRecord, "class" | "reasons" | "digest"> = { name, tier, dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs,
|
|
148
|
+
cwd: "", project: null, scope: null, event: null };
|
|
149
|
+
const path = join(dir, entry);
|
|
150
|
+
const finish = (cls: ReplayClass, reasons: ReplayClass[], digest: string, extra: Partial<ReplayPlanRecord> = {}, readable = true): Inspected =>
|
|
151
|
+
({ record: { ...base, ...extra, class: cls, reasons, digest }, ...(readable ? { path } : {}) });
|
|
152
|
+
if (claimSuffix !== undefined && (planDigest === undefined || claimSuffix !== claimFor(planDigest, name))) return finish("claimed_elsewhere", ["claimed_elsewhere"], "", {}, false);
|
|
153
|
+
const owned = spool.openOwned(path);
|
|
154
|
+
let decoded: ReturnType<typeof spool.decoded>, bytes: Buffer;
|
|
155
|
+
try {
|
|
156
|
+
decoded = spool.decoded(owned);
|
|
157
|
+
bytes = Buffer.alloc(Math.min(owned.initial.size, spool.RECORD_BYTES));
|
|
158
|
+
readSync(owned.fd, bytes, 0, bytes.length, 0);
|
|
159
|
+
} finally { closeSync(owned.fd); }
|
|
160
|
+
const digest = hash(bytes), claimed = claimSuffix !== undefined ? { claimedBy: "this_plan" as const } : {};
|
|
161
|
+
if (!decoded) return finish("undecodable", ["undecodable"], digest, claimed);
|
|
162
|
+
const value = decoded.value, cwd = typeof value.cwd === "string" ? value.cwd : "";
|
|
163
|
+
const event = typeof (value.capture as { event?: unknown } | undefined)?.event === "string" ? (value.capture as { event: string }).event : null;
|
|
164
|
+
if (decoded.unknown) return finish("unknown_schema", ["unknown_schema"], digest, { ...claimed, cwd, event });
|
|
165
|
+
if (value.nonce !== match[4] || value.cwd !== cwd || hash(cwd) !== match[2]) return finish("undecodable", ["undecodable"], digest, { ...claimed, cwd, event });
|
|
166
|
+
if (!lstatSync(cwd, { throwIfNoEntry: false })?.isDirectory()) return finish("cwd_missing", ["cwd_missing"], digest, { ...claimed, cwd, event });
|
|
167
|
+
const physical = realPath(cwd);
|
|
168
|
+
const scope = hookCaptureScope(physical, home) ?? null;
|
|
169
|
+
const project = strictProject(physical);
|
|
170
|
+
const canonical = project === undefined ? null : resolveAlias(home, project);
|
|
171
|
+
const reasons: ReplayClass[] = [];
|
|
172
|
+
// Decision A: the hook path's current scope, or the current-boot legacy device scope it also drains, proves the identity.
|
|
173
|
+
// Any other scope (a previous boot's device, a changed HOME) is a scope mismatch: physical_identity_unverifiable alone.
|
|
174
|
+
const legacy = hookLegacyCaptureScope(physical, home) ?? null;
|
|
175
|
+
const unverifiable = scope === null || value.scope !== scope && value.scope !== legacy;
|
|
176
|
+
// attribution_unproven only when no strict project resolves for the record's own cwd.
|
|
177
|
+
if (canonical === null) reasons.push("attribution_unproven");
|
|
178
|
+
else if (projectState(home, canonical) !== "active") reasons.push("project_inactive");
|
|
179
|
+
if (!reasons.length) {
|
|
180
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(home, physical));
|
|
181
|
+
if (!await policy.screen(value) || !await policy.screen(captureInput(value.capture as never, canonical!))) reasons.push("policy_refused");
|
|
182
|
+
}
|
|
183
|
+
if (unverifiable) reasons.unshift("physical_identity_unverifiable");
|
|
184
|
+
const cls = reasons[0] ?? "replayable";
|
|
185
|
+
const confirmable = unverifiable && reasons.length === 1 ? { confirmable: true as const } : {};
|
|
186
|
+
return finish(cls, reasons.length ? reasons : ["replayable"], digest, { ...claimed, ...confirmable, cwd, event, scope, project: canonical });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function captureInput(c: { agent: string; event: string; at: string; paths: string[]; tool: string | null; status: string | null; excerpt: string; session?: string; subagent?: boolean }, project: string): CaptureInput {
|
|
190
|
+
return { project, agent: c.agent, event: c.event, at: c.at, paths: c.paths, ...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }),
|
|
191
|
+
excerpt: c.excerpt, ...(c.session === undefined ? {} : { session: c.session }), ...(c.subagent === undefined ? {} : { subagent: c.subagent }) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Decision B: deterministic per plan and publication, so the same plan finds its own claims after a crash. */
|
|
195
|
+
export const claimFor = (planDigest: string, name: string) => hash(`${planDigest}\0${name}`).slice(0, 24);
|
|
196
|
+
|
|
197
|
+
function sources(home: string, source: string | undefined): { kind: "home" | "explicit"; dir: string; dev: number; ino: number }[] {
|
|
198
|
+
if (source !== undefined) {
|
|
199
|
+
const dir = directoryIdentity(source, "explicit");
|
|
200
|
+
if (!dir) throw new ReplayError("replay_source_missing", `${source} does not exist`, "check the path of the parked directory");
|
|
201
|
+
return [dir];
|
|
202
|
+
}
|
|
203
|
+
return [directoryIdentity(join(home, "hook-spool"), "home"), directoryIdentity(join(home, "hook-spool", ".capacity"), "home")]
|
|
204
|
+
.filter((dir): dir is NonNullable<typeof dir> => dir !== undefined);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function bindings(home: string, records: ReplayPlanRecord[]): HookSpoolReplayPlan["revisions"] {
|
|
208
|
+
const policy: Record<string, string> = { home: fileRevision(join(home, "amem.config.json")) };
|
|
209
|
+
for (const cwd of [...new Set(records.map(record => record.cwd).filter(Boolean))].sort()) {
|
|
210
|
+
try { policy[cwd] = hash(JSON.stringify(configuredPolicyOptions(home, realPath(cwd)))); } catch { policy[cwd] = hash("unavailable"); }
|
|
211
|
+
}
|
|
212
|
+
return { aliases: fileRevision(join(home, "aliases.jsonl")), lifecycle: hash(JSON.stringify([...new Set(records.map(r => r.project).filter(Boolean))].sort().map(p => [p, projectState(home, p!), aliasedKeys(home, p!)]))), policy };
|
|
213
|
+
}
|
|
214
|
+
const planDigest = (plan: Omit<HookSpoolReplayPlan, "digest">) => hash(JSON.stringify(plan));
|
|
215
|
+
function confirmationGroups(records: ReplayPlanRecord[]): HookSpoolReplayPlan["needsConfirmation"] {
|
|
216
|
+
const groups = new Map<string, { cwd: string; project: string; count: number }>();
|
|
217
|
+
for (const record of records) if (record.confirmable) {
|
|
218
|
+
const key = JSON.stringify([record.cwd, record.project]), row = groups.get(key) ?? { cwd: record.cwd, project: record.project!, count: 0 };
|
|
219
|
+
row.count++; groups.set(key, row);
|
|
220
|
+
}
|
|
221
|
+
return [...groups.values()].sort((a, b) => a.cwd.localeCompare(b.cwd) || a.project.localeCompare(b.project));
|
|
222
|
+
}
|
|
223
|
+
/** A record this apply may replay: always a replayable one; a confirmable unverifiable one only with explicit confirmation. */
|
|
224
|
+
const applicable = (record: ReplayPlanRecord, confirmUnverifiable: boolean) => record.class === "replayable" || confirmUnverifiable && record.confirmable === true
|
|
225
|
+
&& record.class === "physical_identity_unverifiable" && record.reasons.length === 1 && record.project !== null;
|
|
226
|
+
|
|
227
|
+
async function survey(home: string, dirs: HookSpoolReplayPlan["source"], digest: string | undefined): Promise<Inspected[]> {
|
|
228
|
+
const out: Inspected[] = [];
|
|
229
|
+
for (const dir of dirs) {
|
|
230
|
+
const tier = dir.kind === "explicit" ? "source" : dir.dir.endsWith("/.capacity") ? "capacity" : "active";
|
|
231
|
+
for (const { entry, stat } of listPublications(dir.dir)) {
|
|
232
|
+
try { out.push(await inspect(home, dir.dir, tier, entry, stat, digest)); }
|
|
233
|
+
catch (error) {
|
|
234
|
+
const failure = resourceFailure(error);
|
|
235
|
+
if (failure) throw failure;
|
|
236
|
+
/* vanished or replaced between list and open: it is simply not in this plan */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Read-only, body-free preview. Creates nothing and changes no mode. */
|
|
244
|
+
export async function planHookSpoolReplay(options: { base: string; source?: string }): Promise<HookSpoolReplayPlan> {
|
|
245
|
+
if (options.source !== undefined) assertReplaySource(options.source);
|
|
246
|
+
const home = canonicalPath(options.base);
|
|
247
|
+
if (spool.belowFloor(home)) throw new ReplayError("replay_format_floor", "this home records a hook-spool format floor above this amem", "upgrade amem to the floor's version, then preview again");
|
|
248
|
+
const dirs = sources(home, options.source);
|
|
249
|
+
const inspected = await survey(home, dirs, undefined);
|
|
250
|
+
const records = inspected.map(item => item.record), counts = zeroCounts();
|
|
251
|
+
for (const record of records) counts[record.class]++;
|
|
252
|
+
const body: Omit<HookSpoolReplayPlan, "digest"> = { schema: REPLAY_PLAN_SCHEMA, home, source: dirs, revisions: bindings(home, records), records, counts, needsConfirmation: confirmationGroups(records) };
|
|
253
|
+
return { ...body, digest: planDigest(body) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Validate the saved plan's own shape and digest before using any of it. */
|
|
257
|
+
export function parseReplayPlan(value: unknown): HookSpoolReplayPlan {
|
|
258
|
+
const plan = value as HookSpoolReplayPlan;
|
|
259
|
+
if (!plan || typeof plan !== "object" || plan.schema !== REPLAY_PLAN_SCHEMA || !Array.isArray(plan.records) || !Array.isArray(plan.needsConfirmation) || typeof plan.digest !== "string")
|
|
260
|
+
throw new ReplayError("replay_plan_invalid", "the file is not an amem spool replay plan", "run `amem spool replay --json > plan.json` and pass that file");
|
|
261
|
+
const { digest, ...body } = plan;
|
|
262
|
+
if (planDigest(body) !== digest) throw new ReplayError("replay_plan_altered", "the plan file was edited after the preview", "preview again and apply the new, unedited plan");
|
|
263
|
+
return plan;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Apply a saved plan. The whole binding (home, source directories, revisions, and every record's identity, digest and class)
|
|
268
|
+
* is revalidated first; any change refuses with no effect. Records are replayed oldest first through one CaptureStore at a
|
|
269
|
+
* time; the durable append (or the capture spool's acknowledgement) completes before the exact owned claim retires.
|
|
270
|
+
*/
|
|
271
|
+
export async function applyHookSpoolReplay(options: { base: string; plan: HookSpoolReplayPlan; confirmUnverifiable?: boolean }): Promise<HookSpoolReplayReceipt> {
|
|
272
|
+
const plan = parseReplayPlan(options.plan), home = canonicalPath(options.base), confirm = options.confirmUnverifiable === true;
|
|
273
|
+
if (plan.home !== home) throw new ReplayError("replay_home_changed", "the plan was made for another memory home", "run the preview again from this home");
|
|
274
|
+
if (spool.belowFloor(home)) throw new ReplayError("replay_format_floor", "this home records a hook-spool format floor above this amem", "upgrade amem to the floor's version, then preview again");
|
|
275
|
+
const dirs = plan.source.map(dir => directoryIdentity(dir.dir, dir.kind));
|
|
276
|
+
if (dirs.some((dir, index) => !dir || dir.dev !== plan.source[index]!.dev || dir.ino !== plan.source[index]!.ino))
|
|
277
|
+
throw new ReplayError("replay_source_changed", "a source directory was replaced since the preview", "preview again and apply the new plan");
|
|
278
|
+
const current = await survey(home, plan.source, plan.digest);
|
|
279
|
+
{
|
|
280
|
+
const byName = new Map(current.map(item => [item.record.name, item]));
|
|
281
|
+
for (const planned of plan.records) {
|
|
282
|
+
if (!applicable(planned, confirm)) continue;
|
|
283
|
+
const now = byName.get(planned.name)?.record;
|
|
284
|
+
// I2: a planned publication that is gone (no source name and no claim under this plan's suffix) has no effect here.
|
|
285
|
+
// It was retired by an earlier run of this plan or taken by a live drainer that captured it; nothing is appended for it.
|
|
286
|
+
if (!now) continue;
|
|
287
|
+
if (now.dev !== planned.dev || now.ino !== planned.ino || now.size !== planned.size || now.digest !== planned.digest
|
|
288
|
+
|| now.class !== planned.class || JSON.stringify(now.reasons) !== JSON.stringify(planned.reasons) || now.confirmable !== planned.confirmable
|
|
289
|
+
|| now.project !== planned.project || now.scope !== planned.scope || now.cwd !== planned.cwd)
|
|
290
|
+
throw new ReplayError("replay_plan_stale", `${planned.name} changed since the preview (identity, bytes, scope, project or policy)`, "preview again and apply the new plan");
|
|
291
|
+
}
|
|
292
|
+
// Recompute the same bindings over the plan's own cwds and projects: any policy, alias or lifecycle change refuses.
|
|
293
|
+
if (JSON.stringify(bindings(home, plan.records)) !== JSON.stringify(plan.revisions))
|
|
294
|
+
throw new ReplayError("replay_plan_stale", "a policy, alias or lifecycle input changed since the preview", "preview again and apply the new plan");
|
|
295
|
+
|
|
296
|
+
const receipt: HookSpoolReplayReceipt = { plan: plan.digest, applied: 0, spooled: 0, retired: 0, retained: 0, absent: 0, outcomes: [] };
|
|
297
|
+
const planned = plan.records.filter(record => applicable(record, confirm)).sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name));
|
|
298
|
+
let store: CaptureStore | undefined, storeProject: string | undefined, held: HookSpoolOwnedFile | undefined;
|
|
299
|
+
try {
|
|
300
|
+
for (const record of planned) {
|
|
301
|
+
const item = byName.get(record.name);
|
|
302
|
+
if (!item) { receipt.absent++; receipt.outcomes.push({ name: record.name, outcome: "absent", reason: "completed by an earlier run of this plan, or removed or drained by someone else; nothing was appended for it" }); continue; }
|
|
303
|
+
const retain = (reason: string) => { receipt.retained++; receipt.outcomes.push({ name: record.name, outcome: "retained", reason }); };
|
|
304
|
+
if (!item.path) { retain("unavailable"); continue; }
|
|
305
|
+
// I3: one descriptor at a time. Re-open here and require the same inode, size, mtime and bytes the plan bound.
|
|
306
|
+
let owned: HookSpoolOwnedFile;
|
|
307
|
+
try { owned = spool.openOwned(item.path); }
|
|
308
|
+
catch (error) { const failure = resourceFailure(error); if (failure) throw failure; retain("unavailable"); continue; }
|
|
309
|
+
held = owned;
|
|
310
|
+
if (owned.initial.dev !== record.dev || owned.initial.ino !== record.ino || owned.initial.size !== record.size || owned.initial.mtimeMs !== record.mtimeMs) { retain("identity_changed"); closeSync(owned.fd); held = undefined; continue; }
|
|
311
|
+
// Decision B: claim under the plan's own deterministic suffix (an existing own claim is already that name).
|
|
312
|
+
const claim = item.record.claimedBy === "this_plan" ? owned : spool.claimAs(owned, `${record.name}`, claimFor(plan.digest, record.name));
|
|
313
|
+
if (!claim) { retain("claim_lost"); closeSync(owned.fd); held = undefined; continue; }
|
|
314
|
+
held = claim;
|
|
315
|
+
const decoded = spool.decoded(claim);
|
|
316
|
+
if (!decoded || decoded.unknown || hash(decoded.bytes) !== record.digest) { retain("decode_changed"); closeSync(claim.fd); held = undefined; continue; }
|
|
317
|
+
// One CaptureStore at a time: close the previous project's store before opening the next one.
|
|
318
|
+
if (storeProject !== record.project) { store?.close(); store = new CaptureStore({ memoryDir: home, project: record.project!, cwd: realPath(record.cwd) }); storeProject = record.project!; }
|
|
319
|
+
const result = store!.append(captureInput(decoded.value.capture as never, record.project!), { receipt: hash(decoded.bytes) });
|
|
320
|
+
const spooled = "spooled" in result;
|
|
321
|
+
if (spooled) receipt.spooled++; else receipt.applied++;
|
|
322
|
+
const retired = spool.retireOwned(claim);
|
|
323
|
+
closeSync(claim.fd); held = undefined;
|
|
324
|
+
if (retired) { receipt.retired++; receipt.outcomes.push({ name: record.name, outcome: spooled ? "spooled" : "applied" }); }
|
|
325
|
+
else receipt.outcomes.push({ name: record.name, outcome: "applied_cleanup_failed", reason: "the claim changed after the append; the row is recorded and the claim is kept" });
|
|
326
|
+
}
|
|
327
|
+
} finally { store?.close(); if (held) try { closeSync(held.fd); } catch { /* already closed */ } }
|
|
328
|
+
for (const record of plan.records) if (!applicable(record, confirm)) receipt.outcomes.push({ name: record.name, outcome: "skipped",
|
|
329
|
+
reason: record.confirmable ? "physical_identity_unverifiable: needs --confirm-unverifiable" : record.class });
|
|
330
|
+
return receipt;
|
|
331
|
+
}
|
|
332
|
+
}
|