@staix/agent-mem 0.18.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 +23 -7
- package/package.json +3 -2
- package/src/cli.ts +82 -6
- package/src/commands-table.ts +8 -1
- package/src/hook-contract.ts +12 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +87 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +315 -66
- package/src/hook-transcript.ts +205 -0
- package/src/hooks.ts +183 -51
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +54 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +5 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/public-prune-view.ts +6 -2
- package/src/memory/search-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +117 -65
- package/src/session-brief.ts +19 -7
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
package/src/hook-project.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { combineRssSources } from "./hook-resources.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
@@ -12,6 +13,8 @@ import { hookNative, type HookNativeUsage } from "./hook-native.ts";
|
|
|
12
13
|
const SCHEMA = "agent-mem.hook-project/v1", CACHE_BYTES = 4096, CACHE_CWDS = 128;
|
|
13
14
|
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
14
15
|
interface Binding { schema: typeof SCHEMA; cwd: string; scope: string; validity: string; binary: string; project: string }
|
|
16
|
+
/** One-invocation parent/child transport, not an authentication token or a reusable cache. */
|
|
17
|
+
export interface HookProjectReceipt { cwd: string; base: string; scope: string; validity?: string; binary: string; project: string }
|
|
15
18
|
function read(file: string): Binding | undefined {
|
|
16
19
|
let fd: number | undefined;
|
|
17
20
|
try {
|
|
@@ -103,7 +106,21 @@ function prepareHookProject(options: ResolverOptions) {
|
|
|
103
106
|
}
|
|
104
107
|
checkDeadline();
|
|
105
108
|
};
|
|
106
|
-
return { binary, verifyBinary,
|
|
109
|
+
return { binary, verifyBinary,
|
|
110
|
+
receipt(project: string): HookProjectReceipt | undefined {
|
|
111
|
+
verify();
|
|
112
|
+
return scope === undefined ? undefined : { cwd, base, scope, validity, binary: binary.revision, project };
|
|
113
|
+
},
|
|
114
|
+
accept(value: unknown): string {
|
|
115
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("project receipt unavailable");
|
|
116
|
+
const receipt = value as HookProjectReceipt;
|
|
117
|
+
if (Object.keys(receipt).some(key => !["cwd", "base", "scope", "validity", "binary", "project"].includes(key))
|
|
118
|
+
|| receipt.cwd !== cwd || receipt.base !== base || receipt.scope !== scope || scope === undefined
|
|
119
|
+
|| receipt.validity !== validity || receipt.binary !== binary.revision || typeof receipt.project !== "string"
|
|
120
|
+
|| !receipt.project || Buffer.byteLength(receipt.project) > 64 * 1024) throw new Error("project receipt changed");
|
|
121
|
+
verify(); return receipt.project;
|
|
122
|
+
},
|
|
123
|
+
resolve(gitRead: ProjectGitRead, assertComplete?: () => void): string {
|
|
107
124
|
checkDeadline();
|
|
108
125
|
if (hit && cached) {
|
|
109
126
|
assertComplete?.(); verify();
|
|
@@ -128,6 +145,11 @@ export function resolveHookProject(options: ResolverOptions): string {
|
|
|
128
145
|
return prepared.resolve(options.gitRead ?? hookGit(options.timeRemaining, prepared.binary.file), options.assertComplete);
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
/** Revalidate a fresh result delivered by our parent, without launching a native child. */
|
|
149
|
+
export function acceptHookProjectReceipt(value: unknown, options: ResolverOptions): string {
|
|
150
|
+
return prepareHookProject(options).accept(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
/** A local key's superproject chain depends on outer Git indexes. Cache only ordinary
|
|
132
154
|
* standalone roots; linked/submodule/local nested layouts retain the native resolver. */
|
|
133
155
|
function cacheableLocal(cwd: string, project: string): boolean {
|
|
@@ -146,7 +168,7 @@ function cacheableLocal(cwd: string, project: string): boolean {
|
|
|
146
168
|
|
|
147
169
|
/** Replay the shared synchronous resolver only after every requested native read is known.
|
|
148
170
|
* A suspended/partially resolved pass can never publish a cache entry or return a guessed key. */
|
|
149
|
-
export async function resolveHookProjectAsync(options: { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; prefetch?: boolean }, usage: HookNativeUsage): Promise<string> {
|
|
171
|
+
export async function resolveHookProjectAsync(options: { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; prefetch?: boolean; onResolved?: (receipt: HookProjectReceipt | undefined) => void }, usage: HookNativeUsage): Promise<string> {
|
|
150
172
|
const reads = new Map<string, ReturnType<ProjectGitRead>>();
|
|
151
173
|
const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: options.base };
|
|
152
174
|
const prepared = prepareHookProject({ ...options, env });
|
|
@@ -159,7 +181,7 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
|
|
|
159
181
|
if (pending.size >= 64) throw new Error("native identity query bound");
|
|
160
182
|
prepared.verifyBinary(); // No awaited native read may execute a replaced binary.
|
|
161
183
|
const argv = memoryGitArgv(args); argv[0] = prepared.binary.file;
|
|
162
|
-
const abort = new AbortController(), own = { rssKB: 0, rawRssKB: 0 };
|
|
184
|
+
const abort = new AbortController(), own: HookNativeUsage = { rssKB: 0, rawRssKB: 0, observe: usage.observe };
|
|
163
185
|
active.add(key); overlaps.push([...active]);
|
|
164
186
|
const result = hookNative(argv, memoryGitEnv(env, { repositoryIsolation: true }), options.timeRemaining, own, abort.signal)
|
|
165
187
|
.finally(() => { active.delete(key); });
|
|
@@ -178,7 +200,7 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
|
|
|
178
200
|
needed ??= { key, args }; throw suspended;
|
|
179
201
|
}, () => { if (needed) throw suspended; });
|
|
180
202
|
} catch (error) { failure = error; }
|
|
181
|
-
if (!needed) { if (failure) throw failure; if (result !== undefined) return result; throw suspended; }
|
|
203
|
+
if (!needed) { if (failure) throw failure; if (result !== undefined) { options.onResolved?.(prepared.receipt(result)); return result; } throw suspended; }
|
|
182
204
|
const request = needed as { key: string; args: string[] };
|
|
183
205
|
const demanded = query(request.args);
|
|
184
206
|
// A native read is requested only on a real cache miss. Prefetch the portable
|
|
@@ -202,5 +224,6 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
|
|
|
202
224
|
usage.rssKB = Math.max(usage.rssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rssKB, 0));
|
|
203
225
|
usage.rawRssKB = Math.max(usage.rawRssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rawRssKB, 0));
|
|
204
226
|
}
|
|
227
|
+
usage.rssSources = combineRssSources(...[...pending.values()].map(child => child.usage.rssSources));
|
|
205
228
|
}
|
|
206
229
|
}
|
package/src/hook-resources.ts
CHANGED
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
return
|
|
3
|
+
export const HOOK_RSS_COVERAGE = "images: bun+supervisor; descendants: not measured" as const;
|
|
4
|
+
|
|
5
|
+
export interface HookRssReading { kb: number; rawKB: number; source: "VmHWM" | "vmpeak-bound" | "platform-rusage" | "raw-fallback" | "unavailable"; known: boolean }
|
|
6
|
+
export interface HookRssSources { vmhwm: number; vmpeakBound: number; platformRusage: number; rawFallback: number; unavailable: number }
|
|
7
|
+
const positive = (value: number | undefined): value is number => value !== undefined && Number.isSafeInteger(value) && value > 0;
|
|
8
|
+
|
|
9
|
+
/** A raw fallback stays conservative, but is explicitly insufficient to certify a Linux image peak. */
|
|
10
|
+
export function selectHookRss(platform: string, imageKB: number | undefined, rawKB: number, virtualKB?: number): HookRssReading {
|
|
11
|
+
const raw = positive(rawKB) ? rawKB : 0;
|
|
12
|
+
if (platform === "linux" && positive(imageKB)) return { kb: imageKB, rawKB: raw, source: "VmHWM", known: true };
|
|
13
|
+
if (platform === "linux" && positive(virtualKB)) return { kb: virtualKB, rawKB: raw, source: "vmpeak-bound", known: true };
|
|
14
|
+
if (platform !== "linux" && raw) return { kb: Math.max(raw, positive(imageKB) ? imageKB : 0), rawKB: raw, source: "platform-rusage", known: true };
|
|
15
|
+
return { kb: raw, rawKB: raw, source: raw ? "raw-fallback" : "unavailable", known: false };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function processImageMemory(pid: number | "self"): { hwmKB?: number; virtualKB?: number } {
|
|
19
|
+
if (process.platform !== "linux" || pid !== "self" && (!Number.isSafeInteger(pid) || pid <= 0)) return {};
|
|
20
|
+
try {
|
|
21
|
+
const text = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
22
|
+
const field = (match: RegExpExecArray | null) => { const value = Number(match?.[1]); return positive(value) ? value : undefined; };
|
|
23
|
+
return { hwmKB: field(/^VmHWM:\s+(\d+)\s+kB$/m.exec(text)), virtualKB: field(/^VmPeak:\s+(\d+)\s+kB$/m.exec(text)) };
|
|
24
|
+
} catch { /* Missing or exited processes are unknown, never a fabricated zero peak. */ }
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
export const processImageRssKB = (pid: number | "self"): number | undefined => processImageMemory(pid).hwmKB;
|
|
28
|
+
|
|
29
|
+
export function hookRssReading(): HookRssReading {
|
|
30
|
+
const image = processImageMemory("self");
|
|
31
|
+
return selectHookRss(process.platform, image.hwmKB, process.resourceUsage().maxRSS, image.virtualKB);
|
|
32
|
+
}
|
|
33
|
+
export const hookPeakRssKB = (): number => hookRssReading().kb;
|
|
34
|
+
export const emptyRssSources = (): HookRssSources => ({ vmhwm: 0, vmpeakBound: 0, platformRusage: 0, rawFallback: 0, unavailable: 0 });
|
|
35
|
+
export function rssSource(reading: HookRssReading): HookRssSources {
|
|
36
|
+
const value = emptyRssSources();
|
|
37
|
+
value[reading.source === "VmHWM" ? "vmhwm" : reading.source === "vmpeak-bound" ? "vmpeakBound" : reading.source === "platform-rusage" ? "platformRusage" : reading.source === "raw-fallback" ? "rawFallback" : "unavailable"] = 1;
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function combineRssSources(...values: (HookRssSources | undefined)[]): HookRssSources {
|
|
41
|
+
const result = emptyRssSources();
|
|
42
|
+
for (const value of values) if (value) for (const key of Object.keys(result) as (keyof HookRssSources)[]) result[key] += value[key];
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
export function countedRssSources(kb: number, value?: HookRssSources): HookRssSources {
|
|
46
|
+
const result = combineRssSources(value);
|
|
47
|
+
if (kb > 0 && Object.values(result).every(count => count === 0)) result.unavailable++;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function validRssSources(value: unknown): value is HookRssSources {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
52
|
+
const row = value as Record<string, unknown>, keys = Object.keys(emptyRssSources());
|
|
53
|
+
return Object.keys(row).length === keys.length && keys.every(key => Number.isSafeInteger(row[key]) && (row[key] as number) >= 0 && (row[key] as number) <= 1024);
|
|
54
|
+
}
|
|
55
|
+
export function rssSourcesKnown(value: HookRssSources): boolean {
|
|
56
|
+
return value.rawFallback === 0 && value.unavailable === 0 && value.vmhwm + value.vmpeakBound + value.platformRusage > 0;
|
|
15
57
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { combineRssSources, processImageMemory, rssSource, selectHookRss, type HookRssReading, type HookRssSources } from "./hook-resources.ts";
|
|
2
|
+
|
|
3
|
+
const SOURCES = ["VmHWM", "vmpeak-bound", "platform-rusage", "raw-fallback", "unavailable"] as const;
|
|
4
|
+
export const rssSourceCode = (value: HookRssReading): number => SOURCES.indexOf(value.source);
|
|
5
|
+
function reading(kb: unknown, raw: unknown, code: unknown): HookRssReading | undefined {
|
|
6
|
+
if (!Number.isSafeInteger(kb) || (kb as number) < 0 || !Number.isSafeInteger(raw) || (raw as number) < 0 || !Number.isSafeInteger(code) || (code as number) < 0 || (code as number) >= SOURCES.length) return;
|
|
7
|
+
const source = SOURCES[code as number]!;
|
|
8
|
+
if ((code as number) < 3 && (kb as number) <= 0) return;
|
|
9
|
+
return { kb: kb as number, rawKB: raw as number, source, known: (code as number) < 3 };
|
|
10
|
+
}
|
|
11
|
+
type Image = ReturnType<typeof processImageMemory>;
|
|
12
|
+
interface Native { pid: number; image?: Image; result?: HookRssReading }
|
|
13
|
+
|
|
14
|
+
/** Bounded numeric progress survives worker timeout; no native arguments or project data enter it. */
|
|
15
|
+
export class HookWorkerRss {
|
|
16
|
+
private entered = false;
|
|
17
|
+
private invalid = false;
|
|
18
|
+
private readonly native = new Map<number, Native>();
|
|
19
|
+
private readonly active = new Set<number>();
|
|
20
|
+
private readonly overlaps: number[][] = [];
|
|
21
|
+
private image: Image = {};
|
|
22
|
+
private self?: HookRssReading;
|
|
23
|
+
constructor(private readonly pid: number) {}
|
|
24
|
+
invalidate(): void { this.invalid = true; }
|
|
25
|
+
frame(value: unknown): void {
|
|
26
|
+
if (!Array.isArray(value)) { this.invalid = true; return; }
|
|
27
|
+
const [kind, id, pid] = value;
|
|
28
|
+
if (kind === 0 && value.length === 1 && !this.entered) { this.entered = true; return; }
|
|
29
|
+
if (!this.entered) { this.invalid = true; return; }
|
|
30
|
+
if (kind === 3 && value.length === 4) {
|
|
31
|
+
const selected = reading(value[1], value[2], value[3]);
|
|
32
|
+
if (!selected) this.invalid = true; else this.self = selected;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!Number.isSafeInteger(id) || id < 1 || id > 64) { this.invalid = true; return; }
|
|
36
|
+
if (kind === 1 && value.length === 3 && Number.isSafeInteger(pid) && pid >= 0) {
|
|
37
|
+
const prior = this.native.get(id);
|
|
38
|
+
if (!prior && this.native.size < 64 && pid === 0) {
|
|
39
|
+
this.native.set(id, { pid }); this.active.add(id); this.overlaps.push([...this.active]); return;
|
|
40
|
+
}
|
|
41
|
+
if (prior && prior.pid === 0 && !prior.result && pid > 0) { prior.pid = pid; return; }
|
|
42
|
+
}
|
|
43
|
+
if (kind === 2 && value.length === 6) {
|
|
44
|
+
const prior = this.native.get(id), selected = reading(value[2], value[3], value[4]);
|
|
45
|
+
// Reserved final slot must remain zero, so protocol extensions cannot be misread.
|
|
46
|
+
if (prior && !prior.result && selected && value[5] === 0) { prior.result = selected; this.active.delete(id); return; }
|
|
47
|
+
}
|
|
48
|
+
this.invalid = true;
|
|
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); }
|
|
52
|
+
captureLive(read: typeof processImageMemory = processImageMemory): void {
|
|
53
|
+
const retain = (before: Image | undefined, after: Image): Image => ({
|
|
54
|
+
hwmKB: Math.max(before?.hwmKB ?? 0, after.hwmKB ?? 0) || undefined,
|
|
55
|
+
virtualKB: Math.max(before?.virtualKB ?? 0, after.virtualKB ?? 0) || undefined,
|
|
56
|
+
});
|
|
57
|
+
this.image = retain(this.image, read(this.pid));
|
|
58
|
+
for (const id of this.active) {
|
|
59
|
+
const value = this.native.get(id)!;
|
|
60
|
+
if (value.pid > 0) value.image = retain(value.image, read(value.pid));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finish(rawWorkerKB: number, platform = process.platform): { rssKB: number; rawRssKB: number; rssSources: HookRssSources } {
|
|
64
|
+
const self = this.self;
|
|
65
|
+
const hwm = Math.max(this.image.hwmKB ?? 0, self?.source === "VmHWM" ? self.kb : 0);
|
|
66
|
+
const virtual = Math.max(this.image.virtualKB ?? 0, self?.source === "vmpeak-bound" ? self.kb : 0);
|
|
67
|
+
const ownRaw = platform !== "linux" && self?.source === "platform-rusage" ? self.kb : rawWorkerKB;
|
|
68
|
+
const own = selectHookRss(platform, hwm, ownRaw, virtual);
|
|
69
|
+
const sources = [rssSource(own)];
|
|
70
|
+
const native = new Map<number, HookRssReading>();
|
|
71
|
+
for (const [id, value] of this.native) {
|
|
72
|
+
const given = value.result;
|
|
73
|
+
const selected = given && !given.known && !value.image?.hwmKB && !value.image?.virtualKB ? given : selectHookRss(platform,
|
|
74
|
+
Math.max(value.image?.hwmKB ?? 0, given?.source === "VmHWM" ? given.kb : 0), given?.rawKB ?? 0,
|
|
75
|
+
Math.max(value.image?.virtualKB ?? 0, given?.source === "vmpeak-bound" ? given.kb : 0));
|
|
76
|
+
native.set(id, selected); sources.push(rssSource(selected));
|
|
77
|
+
}
|
|
78
|
+
let nativeKB = 0;
|
|
79
|
+
for (const group of this.overlaps) {
|
|
80
|
+
nativeKB = Math.max(nativeKB, group.reduce((sum, id) => sum + native.get(id)!.kb, 0));
|
|
81
|
+
}
|
|
82
|
+
const rssSources = combineRssSources(...sources);
|
|
83
|
+
if (this.invalid) rssSources.unavailable++;
|
|
84
|
+
const combined = own.kb + nativeKB;
|
|
85
|
+
return { rssKB: platform === "linux" ? combined : Math.max(combined, rawWorkerKB), rawRssKB: rawWorkerKB, rssSources };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|