@afokapu/atdd-flow 0.3.54 → 0.3.56
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/exact-adoption-recovery.ts +234 -0
- package/src/handover.ts +24 -2
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// URN: component:flow:e006:exact-adoption-recovery:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:exact-adoption-resumed-proof-and-relaunch
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Classify the immutable evidence for one exact Path-A adoption and recovery.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
9
|
+
import { basename, join, resolve } from "node:path";
|
|
10
|
+
import { type Seat, paths, readYaml, yaml } from "./core";
|
|
11
|
+
import type { HandoverReplacementRequest } from "./handover";
|
|
12
|
+
import { readRuntimeState } from "./runtime-state";
|
|
13
|
+
import type { Task } from "./tasks";
|
|
14
|
+
|
|
15
|
+
type ReplacementLaunchReceipt = {
|
|
16
|
+
schema?: string;
|
|
17
|
+
seat?: string;
|
|
18
|
+
tasks?: string[];
|
|
19
|
+
pi_session?: string;
|
|
20
|
+
pi_session_path?: string;
|
|
21
|
+
herdr_session?: string;
|
|
22
|
+
pane?: string;
|
|
23
|
+
handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
|
|
24
|
+
relocation?: {
|
|
25
|
+
source_herdr_session?: string;
|
|
26
|
+
source_pane?: string;
|
|
27
|
+
authorization?: { id?: string; hash?: string };
|
|
28
|
+
backup?: string;
|
|
29
|
+
backup_sha256?: string;
|
|
30
|
+
};
|
|
31
|
+
created_at?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type CanonicalSource = NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
|
|
35
|
+
type TaskEligibility = (task: Task) => boolean | Promise<boolean>;
|
|
36
|
+
|
|
37
|
+
/** Hash bytes exactly as the immutable records were published. */
|
|
38
|
+
function digest(content: string | Uint8Array) {
|
|
39
|
+
return createHash("sha256").update(content).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Match both the authorization content and its separately stored digest. */
|
|
43
|
+
async function exactAuthorizationMatches(root: string, request: HandoverReplacementRequest) {
|
|
44
|
+
const id = request.authorization.id;
|
|
45
|
+
const folder = join(root, ".atdd-flow", "exact-session-adoption-authorizations");
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(join(folder, `${id}.yaml`), "utf8");
|
|
48
|
+
const storedHash = (await readFile(join(folder, `${id}.sha256`), "utf8")).trim();
|
|
49
|
+
const record = object(yaml.parse<unknown>(raw));
|
|
50
|
+
const source = object(record?.source), target = object(record?.target);
|
|
51
|
+
const observed = {
|
|
52
|
+
storedHash, contentHash: digest(raw), schema: record?.schema, id: record?.id, issuedBy: record?.issued_by,
|
|
53
|
+
seat: record?.seat, session: record?.pi_session, sessionPath: record?.pi_session_path,
|
|
54
|
+
sourceSession: source?.herdr_session, sourcePane: source?.pane,
|
|
55
|
+
targetSession: target?.herdr_session, targetPane: target?.pane, targetCwd: target?.cwd,
|
|
56
|
+
};
|
|
57
|
+
const expected = {
|
|
58
|
+
storedHash: request.authorization.hash, contentHash: request.authorization.hash,
|
|
59
|
+
schema: "atdd-flow/exact-session-adoption-authorization/v1", id, issuedBy: "operator@desk",
|
|
60
|
+
seat: request.seat, session: request.source.pi_session, sessionPath: request.source.pi_session_path,
|
|
61
|
+
sourceSession: request.source.herdr_session, sourcePane: request.source.pane,
|
|
62
|
+
targetSession: request.target.herdr_session, targetPane: request.target.pane, targetCwd: request.target.cwd,
|
|
63
|
+
};
|
|
64
|
+
return JSON.stringify(observed) === JSON.stringify(expected);
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
69
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Validate the prior canonical receipt without trusting the active adopted receipt. */
|
|
73
|
+
function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, seat: string, canonical: CanonicalSource) {
|
|
74
|
+
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
|
|
75
|
+
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
76
|
+
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Bind the archived prior receipt to its archived request and successor. */
|
|
80
|
+
function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: CanonicalSource) {
|
|
81
|
+
return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
|
|
82
|
+
&& receipt.handover_replacement?.successor_session === canonical.pi_session
|
|
83
|
+
&& JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validConsumedRequest(request: HandoverReplacementRequest, seat: string) {
|
|
87
|
+
return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === seat
|
|
88
|
+
&& Boolean(request.task && request.handover?.id && request.handover.sha256);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rebuild and compare every archived supersession byte and identity. */
|
|
92
|
+
async function exactArchiveMatches(request: HandoverReplacementRequest) {
|
|
93
|
+
const proof = request.supersession;
|
|
94
|
+
if (!proof) return false;
|
|
95
|
+
try {
|
|
96
|
+
const requestPath = join(proof.prior_request.archive, "request.yaml");
|
|
97
|
+
const receiptPath = join(proof.prior_request.archive, "launch-receipt.yaml");
|
|
98
|
+
const manifestPath = join(proof.prior_request.archive, "supersession.yaml");
|
|
99
|
+
const [files, requestBytes, receiptBytes, manifestBytes] = await Promise.all([
|
|
100
|
+
readdir(proof.prior_request.archive), readFile(requestPath, "utf8"),
|
|
101
|
+
readFile(receiptPath, "utf8"), readFile(manifestPath, "utf8"),
|
|
102
|
+
]);
|
|
103
|
+
const archivedRequest = yaml.parse<HandoverReplacementRequest>(requestBytes);
|
|
104
|
+
const archivedReceipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
|
|
105
|
+
const manifest = yaml.print({
|
|
106
|
+
schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: request.seat,
|
|
107
|
+
prior_request: {
|
|
108
|
+
path: proof.prior_request.path, sha256: proof.prior_request.sha256, task: proof.prior_request.task,
|
|
109
|
+
handover: proof.prior_request.handover, handover_sha256: proof.prior_request.handover_sha256,
|
|
110
|
+
},
|
|
111
|
+
prior_receipt: proof.prior_receipt,
|
|
112
|
+
});
|
|
113
|
+
return [
|
|
114
|
+
files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n"),
|
|
115
|
+
basename(proof.prior_request.archive) === proof.prior_request.sha256,
|
|
116
|
+
digest(requestBytes) === proof.prior_request.sha256,
|
|
117
|
+
digest(receiptBytes) === proof.prior_receipt.sha256,
|
|
118
|
+
manifestBytes === manifest,
|
|
119
|
+
validConsumedRequest(archivedRequest, request.seat),
|
|
120
|
+
archivedRequest.task === proof.prior_request.task,
|
|
121
|
+
archivedRequest.handover.id === proof.prior_request.handover,
|
|
122
|
+
archivedRequest.handover.sha256 === proof.prior_request.handover_sha256,
|
|
123
|
+
receiptMatchesCanonical(archivedReceipt, request.seat, proof.canonical_source),
|
|
124
|
+
receiptProvesRequest(archivedReceipt, archivedRequest, proof.canonical_source),
|
|
125
|
+
].every(Boolean);
|
|
126
|
+
} catch { return false; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A single authorization may identify exactly one adoption receipt. */
|
|
130
|
+
async function uniqueAdoptionReceipt(root: string, authorizationId: string, expectedPath: string) {
|
|
131
|
+
try {
|
|
132
|
+
const folder = join(root, ".atdd-flow", "runtime-launch");
|
|
133
|
+
const candidates: string[] = [];
|
|
134
|
+
for (const file of await readdir(folder)) {
|
|
135
|
+
if (!file.endsWith(".yaml")) continue;
|
|
136
|
+
const path = join(folder, file);
|
|
137
|
+
try {
|
|
138
|
+
const receipt = yaml.parse<ReplacementLaunchReceipt>(await readFile(path, "utf8"));
|
|
139
|
+
if (receipt.relocation?.authorization?.id === authorizationId) candidates.push(path);
|
|
140
|
+
} catch { /* Malformed receipts cannot prove exact adoption. */ }
|
|
141
|
+
}
|
|
142
|
+
return candidates.length === 1 && resolve(candidates[0]!) === resolve(expectedPath);
|
|
143
|
+
} catch { return false; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The failed adopted runtime retains source identity at the exact target pane. */
|
|
147
|
+
function exactAdoptionRuntimeMatches(record: Seat, request: HandoverReplacementRequest) {
|
|
148
|
+
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
149
|
+
const observed = {
|
|
150
|
+
supersession: Boolean(request.supersession), application: runtime?.application, wake: runtime?.wake,
|
|
151
|
+
session: runtime?.pi_session, sessionPath: runtime?.pi_session_path,
|
|
152
|
+
herdrSession: typeof locator === "string" ? undefined : locator?.session,
|
|
153
|
+
pane: typeof locator === "string" ? undefined : locator?.pane,
|
|
154
|
+
};
|
|
155
|
+
const expected = {
|
|
156
|
+
supersession: true, application: "herdr", wake: "native",
|
|
157
|
+
session: request.source.pi_session, sessionPath: request.source.pi_session_path,
|
|
158
|
+
herdrSession: request.target.herdr_session, pane: request.target.pane,
|
|
159
|
+
};
|
|
160
|
+
return JSON.stringify(observed) === JSON.stringify(expected);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Require the active request and receipt to describe one exact relocation. */
|
|
164
|
+
function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBytes: string, address: string, request: HandoverReplacementRequest) {
|
|
165
|
+
const relocation = receipt.relocation;
|
|
166
|
+
return [
|
|
167
|
+
activeBytes === yaml.print(request),
|
|
168
|
+
yaml.print(receipt.handover_replacement?.request) === activeBytes,
|
|
169
|
+
receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1",
|
|
170
|
+
receipt.seat === address,
|
|
171
|
+
receipt.tasks?.length === 1,
|
|
172
|
+
receipt.tasks?.[0] === request.task,
|
|
173
|
+
receipt.pi_session === request.source.pi_session,
|
|
174
|
+
receipt.pi_session_path === request.source.pi_session_path,
|
|
175
|
+
receipt.herdr_session === request.target.herdr_session,
|
|
176
|
+
receipt.pane === request.target.pane,
|
|
177
|
+
receipt.handover_replacement?.successor_session === request.source.pi_session,
|
|
178
|
+
relocation?.source_herdr_session === request.source.herdr_session,
|
|
179
|
+
relocation?.source_pane === request.source.pane,
|
|
180
|
+
relocation?.authorization?.id === request.authorization.id,
|
|
181
|
+
relocation?.authorization?.hash === request.authorization.hash,
|
|
182
|
+
Boolean(relocation?.backup), Boolean(relocation?.backup_sha256), Boolean(receipt.created_at),
|
|
183
|
+
].every(Boolean);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
|
|
187
|
+
async function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
|
|
188
|
+
const relocation = receipt.relocation!;
|
|
189
|
+
const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
|
|
190
|
+
return digest(backup) === relocation.backup_sha256
|
|
191
|
+
&& source.length >= backup.length
|
|
192
|
+
&& source.subarray(0, backup.length).equals(backup);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A live same-identity adoption is fresh only after its receipt and start. */
|
|
196
|
+
function freshAdoptionHeartbeat(runtimeState: Awaited<ReturnType<typeof readRuntimeState>>, receipt: ReplacementLaunchReceipt, address: string, cwd: string) {
|
|
197
|
+
if (!runtimeState) return false;
|
|
198
|
+
return [
|
|
199
|
+
runtimeState.seat === address,
|
|
200
|
+
resolve(runtimeState.cwd) === resolve(cwd),
|
|
201
|
+
Date.parse(runtimeState.started_at) >= Date.parse(receipt.created_at!),
|
|
202
|
+
Date.parse(runtimeState.heartbeat_at) >= Date.parse(runtimeState.started_at),
|
|
203
|
+
].every(Boolean);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Classify only the complete immutable evidence emitted by one exact Path-A
|
|
208
|
+
* adoption. The caller supplies the narrow task lifecycle accepted at its
|
|
209
|
+
* boundary; every receipt, archive, authorization, backup, and runtime check
|
|
210
|
+
* remains shared by adopted RESUMED proof and failed-adoption relaunch.
|
|
211
|
+
* Missing or contradictory evidence always returns no classification.
|
|
212
|
+
*/
|
|
213
|
+
export async function exactFailedAdoptionProof(root: string, record: Seat, address: string, request: HandoverReplacementRequest, taskEligible: TaskEligibility) {
|
|
214
|
+
const receiptPath = record.runtime?.launch_receipt;
|
|
215
|
+
if (!receiptPath || !exactAdoptionRuntimeMatches(record, request)) return undefined;
|
|
216
|
+
try {
|
|
217
|
+
const activePath = join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
|
|
218
|
+
const [activeBytes, receiptBytes, runtimeState, task] = await Promise.all([
|
|
219
|
+
readFile(activePath, "utf8"), readFile(receiptPath, "utf8"), readRuntimeState(root, address),
|
|
220
|
+
readYaml<Task>(paths(root).taskFile(record.project, request.task)),
|
|
221
|
+
]);
|
|
222
|
+
const receipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
|
|
223
|
+
const evidence = [
|
|
224
|
+
exactAdoptionReceiptMatches(receipt, activeBytes, address, request),
|
|
225
|
+
await taskEligible(task),
|
|
226
|
+
await exactAuthorizationMatches(root, request),
|
|
227
|
+
await exactArchiveMatches(request),
|
|
228
|
+
await uniqueAdoptionReceipt(root, request.authorization.id, receiptPath),
|
|
229
|
+
await exactBackupMatches(receipt, request),
|
|
230
|
+
];
|
|
231
|
+
if (!evidence.every(Boolean)) return undefined;
|
|
232
|
+
return { receipt, runtimeState, heartbeat: freshAdoptionHeartbeat(runtimeState, receipt, address, request.target.cwd) };
|
|
233
|
+
} catch { return undefined; }
|
|
234
|
+
}
|
package/src/handover.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// URN: component:flow:e005:handover:backend:integration
|
|
2
2
|
// Tested-By:
|
|
3
3
|
// - test:atdd-flow:repeat-handover-replacement-supersession
|
|
4
|
+
// - test:flow:dos-supersession-launch-eligibility
|
|
4
5
|
// Runtime: bun
|
|
5
6
|
// Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
|
|
6
7
|
|
|
@@ -10,6 +11,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
10
11
|
import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
|
|
11
12
|
import { type Task, terminal } from "./tasks";
|
|
12
13
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
14
|
+
import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
|
|
13
15
|
import { readRuntimeState } from "./runtime-state";
|
|
14
16
|
|
|
15
17
|
export type Handover = {
|
|
@@ -211,13 +213,31 @@ export async function mainLaunchEligible(root: string, record: Seat, address: st
|
|
|
211
213
|
&& await isExactMainSeat(root, record, address);
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
function evidenceEmptyRecoveryTask(task: Task) {
|
|
217
|
+
return task.status === "in_progress" && !task.blocker && !task.handoff
|
|
218
|
+
&& task.done_when.every((criterion) => !criterion.proof);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function failedAdoptionTaskEligible(root: string, record: Seat, address: string, task: Task) {
|
|
222
|
+
return (task.status === "todo" || evidenceEmptyRecoveryTask(task))
|
|
223
|
+
&& task.coordinator === address && task.assignee === address
|
|
224
|
+
&& await isExactMainSeat(root, record, address);
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
|
|
215
228
|
if (!await isExactMainSeat(root, record, address)) return undefined;
|
|
216
229
|
const request = await readHandoverReplacement(root, address).catch(() => {
|
|
217
230
|
throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
|
|
218
231
|
});
|
|
219
232
|
const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
|
|
220
|
-
|
|
233
|
+
const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
|
|
234
|
+
(task) => failedAdoptionTaskEligible(root, record, address, task));
|
|
235
|
+
const canonicalLaunch = replacementMatchesRuntime(record.runtime, request)
|
|
236
|
+
&& await (request.supersession
|
|
237
|
+
? failedAdoptionTaskEligible(root, record, address, task)
|
|
238
|
+
: mainLaunchEligible(root, record, address, task));
|
|
239
|
+
const failedAdoptionRecovery = Boolean(failedAdoption && !failedAdoption.runtimeState);
|
|
240
|
+
if (!canonicalLaunch && !failedAdoptionRecovery) {
|
|
221
241
|
throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
|
|
222
242
|
}
|
|
223
243
|
return { request, task };
|
|
@@ -739,10 +759,12 @@ export async function resumedHandoverReplacement(root: string, address: string)
|
|
|
739
759
|
const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
|
|
740
760
|
const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
|
|
741
761
|
const runtimeState = await readRuntimeState(root, resolved);
|
|
762
|
+
const failedAdoption = await exactFailedAdoptionProof(root, record, resolved, request,
|
|
763
|
+
(task) => failedAdoptionTaskEligible(root, record, resolved, task));
|
|
742
764
|
const verified = [
|
|
743
765
|
request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
|
|
744
766
|
runtime?.application === "herdr" && runtime.wake === "native",
|
|
745
|
-
runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
|
|
767
|
+
runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path) || Boolean(failedAdoption?.heartbeat),
|
|
746
768
|
typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
|
|
747
769
|
replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
|
|
748
770
|
Boolean(runtimeState && runtimeState.seat === resolved),
|