@afokapu/atdd-flow 0.3.54 → 0.3.55

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -10,6 +10,7 @@ import { basename, dirname, join, resolve } from "node:path";
10
10
  import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
11
11
  import { type Task, terminal } from "./tasks";
12
12
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
13
+ import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
13
14
  import { readRuntimeState } from "./runtime-state";
14
15
 
15
16
  export type Handover = {
@@ -211,13 +212,29 @@ export async function mainLaunchEligible(root: string, record: Seat, address: st
211
212
  && await isExactMainSeat(root, record, address);
212
213
  }
213
214
 
215
+ function evidenceEmptyRecoveryTask(task: Task) {
216
+ return task.status === "in_progress" && !task.blocker && !task.handoff
217
+ && task.done_when.every((criterion) => !criterion.proof);
218
+ }
219
+
220
+ async function failedAdoptionTaskEligible(root: string, record: Seat, address: string, task: Task) {
221
+ return (task.status === "todo" || evidenceEmptyRecoveryTask(task))
222
+ && task.coordinator === address && task.assignee === address
223
+ && await isExactMainSeat(root, record, address);
224
+ }
225
+
214
226
  export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
215
227
  if (!await isExactMainSeat(root, record, address)) return undefined;
216
228
  const request = await readHandoverReplacement(root, address).catch(() => {
217
229
  throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
218
230
  });
219
231
  const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
220
- if (!replacementMatchesRuntime(record.runtime, request) || !await mainLaunchEligible(root, record, address, task)) {
232
+ const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
233
+ (task) => failedAdoptionTaskEligible(root, record, address, task));
234
+ const canonicalLaunch = replacementMatchesRuntime(record.runtime, request)
235
+ && await mainLaunchEligible(root, record, address, task);
236
+ const failedAdoptionRecovery = Boolean(failedAdoption && !failedAdoption.runtimeState);
237
+ if (!canonicalLaunch && !failedAdoptionRecovery) {
221
238
  throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
222
239
  }
223
240
  return { request, task };
@@ -739,10 +756,12 @@ export async function resumedHandoverReplacement(root: string, address: string)
739
756
  const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
740
757
  const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
741
758
  const runtimeState = await readRuntimeState(root, resolved);
759
+ const failedAdoption = await exactFailedAdoptionProof(root, record, resolved, request,
760
+ (task) => failedAdoptionTaskEligible(root, record, resolved, task));
742
761
  const verified = [
743
762
  request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
744
763
  runtime?.application === "herdr" && runtime.wake === "native",
745
- runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
764
+ runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path) || Boolean(failedAdoption?.heartbeat),
746
765
  typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
747
766
  replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
748
767
  Boolean(runtimeState && runtimeState.seat === resolved),