@afokapu/atdd-flow 0.3.72 → 0.3.73
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/stale-main-recovery.ts +28 -8
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Tested-By:
|
|
3
3
|
// - test:atdd-flow:stale-main-binding-recovery
|
|
4
4
|
// - test:atdd-flow:stale-main-recovery-resumed
|
|
5
|
+
// - test:atdd-flow:stale-main-source-exited-path-a
|
|
5
6
|
// Runtime: bun
|
|
6
7
|
// Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
|
|
7
8
|
|
|
@@ -20,10 +21,13 @@ export type StaleMainRecovery = {
|
|
|
20
21
|
canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
|
|
21
22
|
prior_receipt: { path: string; sha256: string; archive: string };
|
|
22
23
|
/** A superseded active request that no longer matches the recorded runtime, preserved by hash. */
|
|
23
|
-
prior_request?: { path: string; sha256: string; archive: string };
|
|
24
|
+
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited };
|
|
24
25
|
live_source: LiveSourceProof;
|
|
25
26
|
};
|
|
26
27
|
|
|
28
|
+
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
29
|
+
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
30
|
+
|
|
27
31
|
type Source = HandoverReplacementRequest["source"];
|
|
28
32
|
type Target = HandoverReplacementRequest["target"];
|
|
29
33
|
type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
@@ -86,17 +90,28 @@ async function archiveBytes(root: string, folder: string, seat: string, bytes: s
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
/**
|
|
89
|
-
* An existing active request may be replaced
|
|
90
|
-
*
|
|
93
|
+
* An existing active request may be replaced when it no longer matches the recorded runtime. A request that still
|
|
94
|
+
* matches is replaceable only when its recorded source is exactly the stale canonical source, which the caller has
|
|
95
|
+
* already proven absent from every live Pi agent. The old bytes are archived by hash with that source-exit evidence.
|
|
91
96
|
*/
|
|
92
|
-
async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean) {
|
|
97
|
+
async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; verifiedAt: string }) {
|
|
93
98
|
let bytes: string;
|
|
94
99
|
try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
|
|
95
100
|
const prior = yaml.parse<HandoverReplacementRequest>(bytes);
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
const matching = isLive(prior), exited = matching && sourceIsCanonical(prior.source, exit.canonical);
|
|
102
|
+
if (prior.seat !== seat || (matching && !exited)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime or whose matching source has exited.");
|
|
103
|
+
const source_exited = exited ? sourceExited(exit.canonical, exit.verifiedAt) : undefined;
|
|
104
|
+
return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes), ...(source_exited ? { source_exited } : {}) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sourceIsCanonical(source: Source, canonical: StaleMainRecovery["canonical_source"]) {
|
|
108
|
+
return source.pi_session === canonical.pi_session && source.pi_session_path === canonical.pi_session_path
|
|
109
|
+
&& source.herdr_session === canonical.herdr_session && source.pane === canonical.pane;
|
|
98
110
|
}
|
|
99
111
|
|
|
112
|
+
const sourceExited = (canonical: StaleMainRecovery["canonical_source"], verified_at: string): SourceExited =>
|
|
113
|
+
({ pi_session: canonical.pi_session, pi_session_path: canonical.pi_session_path, herdr_session: canonical.herdr_session, pane: canonical.pane, verified_at });
|
|
114
|
+
|
|
100
115
|
/** Swap the active request through a staged file so readers never observe a partial request. */
|
|
101
116
|
export async function replaceActiveRequest(file: string, content: string) {
|
|
102
117
|
const staged = `${file}.${crypto.randomUUID()}.tmp`;
|
|
@@ -141,13 +156,18 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
|
|
|
141
156
|
&& live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
|
|
142
157
|
}
|
|
143
158
|
|
|
159
|
+
function exitMatches(recovery: StaleMainRecovery) {
|
|
160
|
+
const exited = recovery.prior_request?.source_exited;
|
|
161
|
+
return !exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at));
|
|
162
|
+
}
|
|
163
|
+
|
|
144
164
|
function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
145
165
|
return !prior || (/^[a-f0-9]{64}$/i.test(prior.sha256) && basename(prior.archive) === `${prior.sha256}.yaml`);
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
/** Request, canonical source and live-source snapshot must describe one exact target tuple. */
|
|
149
169
|
export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
|
|
150
|
-
return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request);
|
|
170
|
+
return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
/** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
|
|
@@ -159,6 +179,6 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
159
179
|
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (canonical: StaleMainRecovery["canonical_source"]) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
|
|
160
180
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
161
181
|
const live_source = await verify(canonical);
|
|
162
|
-
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive);
|
|
182
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, verifiedAt: live_source.verified_at });
|
|
163
183
|
return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
|
|
164
184
|
}
|