@afokapu/atdd-flow 0.3.51 → 0.3.53
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/README.md +6 -0
- package/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/handover.ts +383 -16
package/README.md
CHANGED
|
@@ -134,6 +134,12 @@ Proof is a compact PR, CI run, report, commit range, deployment, or thread refer
|
|
|
134
134
|
|
|
135
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
136
136
|
|
|
137
|
+
A project main seat can perform a second Path-A replacement without deleting or editing the consumed active request. Use the normal `handover replace` arguments with the explicit `--supersede-consumed` flag, a fresh exact-session adoption authorization, the current validated handover, and exactly one ready self-assigned takeover task. Without the flag, replacement remains create-only and refuses an existing active request.
|
|
138
|
+
|
|
139
|
+
Before observing Herdr, supersession requires the prior request's takeover task to be terminal and the canonical runtime receipt to embed that exact request and successor. It also requires unchanged main topology, canonical pane and worktree placement, and a matching current handover lineage. Read-only Herdr checks then prove the freshly authorized live Pi session, JSONL path, pane, cwd, agent, and exactly one Pi writer. The command never stops, launches, binds, or changes a runtime.
|
|
140
|
+
|
|
141
|
+
Supersession serializes publication per seat. It stages a byte-identical copy of the prior request and launch receipt under `.atdd-flow/handover-replacement-archive/<encoded-seat>/<prior-request-sha256>/`, publishes that complete immutable archive first, then atomically replaces the active request. Validation, staging, archive, swap, and concurrent-attempt failures preserve the original active request, receipt, seat, and runtime. After the source extension observes the new request and exits, use the unchanged `pi runtime launch` adoption path; do not hand-edit Desk records or rebind the seat.
|
|
142
|
+
|
|
137
143
|
For topology normalization, transfer an active task through the narrow durable command, never by editing its YAML: `atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <current-coordinator>`; `main@project` must additionally pass `--authorization <operator-message-id>`. That message must be an immutable `task-transfer-authorization/v1` exact tuple for the project, task, current/target coordinator, reason, and target branch/head; only `operator@desk` creates it through `atdd-flow post --task-transfer-authorization <json>`. The command appends immutable source/target/reason/exact-head/effective-time provenance, keeps earlier phase/review/message evidence intact, derives the new governed base from the accepted target head, and never moves a seat, branch, runtime, or worktree. Same-target, duplicate, nested, mismatched, unauthorized, and manually rewritten transitions fail closed.
|
|
138
144
|
|
|
139
145
|
## Communicate and hand over
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
// URN: component:flow:e005:cli:backend:presentation
|
|
3
|
+
// Tested-By:
|
|
4
|
+
// - test:atdd-flow:repeat-handover-replacement-supersession
|
|
5
|
+
// Runtime: bun
|
|
6
|
+
// Purpose: Route supported Desk commands and publish their exact CLI grammar.
|
|
2
7
|
|
|
3
8
|
import { basename, resolve } from "node:path";
|
|
4
9
|
import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
@@ -39,7 +44,7 @@ Usage:
|
|
|
39
44
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
40
45
|
atdd-flow handover <address> --file <path> [--check]
|
|
41
46
|
atdd-flow handover show <address>
|
|
42
|
-
atdd-flow handover replace <address> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
47
|
+
atdd-flow handover replace <address> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
43
48
|
atdd-flow handover resumed <address>
|
|
44
49
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
45
50
|
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
|
package/src/handover.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
// URN: component:flow:e005:handover:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:repeat-handover-replacement-supersession
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
|
|
6
|
+
|
|
1
7
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
9
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
10
|
import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
|
|
5
11
|
import { type Task, terminal } from "./tasks";
|
|
@@ -102,23 +108,33 @@ export type HandoverReplacementRequest = {
|
|
|
102
108
|
authorization: { id: string; hash: string };
|
|
103
109
|
source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
|
|
104
110
|
target: { herdr_session: string; pane: string; cwd: string };
|
|
105
|
-
legacy_source?:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
herdr_session: string;
|
|
110
|
-
|
|
111
|
-
cwd: string;
|
|
112
|
-
agent: string;
|
|
113
|
-
verified_at: string;
|
|
111
|
+
legacy_source?: LiveSourceProof;
|
|
112
|
+
supersession?: {
|
|
113
|
+
prior_request: { path: string; sha256: string; task: string; handover: string; handover_sha256: string; archive: string };
|
|
114
|
+
prior_receipt: { path: string; sha256: string; successor_session: string };
|
|
115
|
+
canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
|
|
116
|
+
live_source: LiveSourceProof;
|
|
114
117
|
};
|
|
115
118
|
created_at: string;
|
|
116
119
|
};
|
|
117
120
|
|
|
121
|
+
type LiveSourceProof = {
|
|
122
|
+
authorization_hash: string;
|
|
123
|
+
pi_session: string;
|
|
124
|
+
pi_session_path: string;
|
|
125
|
+
herdr_session: string;
|
|
126
|
+
pane: string;
|
|
127
|
+
cwd: string;
|
|
128
|
+
agent: string;
|
|
129
|
+
verified_at: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
118
132
|
export type HandoverReplacementDependencies = {
|
|
119
133
|
command?: (command: string[]) => Promise<string>;
|
|
120
134
|
at?: () => string;
|
|
121
135
|
publish?: (file: string, content: string) => Promise<void>;
|
|
136
|
+
publishArchive?: (staging: string, destination: string) => Promise<void>;
|
|
137
|
+
replaceActive?: (staged: string, active: string) => Promise<void>;
|
|
122
138
|
};
|
|
123
139
|
|
|
124
140
|
export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
|
|
@@ -155,7 +171,37 @@ function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: Han
|
|
|
155
171
|
&& runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
type SupersessionProof = NonNullable<HandoverReplacementRequest["supersession"]>;
|
|
175
|
+
|
|
176
|
+
function supersessionPlacementMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
|
|
177
|
+
const locator = runtime?.addresses.herdr;
|
|
178
|
+
return runtime?.application === "herdr" && typeof locator !== "string"
|
|
179
|
+
&& locator?.session === proof.canonical_source.herdr_session && locator.pane === proof.canonical_source.pane;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function supersessionSessionMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
|
|
183
|
+
return runtime?.pi_session === proof.canonical_source.pi_session
|
|
184
|
+
&& runtime.pi_session_path === proof.canonical_source.pi_session_path
|
|
185
|
+
&& runtime.launch_receipt === proof.canonical_source.launch_receipt
|
|
186
|
+
&& proof.prior_receipt.path === runtime.launch_receipt
|
|
187
|
+
&& proof.prior_receipt.successor_session === runtime.pi_session;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function supersessionEvidenceMatches(request: HandoverReplacementRequest, proof: SupersessionProof) {
|
|
191
|
+
return proof.canonical_source.cwd === request.target.cwd
|
|
192
|
+
&& proof.canonical_source.herdr_session === request.target.herdr_session
|
|
193
|
+
&& proof.canonical_source.pane === request.target.pane
|
|
194
|
+
&& snapshotMatchesRequest(proof.live_source, request)
|
|
195
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_request.sha256)
|
|
196
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_request.handover_sha256)
|
|
197
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_receipt.sha256)
|
|
198
|
+
&& basename(proof.prior_request.archive) === proof.prior_request.sha256
|
|
199
|
+
&& Boolean(proof.prior_request.task && proof.prior_request.handover);
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
|
|
203
|
+
const proof = request.supersession;
|
|
204
|
+
if (proof) return supersessionPlacementMatches(runtime, proof) && supersessionSessionMatches(runtime, proof) && supersessionEvidenceMatches(request, proof);
|
|
159
205
|
if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
|
|
160
206
|
return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
|
|
161
207
|
}
|
|
@@ -270,9 +316,10 @@ async function publishCreateOnly(file: string, content: string) {
|
|
|
270
316
|
} finally { await rm(temporary, { force: true }); }
|
|
271
317
|
}
|
|
272
318
|
|
|
273
|
-
const replacementFlags = ["--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
319
|
+
const replacementFlags = ["--supersede-consumed", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
274
320
|
function validateReplacementArgs(args: string[]) {
|
|
275
|
-
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
321
|
+
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
322
|
+
if (has(args, "--supersede-consumed") && has(args, "--legacy-source-bootstrap")) throw new Error("Consumed replacement supersession cannot use legacy source bootstrap.");
|
|
276
323
|
}
|
|
277
324
|
|
|
278
325
|
async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
|
|
@@ -303,12 +350,13 @@ function assertReplacementSource(record: Seat, source: HandoverReplacementReques
|
|
|
303
350
|
|
|
304
351
|
async function prepareReplacement(root: string, resolved: string, args: string[]) {
|
|
305
352
|
validateReplacementArgs(args);
|
|
306
|
-
const legacy = args
|
|
353
|
+
const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed");
|
|
354
|
+
const handover = await currentReplacementHandover(root, resolved, args);
|
|
307
355
|
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
308
356
|
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
309
357
|
const source = replacementSource(args), target = replacementTarget(args, record, source);
|
|
310
|
-
assertReplacementSource(record, source, legacy);
|
|
311
|
-
return { legacy, handover, task, source, target };
|
|
358
|
+
if (!supersede) assertReplacementSource(record, source, legacy);
|
|
359
|
+
return { legacy, supersede, handover, task, record, source, target };
|
|
312
360
|
}
|
|
313
361
|
|
|
314
362
|
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
@@ -320,6 +368,324 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
|
|
|
320
368
|
};
|
|
321
369
|
}
|
|
322
370
|
|
|
371
|
+
type ReplacementLaunchReceipt = {
|
|
372
|
+
schema?: string;
|
|
373
|
+
seat?: string;
|
|
374
|
+
tasks?: string[];
|
|
375
|
+
pi_session?: string;
|
|
376
|
+
pi_session_path?: string;
|
|
377
|
+
herdr_session?: string;
|
|
378
|
+
pane?: string;
|
|
379
|
+
handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
type ConsumedReplacement = {
|
|
383
|
+
request: HandoverReplacementRequest;
|
|
384
|
+
requestBytes: string;
|
|
385
|
+
requestHash: string;
|
|
386
|
+
receiptBytes: string;
|
|
387
|
+
receiptHash: string;
|
|
388
|
+
receiptPath: string;
|
|
389
|
+
canonical: NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
|
|
390
|
+
archive: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
function validConsumedRequest(request: HandoverReplacementRequest, resolved: string) {
|
|
394
|
+
return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved
|
|
395
|
+
&& Boolean(request.task && request.handover?.id && request.handover.sha256);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function readConsumedRequest(file: string, resolved: string) {
|
|
399
|
+
let bytes: string;
|
|
400
|
+
try { bytes = await readFile(file, "utf8"); }
|
|
401
|
+
catch { throw new Error(`Consumed Path-A replacement request does not exist for ${resolved}.`); }
|
|
402
|
+
let request: HandoverReplacementRequest;
|
|
403
|
+
try { request = yaml.parse<HandoverReplacementRequest>(bytes); }
|
|
404
|
+
catch { throw new Error(`Consumed Path-A replacement request is malformed for ${resolved}.`); }
|
|
405
|
+
if (!validConsumedRequest(request, resolved)) throw new Error(`Consumed Path-A replacement request is invalid for ${resolved}.`);
|
|
406
|
+
return { bytes, request };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function completeCanonicalRuntime(intent: Awaited<ReturnType<typeof prepareReplacement>>) {
|
|
410
|
+
const runtime = intent.record.runtime, locator = runtime?.addresses.herdr;
|
|
411
|
+
if (runtime?.application !== "herdr" || typeof locator === "string" || !locator
|
|
412
|
+
|| !runtime.pi_session || !runtime.pi_session_path || !runtime.launch_receipt) {
|
|
413
|
+
throw new Error("Consumed Path-A replacement requires a complete canonical main runtime receipt.");
|
|
414
|
+
}
|
|
415
|
+
return { runtime, locator };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertCanonicalPlacement(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest, locator: { session: string; pane: string }) {
|
|
419
|
+
if (locator.session !== intent.target.herdr_session || locator.pane !== intent.target.pane
|
|
420
|
+
|| request.target?.herdr_session !== locator.session || request.target.pane !== locator.pane
|
|
421
|
+
|| resolve(request.target.cwd) !== resolve(intent.record.worktree)) {
|
|
422
|
+
throw new Error("Consumed Path-A replacement does not match the unchanged canonical main runtime and placement.");
|
|
423
|
+
}
|
|
424
|
+
if (intent.handover.supersedes !== request.handover.id && !canonicalSourceMatchesRuntime(intent.record.runtime, intent.source)) {
|
|
425
|
+
throw new Error("Consumed Path-A replacement does not match the current handover lineage.");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function canonicalRuntimeForSupersession(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest) {
|
|
430
|
+
const { runtime, locator } = completeCanonicalRuntime(intent);
|
|
431
|
+
assertCanonicalPlacement(intent, request, locator);
|
|
432
|
+
return {
|
|
433
|
+
pi_session: runtime.pi_session!, pi_session_path: runtime.pi_session_path!,
|
|
434
|
+
herdr_session: locator.session, pane: locator.pane, cwd: resolve(intent.record.worktree), launch_receipt: runtime.launch_receipt!,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function assertTerminalPriorTask(root: string, projectName: string, taskId: string) {
|
|
439
|
+
const priorTask = await readYaml<Task>(paths(root).taskFile(projectName, taskId));
|
|
440
|
+
if (priorTask.schema !== "atdd-workflow/task/v1" || !terminal(priorTask)) {
|
|
441
|
+
throw new Error("Consumed Path-A replacement requires its prior takeover task to be terminal.");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function readReplacementReceipt(path: string) {
|
|
446
|
+
try {
|
|
447
|
+
const bytes = await readFile(path, "utf8");
|
|
448
|
+
return { bytes, receipt: yaml.parse<ReplacementLaunchReceipt>(bytes) };
|
|
449
|
+
} catch { throw new Error("Consumed Path-A replacement requires its immutable prior launch receipt."); }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, resolved: string, canonical: ConsumedReplacement["canonical"]) {
|
|
453
|
+
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === resolved
|
|
454
|
+
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
455
|
+
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: ConsumedReplacement["canonical"]) {
|
|
459
|
+
return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
|
|
460
|
+
&& receipt.handover_replacement?.successor_session === canonical.pi_session
|
|
461
|
+
&& JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function consumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>) {
|
|
465
|
+
const { bytes: requestBytes, request } = await readConsumedRequest(file, resolved);
|
|
466
|
+
const canonical = canonicalRuntimeForSupersession(intent, request);
|
|
467
|
+
await assertTerminalPriorTask(root, intent.record.project, request.task);
|
|
468
|
+
const { bytes: receiptBytes, receipt } = await readReplacementReceipt(canonical.launch_receipt);
|
|
469
|
+
if (!receiptMatchesCanonical(receipt, resolved, canonical) || !receiptProvesRequest(receipt, request, canonical)) {
|
|
470
|
+
throw new Error("Consumed Path-A replacement prior receipt does not prove the exact request, task, and canonical successor.");
|
|
471
|
+
}
|
|
472
|
+
const requestHash = createHash("sha256").update(requestBytes).digest("hex");
|
|
473
|
+
const receiptHash = createHash("sha256").update(receiptBytes).digest("hex");
|
|
474
|
+
return {
|
|
475
|
+
request, requestBytes, requestHash, receiptBytes, receiptHash, canonical,
|
|
476
|
+
receiptPath: canonical.launch_receipt,
|
|
477
|
+
archive: join(root, ".atdd-flow", "handover-replacement-archive", encodeURIComponent(resolved), requestHash),
|
|
478
|
+
} satisfies ConsumedReplacement;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function supersessionManifest(resolved: string, file: string, consumed: ConsumedReplacement) {
|
|
482
|
+
return yaml.print({
|
|
483
|
+
schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: resolved,
|
|
484
|
+
prior_request: { path: file, sha256: consumed.requestHash, task: consumed.request.task, handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256 },
|
|
485
|
+
prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function archiveMatches(destination: string, requestBytes: string, receiptBytes: string, manifest: string) {
|
|
490
|
+
try {
|
|
491
|
+
const [files, request, receipt, proof] = await Promise.all([
|
|
492
|
+
readdir(destination), readFile(join(destination, "request.yaml"), "utf8"),
|
|
493
|
+
readFile(join(destination, "launch-receipt.yaml"), "utf8"), readFile(join(destination, "supersession.yaml"), "utf8"),
|
|
494
|
+
]);
|
|
495
|
+
return files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n")
|
|
496
|
+
&& request === requestBytes && receipt === receiptBytes && proof === manifest;
|
|
497
|
+
} catch { return false; }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function restoreActive(file: string, priorBytes: string) {
|
|
501
|
+
let current: string | undefined;
|
|
502
|
+
try { current = await readFile(file, "utf8"); } catch { /* Restore an absent active request below. */ }
|
|
503
|
+
if (current === priorBytes) return;
|
|
504
|
+
const rollback = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.rollback.tmp`);
|
|
505
|
+
try {
|
|
506
|
+
await writeFile(rollback, priorBytes, { encoding: "utf8", flag: "wx" });
|
|
507
|
+
await rename(rollback, file);
|
|
508
|
+
} finally { await rm(rollback, { force: true }); }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function supersedingRequest(resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, consumed: ConsumedReplacement, liveSource: LiveSourceProof, dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
512
|
+
return {
|
|
513
|
+
...replacementRequest(resolved, intent, authorization, undefined, dependencies),
|
|
514
|
+
supersession: {
|
|
515
|
+
prior_request: {
|
|
516
|
+
path: file, sha256: consumed.requestHash, task: consumed.request.task,
|
|
517
|
+
handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256, archive: consumed.archive,
|
|
518
|
+
},
|
|
519
|
+
prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
|
|
520
|
+
canonical_source: consumed.canonical, live_source: liveSource,
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
type SupersessionTransaction = {
|
|
526
|
+
consumed: ConsumedReplacement;
|
|
527
|
+
archiveStage: string;
|
|
528
|
+
successorStage: string;
|
|
529
|
+
archiveExisted: boolean;
|
|
530
|
+
activeSwapAttempted: boolean;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
async function stageSupersession(file: string, request: HandoverReplacementRequest, consumed: ConsumedReplacement, manifest: string, dependencies: HandoverReplacementDependencies) {
|
|
534
|
+
const archiveParent = dirname(consumed.archive);
|
|
535
|
+
const archiveStage = join(archiveParent, `.${basename(consumed.archive)}.${crypto.randomUUID()}.tmp`);
|
|
536
|
+
const successorStage = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.tmp`);
|
|
537
|
+
await mkdir(archiveParent, { recursive: true });
|
|
538
|
+
try {
|
|
539
|
+
await mkdir(archiveStage);
|
|
540
|
+
const successorWrite = dependencies.publish
|
|
541
|
+
? dependencies.publish(successorStage, yaml.print(request))
|
|
542
|
+
: writeFile(successorStage, yaml.print(request), { encoding: "utf8", flag: "wx" });
|
|
543
|
+
await Promise.all([
|
|
544
|
+
writeFile(join(archiveStage, "request.yaml"), consumed.requestBytes, { encoding: "utf8", flag: "wx" }),
|
|
545
|
+
writeFile(join(archiveStage, "launch-receipt.yaml"), consumed.receiptBytes, { encoding: "utf8", flag: "wx" }),
|
|
546
|
+
writeFile(join(archiveStage, "supersession.yaml"), manifest, { encoding: "utf8", flag: "wx" }),
|
|
547
|
+
successorWrite,
|
|
548
|
+
]);
|
|
549
|
+
return { archiveStage, successorStage };
|
|
550
|
+
} catch (error) {
|
|
551
|
+
await rm(archiveStage, { recursive: true, force: true });
|
|
552
|
+
await rm(successorStage, { force: true });
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function publishArchiveEvidence(transaction: SupersessionTransaction, manifest: string, dependencies: HandoverReplacementDependencies) {
|
|
558
|
+
const { consumed, archiveStage, archiveExisted } = transaction;
|
|
559
|
+
if (archiveExisted) {
|
|
560
|
+
if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
|
|
561
|
+
throw new Error("Consumed Path-A replacement archive collides with different immutable evidence.");
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await (dependencies.publishArchive ?? rename)(archiveStage, consumed.archive);
|
|
566
|
+
if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
|
|
567
|
+
throw new Error("Consumed Path-A replacement archive publication was incomplete.");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function rollbackSupersession(file: string, transaction: SupersessionTransaction) {
|
|
572
|
+
if (transaction.activeSwapAttempted) await restoreActive(file, transaction.consumed.requestBytes);
|
|
573
|
+
if (!transaction.archiveExisted) await rm(transaction.consumed.archive, { recursive: true, force: true });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function cleanupSupersession(transaction: SupersessionTransaction | undefined) {
|
|
577
|
+
if (!transaction) return;
|
|
578
|
+
await rm(transaction.archiveStage, { recursive: true, force: true });
|
|
579
|
+
await rm(transaction.successorStage, { force: true });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const supersessionLockSchema = "atdd-flow/handover-replacement-supersession-lock/v1" as const;
|
|
583
|
+
type SupersessionLock = { schema: typeof supersessionLockSchema; seat: string; active_request_sha256: string; pid: number; token: string };
|
|
584
|
+
const errorCode = (error: unknown) => typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
|
|
585
|
+
|
|
586
|
+
function validSupersessionLock(value: unknown, resolved: string, activeHash: string): value is SupersessionLock {
|
|
587
|
+
const record = object(value);
|
|
588
|
+
return record?.schema === supersessionLockSchema && record.seat === resolved && record.active_request_sha256 === activeHash
|
|
589
|
+
&& Number.isInteger(record.pid) && Number(record.pid) > 0 && typeof record.token === "string" && Boolean(record.token);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function liveProcess(pid: number) {
|
|
593
|
+
try { process.kill(pid, 0); return true; }
|
|
594
|
+
catch (error) {
|
|
595
|
+
if (errorCode(error) === "ESRCH") return false;
|
|
596
|
+
if (errorCode(error) === "EPERM") return true;
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Recovery keeps the lock fail-closed without requiring a manual Desk edit.
|
|
603
|
+
* The active-request hash binds a dead owner to the immutable request it saw.
|
|
604
|
+
* A live PID, EPERM result, malformed record, or changed identity always refuses.
|
|
605
|
+
* A hard-link recovery marker is create-only, so one contender owns cleanup.
|
|
606
|
+
* New contenders observe that marker before attempting ordinary acquisition.
|
|
607
|
+
* A contender already between its marker check and `wx` may win the lock, but
|
|
608
|
+
* the recovery owner then loses its own `wx`; in either ordering only one wins.
|
|
609
|
+
* The marker also closes the remove/reacquire gap for later contenders.
|
|
610
|
+
*/
|
|
611
|
+
async function createSupersessionLock(lock: string, content: string) {
|
|
612
|
+
try { await writeFile(lock, content, { encoding: "utf8", flag: "wx" }); return true; }
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (errorCode(error) === "EEXIST") return false;
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function staleSupersessionLock(lock: string, resolved: string, activeHash: string) {
|
|
620
|
+
const observed = await readFile(lock, "utf8");
|
|
621
|
+
let prior: unknown;
|
|
622
|
+
try { prior = yaml.parse<unknown>(observed); }
|
|
623
|
+
catch { throw new Error(`Path-A replacement supersession lock is malformed for ${resolved}.`); }
|
|
624
|
+
if (!validSupersessionLock(prior, resolved, activeHash)) throw new Error(`Path-A replacement supersession lock identity changed for ${resolved}.`);
|
|
625
|
+
if (liveProcess(prior.pid)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
|
|
626
|
+
return observed;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function recoverSupersessionLock(file: string, lock: string, recovery: string, resolved: string, activeHash: string, observed: string, content: string) {
|
|
630
|
+
try { await link(lock, recovery); }
|
|
631
|
+
catch { throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`); }
|
|
632
|
+
try {
|
|
633
|
+
const [currentLock, currentActive] = await Promise.all([readFile(lock, "utf8"), readFile(file, "utf8")]);
|
|
634
|
+
if (currentLock !== observed || createHash("sha256").update(currentActive).digest("hex") !== activeHash) {
|
|
635
|
+
throw new Error(`Path-A replacement supersession lock changed during recovery for ${resolved}.`);
|
|
636
|
+
}
|
|
637
|
+
await rm(lock);
|
|
638
|
+
if (!await createSupersessionLock(lock, content)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
|
|
639
|
+
} finally { await rm(recovery, { force: true }); }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function acquireSupersessionLock(file: string, resolved: string) {
|
|
643
|
+
const lock = `${file}.lock`, recovery = `${lock}.recovery`;
|
|
644
|
+
const activeHash = createHash("sha256").update(await readFile(file, "utf8")).digest("hex");
|
|
645
|
+
const record: SupersessionLock = { schema: supersessionLockSchema, seat: resolved, active_request_sha256: activeHash, pid: process.pid, token: crypto.randomUUID() };
|
|
646
|
+
const content = yaml.print(record);
|
|
647
|
+
if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
|
|
648
|
+
if (await createSupersessionLock(lock, content)) return { lock, content };
|
|
649
|
+
if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
|
|
650
|
+
const observed = await staleSupersessionLock(lock, resolved, activeHash);
|
|
651
|
+
await recoverSupersessionLock(file, lock, recovery, resolved, activeHash, observed, content);
|
|
652
|
+
return { lock, content };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function releaseSupersessionLock(lock: string, content: string) {
|
|
656
|
+
let current: string;
|
|
657
|
+
try { current = await readFile(lock, "utf8"); } catch { return; }
|
|
658
|
+
if (current === content) await rm(lock);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function supersedeConsumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, args: string[], dependencies: HandoverReplacementDependencies) {
|
|
662
|
+
await mkdir(dirname(file), { recursive: true });
|
|
663
|
+
const ownedLock = await acquireSupersessionLock(file, resolved);
|
|
664
|
+
let transaction: SupersessionTransaction | undefined;
|
|
665
|
+
try {
|
|
666
|
+
const consumed = await consumedReplacement(root, resolved, file, intent);
|
|
667
|
+
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
668
|
+
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
669
|
+
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
670
|
+
});
|
|
671
|
+
const liveSource = await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies);
|
|
672
|
+
const request = supersedingRequest(resolved, file, intent, authorization, consumed, liveSource, dependencies);
|
|
673
|
+
const manifest = supersessionManifest(resolved, file, consumed);
|
|
674
|
+
const staging = await stageSupersession(file, request, consumed, manifest, dependencies);
|
|
675
|
+
transaction = { consumed, ...staging, archiveExisted: await exists(consumed.archive), activeSwapAttempted: false };
|
|
676
|
+
await publishArchiveEvidence(transaction, manifest, dependencies);
|
|
677
|
+
transaction.activeSwapAttempted = true;
|
|
678
|
+
await (dependencies.replaceActive ?? rename)(transaction.successorStage, file);
|
|
679
|
+
console.log(`Recorded superseding Path-A replacement request for ${resolved}.`);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (transaction) await rollbackSupersession(file, transaction);
|
|
682
|
+
throw error;
|
|
683
|
+
} finally {
|
|
684
|
+
await cleanupSupersession(transaction);
|
|
685
|
+
await releaseSupersessionLock(ownedLock.lock, ownedLock.content);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
323
689
|
/**
|
|
324
690
|
* Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
|
|
325
691
|
* It deliberately does not stop, bind, attach, project, or launch any process.
|
|
@@ -327,7 +693,8 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
|
|
|
327
693
|
export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
|
|
328
694
|
const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
|
|
329
695
|
const file = handoverReplacementPath(root, resolved);
|
|
330
|
-
if (intent.
|
|
696
|
+
if (intent.supersede) return supersedeConsumedReplacement(root, resolved, file, intent, args, dependencies);
|
|
697
|
+
if (await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
|
|
331
698
|
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
332
699
|
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
333
700
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|