@afokapu/atdd-flow 0.3.78 → 0.3.80
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/handover.ts +2 -1
- package/src/stale-main-recovery.ts +101 -14
package/package.json
CHANGED
package/src/handover.ts
CHANGED
|
@@ -739,7 +739,8 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
739
739
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
740
740
|
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
741
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"))
|
|
742
|
+
() => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "agent", "list")),
|
|
743
|
+
{ paneList: () => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "pane", "list")), authorizationHash: authorization.hash, at: () => dependencies.at?.() ?? now() }) : undefined;
|
|
743
744
|
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
744
745
|
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
745
746
|
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
@@ -24,21 +24,27 @@ export type StaleMainRecovery = {
|
|
|
24
24
|
prior_receipt: { path: string; sha256: string; archive: string };
|
|
25
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. */
|
|
26
26
|
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** Absent only for a pane-gone relocation, whose source is proven absent rather than live. */
|
|
28
|
+
live_source?: LiveSourceProof;
|
|
29
|
+
/** Present only for a relocated main: an operator manual rebinding receipt, or a launcher receipt whose retired pane is gone. */
|
|
29
30
|
relocation?: Relocation;
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
const LAUNCH_RECEIPT_SCHEMA = "atdd-flow/pi-runtime-launch-receipt/v1";
|
|
32
34
|
export const MANUAL_REBINDING_SCHEMA = "atdd-flow/manual-pi-runtime-rebinding-receipt/v1";
|
|
33
35
|
export const MANUAL_REBINDING_MODE = "operator-authorized-manual-in-place-adoption";
|
|
34
36
|
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
37
|
+
/** Relocation mode of a launcher-generated receipt whose canonical pane and exact Pi are both absent from Herdr. */
|
|
38
|
+
export const PANE_GONE_MODE = "launcher-receipt-pane-gone";
|
|
39
|
+
|
|
40
|
+
/** The retired placement named by the receipt; lineage only, never a live source. A pane-gone relocation also records the global absence proof. */
|
|
41
|
+
export type Relocation = { mode: string; retired: { herdr_session: string; pane: string }; absence?: { authorization_hash: string; verified_at: string } };
|
|
37
42
|
|
|
38
43
|
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
39
44
|
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
40
45
|
|
|
41
46
|
type Run = (argv: string[]) => Promise<string>;
|
|
47
|
+
type Recorded = { session: string; sessionPath: string; locator: { session: string; pane: string } };
|
|
42
48
|
type Source = HandoverReplacementRequest["source"];
|
|
43
49
|
type Target = HandoverReplacementRequest["target"];
|
|
44
50
|
type PriorReceipt = {
|
|
@@ -74,11 +80,12 @@ export function staleCanonicalSource(record: Seat, source: Source, target: Targe
|
|
|
74
80
|
|
|
75
81
|
/** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
|
|
76
82
|
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
83
|
+
if (relocation?.mode === PANE_GONE_MODE) return launchReceiptProvesCanonical(receipt, seat, { ...canonical, ...relocation.retired });
|
|
77
84
|
return relocation ? manualReceiptProvesCanonical(receipt, seat, canonical, relocation) : launchReceiptProvesCanonical(receipt, seat, canonical);
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
function launchReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
|
|
81
|
-
return receipt.schema ===
|
|
88
|
+
return receipt.schema === LAUNCH_RECEIPT_SCHEMA && receipt.seat === seat
|
|
82
89
|
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
83
90
|
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
84
91
|
}
|
|
@@ -201,14 +208,24 @@ function targetMatches(request: HandoverReplacementRequest, canonical: StaleMain
|
|
|
201
208
|
|
|
202
209
|
/** A relocated request names the exact recorded Pi at the retired pane, and moves to the current pane that Herdr observed it on. */
|
|
203
210
|
function relocatedTargetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
204
|
-
return relocation.mode
|
|
211
|
+
return [MANUAL_REBINDING_MODE, PANE_GONE_MODE].includes(relocation.mode)
|
|
205
212
|
&& canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session && canonical.pane === request.target.pane
|
|
206
213
|
&& request.source.pi_session === canonical.pi_session && request.source.pi_session_path === canonical.pi_session_path
|
|
207
214
|
&& request.source.herdr_session === relocation.retired.herdr_session && request.source.pane === relocation.retired.pane
|
|
208
215
|
&& (relocation.retired.herdr_session !== canonical.herdr_session || relocation.retired.pane !== canonical.pane);
|
|
209
216
|
}
|
|
210
217
|
|
|
211
|
-
|
|
218
|
+
/** A pane-gone relocation has no live source: its absence proof must be bound to this request's authorization. */
|
|
219
|
+
function absenceMatches(request: HandoverReplacementRequest, relocation: Relocation) {
|
|
220
|
+
return relocation.absence?.authorization_hash === request.authorization.hash && Boolean(relocation.absence.verified_at);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof | undefined, relocation?: Relocation) {
|
|
224
|
+
if (relocation?.mode === PANE_GONE_MODE) return !live && absenceMatches(request, relocation);
|
|
225
|
+
return live !== undefined && liveSnapshotMatches(request, live, relocation);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function liveSnapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof, relocation?: Relocation) {
|
|
212
229
|
const placement = relocation ? request.target : request.source;
|
|
213
230
|
return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
|
|
214
231
|
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session === placement.herdr_session
|
|
@@ -218,7 +235,7 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
|
|
|
218
235
|
function exitMatches(recovery: StaleMainRecovery) {
|
|
219
236
|
const exited = recovery.prior_request?.source_exited, replaced = recovery.prior_request?.live_source_exited;
|
|
220
237
|
return (!exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at)))
|
|
221
|
-
&& (!replaced || (!sameSource(replaced, recovery.live_source) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
|
|
238
|
+
&& (!replaced || (!(recovery.live_source && sameSource(replaced, recovery.live_source)) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
@@ -236,7 +253,8 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
236
253
|
}
|
|
237
254
|
|
|
238
255
|
/** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
|
|
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
|
|
256
|
+
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>, gone: PaneGoneObservation): Promise<StaleMainRecovery> {
|
|
257
|
+
if (intent.relocation?.mode === PANE_GONE_MODE) return buildPaneGoneStaleMainRecovery(root, seat, intent, intent.relocation, active, { agentList, ...gone });
|
|
240
258
|
if (intent.relocation) return buildRelocatedStaleMainRecovery(root, seat, intent, intent.relocation, active, { verify: () => verify(intent.source, agentListOnce()), agentList });
|
|
241
259
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
242
260
|
const once = agentListOnce(), live_source = await verify(canonical, once);
|
|
@@ -247,12 +265,18 @@ export async function buildStaleMainRecovery(root: string, seat: string, intent:
|
|
|
247
265
|
|
|
248
266
|
const refuse = (reason: string) => new Error(`Stale main relocation receipt recovery refused: ${reason}.`);
|
|
249
267
|
|
|
250
|
-
/**
|
|
268
|
+
/** The recorded receipt, or undefined when it is unreadable or malformed. */
|
|
269
|
+
async function readReceipt(path: string) {
|
|
270
|
+
try { return yaml.parse<PriorReceipt>(await readFile(path, "utf8")); } catch { return undefined; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A launcher receipt is never manual, even if the seat retains a binding_mode; any other receipt is manual when it has the manual schema or the seat records a binding_mode. */
|
|
251
274
|
export async function isManualRebindingReceipt(record: Seat) {
|
|
252
275
|
const path = record.runtime?.launch_receipt;
|
|
253
276
|
if (!path) return false;
|
|
254
|
-
|
|
255
|
-
|
|
277
|
+
const schema = (await readReceipt(path))?.schema;
|
|
278
|
+
if (schema === LAUNCH_RECEIPT_SCHEMA) return false;
|
|
279
|
+
return record.runtime?.binding_mode !== undefined || schema === MANUAL_REBINDING_SCHEMA;
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
/** A backup file must hash to its recorded digest (and size, when recorded) and be the exact byte prefix of the adopted session transcript. */
|
|
@@ -366,7 +390,70 @@ export function assertRelocatedAgentList(raw: string, source: Pick<Source, "pi_s
|
|
|
366
390
|
if (recorded.length !== 1) throw new Error("The recorded Pi must be listed exactly once at the current pane.");
|
|
367
391
|
}
|
|
368
392
|
|
|
369
|
-
/** A target pane that differs from the source pane is a relocation only
|
|
393
|
+
/** A target pane that differs from the source pane is a relocation only for a manual binding or an exact pane-gone launcher receipt; otherwise undefined. */
|
|
370
394
|
export async function relocationFor(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
371
|
-
|
|
395
|
+
if (target.pane === source.pane) return undefined;
|
|
396
|
+
return await isManualRebindingReceipt(record) ? assertRelocatedBinding(record, source, target, activeFile) : assertPaneGoneBinding(record, source, target, activeFile);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function readLauncherReceipt(path: string) {
|
|
400
|
+
const receipt = await readReceipt(path);
|
|
401
|
+
return receipt?.schema === LAUNCH_RECEIPT_SCHEMA ? receipt : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The launcher receipt names this seat's exact recorded Pi at a pane other than the seat's current locator. */
|
|
405
|
+
function receiptNamesRetiredPane(receipt: PriorReceipt, record: Seat, recorded: Recorded) {
|
|
406
|
+
return receipt.seat === record.address && receipt.pi_session === recorded.session && receipt.pi_session_path === recorded.sessionPath
|
|
407
|
+
&& Boolean(receipt.herdr_session && receipt.pane) && (receipt.herdr_session !== recorded.locator.session || receipt.pane !== recorded.locator.pane);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The request moves the exact recorded Pi from the receipt's retired pane to the seat's current locator. */
|
|
411
|
+
function requestMovesRetiredPi(receipt: PriorReceipt, source: Source, target: Target, recorded: Recorded) {
|
|
412
|
+
return source.pi_session === recorded.session && source.pi_session_path === recorded.sessionPath
|
|
413
|
+
&& source.herdr_session === receipt.herdr_session && source.pane === receipt.pane
|
|
414
|
+
&& target.herdr_session === recorded.locator.session && target.pane === recorded.locator.pane;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A launcher-generated receipt whose retired canonical pane is the request source while the request target is the seat's current locator; otherwise undefined, so the ordinary released-source-pane guard refuses. */
|
|
418
|
+
async function assertPaneGoneBinding(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
419
|
+
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
420
|
+
if (!runtime?.launch_receipt || typeof locator === "string" || !locator) return undefined;
|
|
421
|
+
const receipt = await readLauncherReceipt(runtime.launch_receipt);
|
|
422
|
+
if (!receipt || !runtime.pi_session || !runtime.pi_session_path) return undefined;
|
|
423
|
+
const recorded = { session: runtime.pi_session, sessionPath: runtime.pi_session_path, locator };
|
|
424
|
+
if (!receiptNamesRetiredPane(receipt, record, recorded) || !requestMovesRetiredPi(receipt, source, target, recorded) || !await priorRequestIsRetiredLineage(activeFile, receipt)) return undefined;
|
|
425
|
+
return { mode: PANE_GONE_MODE, retired: { herdr_session: receipt.herdr_session!, pane: receipt.pane! } };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Pane ids from one complete global Herdr pane list. */
|
|
429
|
+
function listedPaneIds(raw: string) {
|
|
430
|
+
const parsed = JSON.parse(raw) as { result?: { panes?: unknown }; panes?: unknown };
|
|
431
|
+
const panes = (parsed.result ?? parsed).panes;
|
|
432
|
+
const ids = Array.isArray(panes) ? panes.map((pane) => (pane as { pane_id?: unknown } | null)?.pane_id) : undefined;
|
|
433
|
+
if (!ids || ids.some((id) => typeof id !== "string")) throw new Error("Pane-gone recovery requires complete global Herdr pane-list evidence.");
|
|
434
|
+
return ids as string[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const piSessionId = (path: string) => 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];
|
|
438
|
+
|
|
439
|
+
/** Pane-gone recovery requires the retired pane and the exact recorded Pi to be absent from one complete global pane list and agent list. */
|
|
440
|
+
export function assertPaneGone(agentsRaw: string, panesRaw: string, source: Pick<Source, "pi_session" | "pi_session_path">, retired: { pane: string }) {
|
|
441
|
+
if (listedPaneIds(panesRaw).includes(retired.pane)) throw new Error("The retired canonical pane is still listed by Herdr.");
|
|
442
|
+
const agents = listedPiAgents(agentsRaw, "Pane-gone recovery");
|
|
443
|
+
if (agents.some((agent) => agent.pane === retired.pane)) throw new Error("A Pi is live at the retired canonical pane.");
|
|
444
|
+
if (agents.some(({ path }) => path === resolve(source.pi_session_path) || piSessionId(path) === source.pi_session)) throw new Error("The exact recorded Pi is still a live Pi agent.");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Herdr pane-list evidence plus the authorization and clock that bind the absence proof to one request. */
|
|
448
|
+
export type PaneGoneObservation = { paneList: () => Promise<string>; authorizationHash: string; at: () => string };
|
|
449
|
+
|
|
450
|
+
async function buildPaneGoneStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, pane: Relocation, active: { file: string }, observe: { agentList: () => Promise<string> } & PaneGoneObservation): Promise<StaleMainRecovery> {
|
|
451
|
+
const { locator, session, sessionPath, receipt } = completeRuntime(intent.record.runtime);
|
|
452
|
+
const canonical = { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: intent.target.cwd, launch_receipt: receipt };
|
|
453
|
+
try { assertPaneGone(await observe.agentList(), await observe.paneList(), intent.source, pane.retired); }
|
|
454
|
+
catch (error) { throw new Error(`Path-A replacement target must be the exact released source pane; pane-gone recovery refused: ${(error as Error).message}`); }
|
|
455
|
+
const verified_at = observe.at(), relocation: Relocation = { ...pane, absence: { authorization_hash: observe.authorizationHash, verified_at } };
|
|
456
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, () => false, { canonical, source: intent.source, verifiedAt: verified_at, assertAbsent: async () => {} });
|
|
457
|
+
const prior_receipt = await archiveStaleReceipt(root, seat, canonical, relocation);
|
|
458
|
+
return { canonical_source: canonical, prior_receipt, ...(prior_request ? { prior_request } : {}), relocation };
|
|
372
459
|
}
|