@afokapu/atdd-flow 0.3.76 → 0.3.77
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/package.json +1 -1
- package/src/core.ts +2 -0
- package/src/exact-adoption-recovery.ts +3 -7
- package/src/handover.ts +13 -19
- package/src/stale-main-recovery.ts +175 -12
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -75,6 +75,8 @@ export type Runtime = {
|
|
|
75
75
|
pi_session_path?: string;
|
|
76
76
|
/** Immutable, non-secret receipt for that selected launch. */
|
|
77
77
|
launch_receipt?: string;
|
|
78
|
+
/** Operator-recorded binding mode for an adopted runtime whose receipt is not launcher-generated. */
|
|
79
|
+
binding_mode?: string;
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
|
|
@@ -10,7 +10,7 @@ import { basename, join, resolve } from "node:path";
|
|
|
10
10
|
import { type Seat, paths, readYaml, yaml } from "./core";
|
|
11
11
|
import type { HandoverReplacementRequest } from "./handover";
|
|
12
12
|
import { readRuntimeState } from "./runtime-state";
|
|
13
|
-
import { archivedReceiptMatches, recoveryEvidenceMatches } from "./stale-main-recovery";
|
|
13
|
+
import { archivedReceiptMatches, backupIsTranscriptPrefix, recoveryEvidenceMatches } from "./stale-main-recovery";
|
|
14
14
|
import type { Task } from "./tasks";
|
|
15
15
|
|
|
16
16
|
type ReplacementLaunchReceipt = {
|
|
@@ -188,12 +188,8 @@ function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBy
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
|
|
194
|
-
return digest(backup) === relocation.backup_sha256
|
|
195
|
-
&& source.length >= backup.length
|
|
196
|
-
&& source.subarray(0, backup.length).equals(backup);
|
|
191
|
+
function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
|
|
192
|
+
return backupIsTranscriptPrefix({ path: receipt.relocation!.backup, sha256: receipt.relocation!.backup_sha256 }, request.source.pi_session_path);
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
/** A live same-identity adoption is fresh only after its receipt and start. */
|
package/src/handover.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizati
|
|
|
14
14
|
import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
|
|
15
15
|
import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
|
|
16
16
|
import { readRuntimeState } from "./runtime-state";
|
|
17
|
-
import { type StaleMainRecovery, buildStaleMainRecovery, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
|
|
17
|
+
import { type StaleMainRecovery, buildStaleMainRecovery, listedPiAgents, relocationFor, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
|
|
18
18
|
|
|
19
19
|
export type Handover = {
|
|
20
20
|
schema: "atdd-workflow/handover/v1";
|
|
@@ -314,20 +314,9 @@ function assertSinglePiWriter(raw: string) {
|
|
|
314
314
|
type PiSource = Pick<HandoverReplacementRequest["source"], "pi_session" | "pi_session_path">;
|
|
315
315
|
|
|
316
316
|
function assertCanonicalSourceNotLive(raw: string, canonical: PiSource) {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const agent = object(entry);
|
|
321
|
-
if (!agent) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
|
|
322
|
-
if (text(agent.agent) !== "pi") continue;
|
|
323
|
-
const session = object(agent.agent_session);
|
|
324
|
-
const sessionPath = session?.kind === "path" ? text(session.value) : undefined;
|
|
325
|
-
if (!sessionPath) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
|
|
326
|
-
const sessionId = basename(sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)?.[1];
|
|
327
|
-
if (resolve(sessionPath) === resolve(canonical.pi_session_path) || sessionId === canonical.pi_session) {
|
|
328
|
-
throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
|
|
329
|
-
}
|
|
330
|
-
}
|
|
317
|
+
const live = listedPiAgents(raw, "Consumed Path-A replacement").some(({ path }) => path === resolve(canonical.pi_session_path)
|
|
318
|
+
|| basename(path).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)?.[1] === canonical.pi_session);
|
|
319
|
+
if (live) throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
|
|
331
320
|
}
|
|
332
321
|
|
|
333
322
|
async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies, canonical?: PiSource) {
|
|
@@ -387,7 +376,7 @@ function replacementTarget(args: string[], record: Seat, source: HandoverReplace
|
|
|
387
376
|
const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
|
|
388
377
|
if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
|
|
389
378
|
const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
|
|
390
|
-
if (target.herdr_session !== source.herdr_session
|
|
379
|
+
if (target.herdr_session !== source.herdr_session) throw new Error("Path-A replacement target must be the exact released source pane.");
|
|
391
380
|
return target;
|
|
392
381
|
}
|
|
393
382
|
|
|
@@ -402,9 +391,12 @@ async function prepareReplacement(root: string, resolved: string, args: string[]
|
|
|
402
391
|
const handover = await currentReplacementHandover(root, resolved, args);
|
|
403
392
|
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
404
393
|
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
405
|
-
const source = replacementSource(args),
|
|
394
|
+
const source = replacementSource(args), file = handoverReplacementPath(root, resolved);
|
|
395
|
+
const target = replacementTarget(args, record, source);
|
|
396
|
+
const relocation = recover ? await relocationFor(record, source, target, file) : undefined;
|
|
397
|
+
if (!relocation && target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
|
|
406
398
|
if (!supersede && !recover) assertReplacementSource(record, source, legacy);
|
|
407
|
-
return { legacy, supersede, recover, handover, task, record, source, target };
|
|
399
|
+
return { legacy, supersede, recover, handover, task, record, source, target, relocation };
|
|
408
400
|
}
|
|
409
401
|
|
|
410
402
|
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
@@ -745,7 +737,9 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
745
737
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
746
738
|
});
|
|
747
739
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
748
|
-
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
740
|
+
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
741
|
+
(canonical, once) => verifyLegacySource(intent.relocation ? { ...intent.source, ...intent.target } : intent.source, intent.target, authorization.hash, once(dependencies), canonical),
|
|
742
|
+
() => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "agent", "list"))) : undefined;
|
|
749
743
|
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
750
744
|
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
751
745
|
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// - test:atdd-flow:stale-main-recovery-resumed
|
|
5
5
|
// - test:atdd-flow:stale-main-source-exited-path-a
|
|
6
6
|
// - test:atdd-flow:stale-main-s1-exited-recovery
|
|
7
|
+
// - test:atdd-flow:relocated-receipt-stale-main-recovery
|
|
7
8
|
// Runtime: bun
|
|
8
9
|
// Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
|
|
9
10
|
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
-
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
14
|
import { type Runtime, type Seat, runOutput, yaml } from "./core";
|
|
14
15
|
import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
|
|
15
16
|
|
|
@@ -24,15 +25,28 @@ export type StaleMainRecovery = {
|
|
|
24
25
|
/** A superseded active request, preserved by hash; `live_source_exited` proves an active recovery request's own live source Pi was absent from every live Pi agent when a newer source replaced it. */
|
|
25
26
|
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
|
|
26
27
|
live_source: LiveSourceProof;
|
|
28
|
+
/** Present only for a relocated main: the recorded receipt is an operator manual rebinding receipt for a retired placement. */
|
|
29
|
+
relocation?: Relocation;
|
|
27
30
|
};
|
|
28
31
|
|
|
32
|
+
export const MANUAL_REBINDING_SCHEMA = "atdd-flow/manual-pi-runtime-rebinding-receipt/v1";
|
|
33
|
+
export const MANUAL_REBINDING_MODE = "operator-authorized-manual-in-place-adoption";
|
|
34
|
+
|
|
35
|
+
/** The retired placement named by the manual rebinding receipt; lineage only, never a live source. */
|
|
36
|
+
export type Relocation = { mode: string; retired: { herdr_session: string; pane: string } };
|
|
37
|
+
|
|
29
38
|
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
30
39
|
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
31
40
|
|
|
32
41
|
type Run = (argv: string[]) => Promise<string>;
|
|
33
42
|
type Source = HandoverReplacementRequest["source"];
|
|
34
43
|
type Target = HandoverReplacementRequest["target"];
|
|
35
|
-
type PriorReceipt = {
|
|
44
|
+
type PriorReceipt = {
|
|
45
|
+
schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string;
|
|
46
|
+
mode?: string; authorized_by?: string; authorization?: string;
|
|
47
|
+
target?: { cwd?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
48
|
+
backup?: { path?: string; bytes?: number; sha256?: string };
|
|
49
|
+
};
|
|
36
50
|
|
|
37
51
|
const sha256 = (content: string | Uint8Array) => createHash("sha256").update(content).digest("hex");
|
|
38
52
|
|
|
@@ -59,12 +73,22 @@ export function staleCanonicalSource(record: Seat, source: Source, target: Targe
|
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
|
|
62
|
-
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
|
|
76
|
+
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
77
|
+
return relocation ? manualReceiptProvesCanonical(receipt, seat, canonical, relocation) : launchReceiptProvesCanonical(receipt, seat, canonical);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function launchReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
|
|
63
81
|
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
|
|
64
82
|
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
65
83
|
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
function manualReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
87
|
+
return receipt.schema === MANUAL_REBINDING_SCHEMA && receipt.seat === seat && receipt.mode === relocation.mode
|
|
88
|
+
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
89
|
+
&& receipt.herdr_session === relocation.retired.herdr_session && receipt.pane === relocation.retired.pane;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
const archivePath = (root: string, folder: string, seat: string, hash: string) => join(root, ".atdd-flow", folder, encodeURIComponent(seat), `${hash}.yaml`);
|
|
69
93
|
|
|
70
94
|
async function publishOrVerify(file: string, bytes: string) {
|
|
@@ -75,11 +99,11 @@ async function publishOrVerify(file: string, bytes: string) {
|
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
/** Preserve the stale receipt by content hash, create-only; an existing copy must be identical. */
|
|
78
|
-
export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"]): Promise<StaleMainRecovery["prior_receipt"]> {
|
|
102
|
+
export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation): Promise<StaleMainRecovery["prior_receipt"]> {
|
|
79
103
|
let bytes: string;
|
|
80
104
|
try { bytes = await readFile(canonical.launch_receipt, "utf8"); }
|
|
81
105
|
catch { throw new Error("Stale main recovery requires its immutable recorded launch receipt."); }
|
|
82
|
-
if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical)) {
|
|
106
|
+
if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical, relocation)) {
|
|
83
107
|
throw new Error("Stale main recovery recorded launch receipt does not prove the recorded canonical runtime.");
|
|
84
108
|
}
|
|
85
109
|
return { path: canonical.launch_receipt, ...await archiveBytes(root, "stale-main-receipt-archive", seat, bytes) };
|
|
@@ -153,7 +177,7 @@ export async function archivedReceiptMatches(request: HandoverReplacementRequest
|
|
|
153
177
|
try {
|
|
154
178
|
const bytes = await readFile(recovery.prior_receipt.archive, "utf8");
|
|
155
179
|
const priorRequest = !recovery.prior_request || await archiveIntact(recovery.prior_request);
|
|
156
|
-
return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source);
|
|
180
|
+
return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source, recovery.relocation);
|
|
157
181
|
} catch { return false; }
|
|
158
182
|
}
|
|
159
183
|
|
|
@@ -169,15 +193,26 @@ function sessionMatches(runtime: Runtime | undefined, recovery: StaleMainRecover
|
|
|
169
193
|
&& runtime.launch_receipt === canonical.launch_receipt && recovery.prior_receipt.path === runtime.launch_receipt;
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"]) {
|
|
196
|
+
function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
197
|
+
if (relocation) return relocatedTargetMatches(request, canonical, relocation);
|
|
173
198
|
return canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session
|
|
174
199
|
&& canonical.pane === request.target.pane && canonical.pi_session !== request.source.pi_session;
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
|
|
202
|
+
/** A relocated request names the exact recorded Pi at the retired pane, and moves to the current pane that Herdr observed it on. */
|
|
203
|
+
function relocatedTargetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
204
|
+
return relocation.mode === MANUAL_REBINDING_MODE
|
|
205
|
+
&& canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session && canonical.pane === request.target.pane
|
|
206
|
+
&& request.source.pi_session === canonical.pi_session && request.source.pi_session_path === canonical.pi_session_path
|
|
207
|
+
&& request.source.herdr_session === relocation.retired.herdr_session && request.source.pane === relocation.retired.pane
|
|
208
|
+
&& (relocation.retired.herdr_session !== canonical.herdr_session || relocation.retired.pane !== canonical.pane);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof, relocation?: Relocation) {
|
|
212
|
+
const placement = relocation ? request.target : request.source;
|
|
178
213
|
return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
|
|
179
|
-
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session ===
|
|
180
|
-
&& live.pane ===
|
|
214
|
+
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session === placement.herdr_session
|
|
215
|
+
&& live.pane === placement.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
|
|
181
216
|
}
|
|
182
217
|
|
|
183
218
|
function exitMatches(recovery: StaleMainRecovery) {
|
|
@@ -192,7 +227,7 @@ function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
|
192
227
|
|
|
193
228
|
/** Request, canonical source and live-source snapshot must describe one exact target tuple. */
|
|
194
229
|
export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
|
|
195
|
-
return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
|
|
230
|
+
return targetMatches(request, recovery.canonical_source, recovery.relocation) && snapshotMatches(request, recovery.live_source, recovery.relocation) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
/** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
|
|
@@ -201,9 +236,137 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
201
236
|
}
|
|
202
237
|
|
|
203
238
|
/** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
|
|
204
|
-
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
|
|
239
|
+
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target; relocation?: Relocation }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>, agentList: () => Promise<string>): Promise<StaleMainRecovery> {
|
|
240
|
+
if (intent.relocation) return buildRelocatedStaleMainRecovery(root, seat, intent, intent.relocation, active, { verify: () => verify(intent.source, agentListOnce()), agentList });
|
|
205
241
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
206
242
|
const once = agentListOnce(), live_source = await verify(canonical, once);
|
|
207
243
|
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async (source) => { await verify(source, once); } });
|
|
208
244
|
return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
|
|
209
245
|
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
const refuse = (reason: string) => new Error(`Stale main relocation receipt recovery refused: ${reason}.`);
|
|
249
|
+
|
|
250
|
+
/** True when the seat records a manual binding_mode or its launch receipt is a manual rebinding receipt, rather than a launcher receipt. */
|
|
251
|
+
export async function isManualRebindingReceipt(record: Seat) {
|
|
252
|
+
const path = record.runtime?.launch_receipt;
|
|
253
|
+
if (!path) return false;
|
|
254
|
+
if (record.runtime?.binding_mode !== undefined) return true;
|
|
255
|
+
try { return yaml.parse<PriorReceipt>(await readFile(path, "utf8")).schema === MANUAL_REBINDING_SCHEMA; } catch { return false; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A backup file must hash to its recorded digest (and size, when recorded) and be the exact byte prefix of the adopted session transcript. */
|
|
259
|
+
export async function backupIsTranscriptPrefix(backup: { path?: string; sha256?: string; bytes?: number }, transcriptPath: string) {
|
|
260
|
+
try {
|
|
261
|
+
const [saved, transcript] = await Promise.all([readFile(backup.path!), readFile(transcriptPath)]);
|
|
262
|
+
return sha256(saved) === backup.sha256 && (backup.bytes === undefined || backup.bytes === saved.length)
|
|
263
|
+
&& transcript.length >= saved.length && transcript.subarray(0, saved.length).equals(saved);
|
|
264
|
+
} catch { return false; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** An existing stale active request must retain exactly the retired placement of the recorded Pi. */
|
|
268
|
+
async function priorRequestIsRetiredLineage(file: string, receipt: PriorReceipt) {
|
|
269
|
+
let bytes: string;
|
|
270
|
+
try { bytes = await readFile(file, "utf8"); } catch { return true; }
|
|
271
|
+
try {
|
|
272
|
+
const { source, target } = yaml.parse<HandoverReplacementRequest>(bytes);
|
|
273
|
+
return source.pi_session === receipt.pi_session && source.pi_session_path === receipt.pi_session_path
|
|
274
|
+
&& [source, target].every((placement) => placement.herdr_session === receipt.herdr_session && placement.pane === receipt.pane);
|
|
275
|
+
} catch { return false; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type Check = [boolean, string];
|
|
279
|
+
const firstFailure = (checks: Check[]) => checks.find(([ok]) => !ok)?.[1];
|
|
280
|
+
|
|
281
|
+
/** The recorded receipt is a complete operator-authorized manual rebinding receipt whose mode equals the seat binding_mode. */
|
|
282
|
+
function authorityFailure(receipt: PriorReceipt, record: Seat) {
|
|
283
|
+
return firstFailure([
|
|
284
|
+
[receipt.schema === MANUAL_REBINDING_SCHEMA, "the recorded receipt is not a manual rebinding receipt"],
|
|
285
|
+
[receipt.seat === record.address, "the receipt names another seat"],
|
|
286
|
+
[receipt.mode === MANUAL_REBINDING_MODE && record.runtime?.binding_mode === receipt.mode, "the receipt mode must equal the seat binding_mode"],
|
|
287
|
+
[receipt.authorized_by === "operator@desk" && Boolean(receipt.authorization), "the receipt lacks operator@desk authorization"],
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The receipt describes the exact recorded Pi, the canonical worktree and one retired placement. */
|
|
292
|
+
function identityFailure(receipt: PriorReceipt, record: Seat, session: string, sessionPath: string) {
|
|
293
|
+
const placement = receipt.target;
|
|
294
|
+
return firstFailure([
|
|
295
|
+
[receipt.pi_session === session && receipt.pi_session_path === sessionPath, "the receipt does not name the exact recorded Pi session"],
|
|
296
|
+
[placement?.pi_session === session && placement.pi_session_path === sessionPath, "the receipt target does not name the exact recorded Pi session"],
|
|
297
|
+
[Boolean(placement?.cwd) && resolve(placement!.cwd!) === resolve(record.worktree), "the receipt cwd is not the canonical main worktree"],
|
|
298
|
+
[Boolean(receipt.pane) && placement?.herdr_session === receipt.herdr_session && placement.pane === receipt.pane, "the receipt placement is inconsistent"],
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The request moves the exact recorded Pi from the retired placement to the seat's current locator. */
|
|
303
|
+
function requestFailure(receipt: PriorReceipt, source: Source, target: Target, recorded: { session: string; sessionPath: string; locator: { session: string; pane: string } }) {
|
|
304
|
+
return firstFailure([
|
|
305
|
+
[source.pi_session === recorded.session && source.pi_session_path === recorded.sessionPath, "the request source is not the exact recorded Pi"],
|
|
306
|
+
[source.herdr_session === receipt.herdr_session && source.pane === receipt.pane, "the request source must be the receipt's retired placement"],
|
|
307
|
+
[target.herdr_session === recorded.locator.session && target.pane === recorded.locator.pane, "the request target must be the seat's current locator"],
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validate a relocated main before any authorization or Herdr evidence is consulted: the recorded receipt must be a
|
|
313
|
+
* complete operator manual rebinding receipt whose mode equals the seat binding_mode, for the exact recorded Pi, whose
|
|
314
|
+
* backup is a prefix of the adopted transcript, and whose retired placement is the request source and the stale request's
|
|
315
|
+
* placement while the request target is the seat's current locator. Returns the lineage to record.
|
|
316
|
+
*/
|
|
317
|
+
export async function assertRelocatedBinding(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation> {
|
|
318
|
+
const { locator, session, sessionPath, receipt: path } = completeRuntime(record.runtime);
|
|
319
|
+
let receipt: PriorReceipt;
|
|
320
|
+
try { receipt = yaml.parse<PriorReceipt>(await readFile(path, "utf8")); } catch { throw refuse("the recorded relocation receipt is unreadable"); }
|
|
321
|
+
const failure = authorityFailure(receipt, record) ?? identityFailure(receipt, record, session, sessionPath)
|
|
322
|
+
?? requestFailure(receipt, source, target, { session, sessionPath, locator })
|
|
323
|
+
?? firstFailure([
|
|
324
|
+
[await backupIsTranscriptPrefix(receipt.backup ?? {}, receipt.pi_session_path!), "the receipt backup is missing, mismatched, or not a prefix of the adopted transcript"],
|
|
325
|
+
[await priorRequestIsRetiredLineage(activeFile, receipt), "the stale active request does not retain the receipt's retired placement"],
|
|
326
|
+
]);
|
|
327
|
+
if (failure) throw refuse(failure);
|
|
328
|
+
return { mode: receipt.mode!, retired: { herdr_session: receipt.herdr_session!, pane: receipt.pane! } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Build the relocated recovery record: the live source (injected) is the exact recorded Pi at the current locator. */
|
|
332
|
+
export async function buildRelocatedStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, relocation: Relocation, active: { file: string }, observe: { verify: () => Promise<LiveSourceProof>; agentList: () => Promise<string> }): Promise<StaleMainRecovery> {
|
|
333
|
+
const { locator, session, sessionPath, receipt } = completeRuntime(intent.record.runtime);
|
|
334
|
+
const canonical = { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: intent.target.cwd, launch_receipt: receipt };
|
|
335
|
+
let live_source: LiveSourceProof;
|
|
336
|
+
try {
|
|
337
|
+
live_source = await observe.verify();
|
|
338
|
+
assertRelocatedAgentList(await observe.agentList(), intent.source, intent.target, relocation.retired);
|
|
339
|
+
} catch (error) { throw refuse((error as Error).message.replace(/\.$/, "")); }
|
|
340
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, () => false, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async () => {} });
|
|
341
|
+
const prior_receipt = await archiveStaleReceipt(root, seat, canonical, relocation);
|
|
342
|
+
return { canonical_source: canonical, prior_receipt, ...(prior_request ? { prior_request } : {}), live_source, relocation };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Pi agents from one complete global Herdr agent list, each with its pane and exact session path; `who` names the refusing operation. */
|
|
346
|
+
export function listedPiAgents(raw: string, who: string) {
|
|
347
|
+
const incomplete = () => new Error(`${who} requires complete global Herdr agent-list evidence.`);
|
|
348
|
+
const parsed = JSON.parse(raw) as { result?: { agents?: unknown }; agents?: unknown };
|
|
349
|
+
const agents = (parsed.result ?? parsed).agents;
|
|
350
|
+
if (!Array.isArray(agents)) throw incomplete();
|
|
351
|
+
return agents.filter((entry) => {
|
|
352
|
+
if (!entry || typeof entry !== "object") throw incomplete();
|
|
353
|
+
return (entry as { agent?: unknown }).agent === "pi";
|
|
354
|
+
}).map((entry) => {
|
|
355
|
+
const { pane_id, agent_session } = entry as { pane_id?: string; agent_session?: { kind?: string; value?: string } };
|
|
356
|
+
if (agent_session?.kind !== "path" || !agent_session.value) throw incomplete();
|
|
357
|
+
return { pane: pane_id, path: resolve(agent_session.value) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A relocated recovery requires the exact recorded Pi once, at the current pane, and no Pi at the retired pane. */
|
|
362
|
+
export function assertRelocatedAgentList(raw: string, source: Pick<Source, "pi_session_path">, current: { pane: string }, retired: { pane: string }) {
|
|
363
|
+
const agents = listedPiAgents(raw, "Relocated recovery"), recorded = agents.filter((agent) => agent.path === resolve(source.pi_session_path));
|
|
364
|
+
if (agents.some((agent) => agent.pane === retired.pane)) throw new Error("A Pi is live at the retired relocation pane.");
|
|
365
|
+
if (recorded.some((agent) => agent.pane !== current.pane)) throw new Error("The recorded Pi is also live outside the current pane.");
|
|
366
|
+
if (recorded.length !== 1) throw new Error("The recorded Pi must be listed exactly once at the current pane.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A target pane that differs from the source pane is a relocation only when the seat records a manual binding; otherwise undefined. */
|
|
370
|
+
export async function relocationFor(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
371
|
+
return target.pane !== source.pane && await isManualRebindingReceipt(record) ? assertRelocatedBinding(record, source, target, activeFile) : undefined;
|
|
372
|
+
}
|