@afokapu/atdd-flow 0.3.76 → 0.3.78
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/cli.ts +2 -1
- package/src/core.ts +2 -0
- package/src/exact-adoption-recovery.ts +3 -7
- package/src/handover.ts +13 -19
- package/src/pi-runtime-launch.ts +1 -1
- package/src/stale-main-recovery.ts +175 -12
- package/src/task-supersede-authorization.ts +113 -0
- package/src/tasks.ts +9 -25
- package/src/threads.ts +8 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -67,6 +67,7 @@ Usage:
|
|
|
67
67
|
atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
|
|
68
68
|
atdd-flow task block <project> <task-id> --by <address> --reason <text>
|
|
69
69
|
atdd-flow task supersede|reject <project> <task-id> --by <coordinator-address> --reason <text>
|
|
70
|
+
atdd-flow task supersede <project> <task-id> --by main@<project> --reason <text> --authorization <operator-message-id>
|
|
70
71
|
atdd-flow task defer <project> <task-id> --by <coordinator-address> --reason <text> --owner <address> --trigger <text> --review-at <ISO-8601>
|
|
71
72
|
atdd-flow task unblock <project> <task-id> --by <coordinator-address>
|
|
72
73
|
atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
|
|
@@ -83,7 +84,7 @@ Usage:
|
|
|
83
84
|
atdd-flow thread add <thread-id> <address>
|
|
84
85
|
atdd-flow thread open <thread-id>
|
|
85
86
|
atdd-flow message read <message-id>
|
|
86
|
-
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>]
|
|
87
|
+
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>] [--task-supersede-authorization <json>]
|
|
87
88
|
atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
|
|
88
89
|
atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
|
|
89
90
|
atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
|
package/src/core.ts
CHANGED
|
@@ -75,6 +75,8 @@ export type Runtime = {
|
|
|
75
75
|
pi_session_path?: string;
|
|
76
76
|
/** Immutable, non-secret receipt for that selected launch. */
|
|
77
77
|
launch_receipt?: string;
|
|
78
|
+
/** Operator-recorded binding mode for an adopted runtime whose receipt is not launcher-generated. */
|
|
79
|
+
binding_mode?: string;
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
|
|
@@ -10,7 +10,7 @@ import { basename, join, resolve } from "node:path";
|
|
|
10
10
|
import { type Seat, paths, readYaml, yaml } from "./core";
|
|
11
11
|
import type { HandoverReplacementRequest } from "./handover";
|
|
12
12
|
import { readRuntimeState } from "./runtime-state";
|
|
13
|
-
import { archivedReceiptMatches, recoveryEvidenceMatches } from "./stale-main-recovery";
|
|
13
|
+
import { archivedReceiptMatches, backupIsTranscriptPrefix, recoveryEvidenceMatches } from "./stale-main-recovery";
|
|
14
14
|
import type { Task } from "./tasks";
|
|
15
15
|
|
|
16
16
|
type ReplacementLaunchReceipt = {
|
|
@@ -188,12 +188,8 @@ function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBy
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
|
|
194
|
-
return digest(backup) === relocation.backup_sha256
|
|
195
|
-
&& source.length >= backup.length
|
|
196
|
-
&& source.subarray(0, backup.length).equals(backup);
|
|
191
|
+
function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
|
|
192
|
+
return backupIsTranscriptPrefix({ path: receipt.relocation!.backup, sha256: receipt.relocation!.backup_sha256 }, request.source.pi_session_path);
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
/** A live same-identity adoption is fresh only after its receipt and start. */
|
package/src/handover.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizati
|
|
|
14
14
|
import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
|
|
15
15
|
import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
|
|
16
16
|
import { readRuntimeState } from "./runtime-state";
|
|
17
|
-
import { type StaleMainRecovery, buildStaleMainRecovery, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
|
|
17
|
+
import { type StaleMainRecovery, buildStaleMainRecovery, listedPiAgents, relocationFor, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
|
|
18
18
|
|
|
19
19
|
export type Handover = {
|
|
20
20
|
schema: "atdd-workflow/handover/v1";
|
|
@@ -314,20 +314,9 @@ function assertSinglePiWriter(raw: string) {
|
|
|
314
314
|
type PiSource = Pick<HandoverReplacementRequest["source"], "pi_session" | "pi_session_path">;
|
|
315
315
|
|
|
316
316
|
function assertCanonicalSourceNotLive(raw: string, canonical: PiSource) {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const agent = object(entry);
|
|
321
|
-
if (!agent) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
|
|
322
|
-
if (text(agent.agent) !== "pi") continue;
|
|
323
|
-
const session = object(agent.agent_session);
|
|
324
|
-
const sessionPath = session?.kind === "path" ? text(session.value) : undefined;
|
|
325
|
-
if (!sessionPath) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
|
|
326
|
-
const sessionId = basename(sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)?.[1];
|
|
327
|
-
if (resolve(sessionPath) === resolve(canonical.pi_session_path) || sessionId === canonical.pi_session) {
|
|
328
|
-
throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
|
|
329
|
-
}
|
|
330
|
-
}
|
|
317
|
+
const live = listedPiAgents(raw, "Consumed Path-A replacement").some(({ path }) => path === resolve(canonical.pi_session_path)
|
|
318
|
+
|| 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] === canonical.pi_session);
|
|
319
|
+
if (live) throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
|
|
331
320
|
}
|
|
332
321
|
|
|
333
322
|
async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies, canonical?: PiSource) {
|
|
@@ -387,7 +376,7 @@ function replacementTarget(args: string[], record: Seat, source: HandoverReplace
|
|
|
387
376
|
const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
|
|
388
377
|
if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
|
|
389
378
|
const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
|
|
390
|
-
if (target.herdr_session !== source.herdr_session
|
|
379
|
+
if (target.herdr_session !== source.herdr_session) throw new Error("Path-A replacement target must be the exact released source pane.");
|
|
391
380
|
return target;
|
|
392
381
|
}
|
|
393
382
|
|
|
@@ -402,9 +391,12 @@ async function prepareReplacement(root: string, resolved: string, args: string[]
|
|
|
402
391
|
const handover = await currentReplacementHandover(root, resolved, args);
|
|
403
392
|
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
404
393
|
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
405
|
-
const source = replacementSource(args),
|
|
394
|
+
const source = replacementSource(args), file = handoverReplacementPath(root, resolved);
|
|
395
|
+
const target = replacementTarget(args, record, source);
|
|
396
|
+
const relocation = recover ? await relocationFor(record, source, target, file) : undefined;
|
|
397
|
+
if (!relocation && target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
|
|
406
398
|
if (!supersede && !recover) assertReplacementSource(record, source, legacy);
|
|
407
|
-
return { legacy, supersede, recover, handover, task, record, source, target };
|
|
399
|
+
return { legacy, supersede, recover, handover, task, record, source, target, relocation };
|
|
408
400
|
}
|
|
409
401
|
|
|
410
402
|
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
@@ -745,7 +737,9 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
745
737
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
746
738
|
});
|
|
747
739
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
748
|
-
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
740
|
+
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
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"))) : undefined;
|
|
749
743
|
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
750
744
|
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
751
745
|
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -99,7 +99,7 @@ async function isExactNamedCoordinator(root: string, record: Seat, address: stri
|
|
|
99
99
|
return Boolean(worktree && await hasUniqueCoordinatorPlacement(root, record, worktree));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
102
|
+
export async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
103
103
|
const coordinator = await isExactNamedCoordinator(root, record, address);
|
|
104
104
|
const operator = await isExactOperatorSeat(root, record, address);
|
|
105
105
|
const tasks = await seatTasks(root, record.project, record.address);
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// - test:atdd-flow:stale-main-recovery-resumed
|
|
5
5
|
// - test:atdd-flow:stale-main-source-exited-path-a
|
|
6
6
|
// - test:atdd-flow:stale-main-s1-exited-recovery
|
|
7
|
+
// - test:atdd-flow:relocated-receipt-stale-main-recovery
|
|
7
8
|
// Runtime: bun
|
|
8
9
|
// Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
|
|
9
10
|
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
-
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
14
|
import { type Runtime, type Seat, runOutput, yaml } from "./core";
|
|
14
15
|
import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
|
|
15
16
|
|
|
@@ -24,15 +25,28 @@ export type StaleMainRecovery = {
|
|
|
24
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. */
|
|
25
26
|
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
|
|
26
27
|
live_source: LiveSourceProof;
|
|
28
|
+
/** Present only for a relocated main: the recorded receipt is an operator manual rebinding receipt for a retired placement. */
|
|
29
|
+
relocation?: Relocation;
|
|
27
30
|
};
|
|
28
31
|
|
|
32
|
+
export const MANUAL_REBINDING_SCHEMA = "atdd-flow/manual-pi-runtime-rebinding-receipt/v1";
|
|
33
|
+
export const MANUAL_REBINDING_MODE = "operator-authorized-manual-in-place-adoption";
|
|
34
|
+
|
|
35
|
+
/** The retired placement named by the manual rebinding receipt; lineage only, never a live source. */
|
|
36
|
+
export type Relocation = { mode: string; retired: { herdr_session: string; pane: string } };
|
|
37
|
+
|
|
29
38
|
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
30
39
|
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
31
40
|
|
|
32
41
|
type Run = (argv: string[]) => Promise<string>;
|
|
33
42
|
type Source = HandoverReplacementRequest["source"];
|
|
34
43
|
type Target = HandoverReplacementRequest["target"];
|
|
35
|
-
type PriorReceipt = {
|
|
44
|
+
type PriorReceipt = {
|
|
45
|
+
schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string;
|
|
46
|
+
mode?: string; authorized_by?: string; authorization?: string;
|
|
47
|
+
target?: { cwd?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
48
|
+
backup?: { path?: string; bytes?: number; sha256?: string };
|
|
49
|
+
};
|
|
36
50
|
|
|
37
51
|
const sha256 = (content: string | Uint8Array) => createHash("sha256").update(content).digest("hex");
|
|
38
52
|
|
|
@@ -59,12 +73,22 @@ export function staleCanonicalSource(record: Seat, source: Source, target: Targe
|
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
|
|
62
|
-
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
|
|
76
|
+
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
77
|
+
return relocation ? manualReceiptProvesCanonical(receipt, seat, canonical, relocation) : launchReceiptProvesCanonical(receipt, seat, canonical);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function launchReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
|
|
63
81
|
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
|
|
64
82
|
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
65
83
|
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
function manualReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
87
|
+
return receipt.schema === MANUAL_REBINDING_SCHEMA && receipt.seat === seat && receipt.mode === relocation.mode
|
|
88
|
+
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
89
|
+
&& receipt.herdr_session === relocation.retired.herdr_session && receipt.pane === relocation.retired.pane;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
const archivePath = (root: string, folder: string, seat: string, hash: string) => join(root, ".atdd-flow", folder, encodeURIComponent(seat), `${hash}.yaml`);
|
|
69
93
|
|
|
70
94
|
async function publishOrVerify(file: string, bytes: string) {
|
|
@@ -75,11 +99,11 @@ async function publishOrVerify(file: string, bytes: string) {
|
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
/** Preserve the stale receipt by content hash, create-only; an existing copy must be identical. */
|
|
78
|
-
export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"]): Promise<StaleMainRecovery["prior_receipt"]> {
|
|
102
|
+
export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation): Promise<StaleMainRecovery["prior_receipt"]> {
|
|
79
103
|
let bytes: string;
|
|
80
104
|
try { bytes = await readFile(canonical.launch_receipt, "utf8"); }
|
|
81
105
|
catch { throw new Error("Stale main recovery requires its immutable recorded launch receipt."); }
|
|
82
|
-
if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical)) {
|
|
106
|
+
if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical, relocation)) {
|
|
83
107
|
throw new Error("Stale main recovery recorded launch receipt does not prove the recorded canonical runtime.");
|
|
84
108
|
}
|
|
85
109
|
return { path: canonical.launch_receipt, ...await archiveBytes(root, "stale-main-receipt-archive", seat, bytes) };
|
|
@@ -153,7 +177,7 @@ export async function archivedReceiptMatches(request: HandoverReplacementRequest
|
|
|
153
177
|
try {
|
|
154
178
|
const bytes = await readFile(recovery.prior_receipt.archive, "utf8");
|
|
155
179
|
const priorRequest = !recovery.prior_request || await archiveIntact(recovery.prior_request);
|
|
156
|
-
return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source);
|
|
180
|
+
return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source, recovery.relocation);
|
|
157
181
|
} catch { return false; }
|
|
158
182
|
}
|
|
159
183
|
|
|
@@ -169,15 +193,26 @@ function sessionMatches(runtime: Runtime | undefined, recovery: StaleMainRecover
|
|
|
169
193
|
&& runtime.launch_receipt === canonical.launch_receipt && recovery.prior_receipt.path === runtime.launch_receipt;
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"]) {
|
|
196
|
+
function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
197
|
+
if (relocation) return relocatedTargetMatches(request, canonical, relocation);
|
|
173
198
|
return canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session
|
|
174
199
|
&& canonical.pane === request.target.pane && canonical.pi_session !== request.source.pi_session;
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
|
|
202
|
+
/** A relocated request names the exact recorded Pi at the retired pane, and moves to the current pane that Herdr observed it on. */
|
|
203
|
+
function relocatedTargetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
204
|
+
return relocation.mode === MANUAL_REBINDING_MODE
|
|
205
|
+
&& canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session && canonical.pane === request.target.pane
|
|
206
|
+
&& request.source.pi_session === canonical.pi_session && request.source.pi_session_path === canonical.pi_session_path
|
|
207
|
+
&& request.source.herdr_session === relocation.retired.herdr_session && request.source.pane === relocation.retired.pane
|
|
208
|
+
&& (relocation.retired.herdr_session !== canonical.herdr_session || relocation.retired.pane !== canonical.pane);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof, relocation?: Relocation) {
|
|
212
|
+
const placement = relocation ? request.target : request.source;
|
|
178
213
|
return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
|
|
179
|
-
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session ===
|
|
180
|
-
&& live.pane ===
|
|
214
|
+
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session === placement.herdr_session
|
|
215
|
+
&& live.pane === placement.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
|
|
181
216
|
}
|
|
182
217
|
|
|
183
218
|
function exitMatches(recovery: StaleMainRecovery) {
|
|
@@ -192,7 +227,7 @@ function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
|
192
227
|
|
|
193
228
|
/** Request, canonical source and live-source snapshot must describe one exact target tuple. */
|
|
194
229
|
export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
|
|
195
|
-
return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
|
|
230
|
+
return targetMatches(request, recovery.canonical_source, recovery.relocation) && snapshotMatches(request, recovery.live_source, recovery.relocation) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
/** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
|
|
@@ -201,9 +236,137 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
201
236
|
}
|
|
202
237
|
|
|
203
238
|
/** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
|
|
204
|
-
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
|
|
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>): Promise<StaleMainRecovery> {
|
|
240
|
+
if (intent.relocation) return buildRelocatedStaleMainRecovery(root, seat, intent, intent.relocation, active, { verify: () => verify(intent.source, agentListOnce()), agentList });
|
|
205
241
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
206
242
|
const once = agentListOnce(), live_source = await verify(canonical, once);
|
|
207
243
|
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async (source) => { await verify(source, once); } });
|
|
208
244
|
return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
|
|
209
245
|
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
const refuse = (reason: string) => new Error(`Stale main relocation receipt recovery refused: ${reason}.`);
|
|
249
|
+
|
|
250
|
+
/** True when the seat records a manual binding_mode or its launch receipt is a manual rebinding receipt, rather than a launcher receipt. */
|
|
251
|
+
export async function isManualRebindingReceipt(record: Seat) {
|
|
252
|
+
const path = record.runtime?.launch_receipt;
|
|
253
|
+
if (!path) return false;
|
|
254
|
+
if (record.runtime?.binding_mode !== undefined) return true;
|
|
255
|
+
try { return yaml.parse<PriorReceipt>(await readFile(path, "utf8")).schema === MANUAL_REBINDING_SCHEMA; } catch { return false; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A backup file must hash to its recorded digest (and size, when recorded) and be the exact byte prefix of the adopted session transcript. */
|
|
259
|
+
export async function backupIsTranscriptPrefix(backup: { path?: string; sha256?: string; bytes?: number }, transcriptPath: string) {
|
|
260
|
+
try {
|
|
261
|
+
const [saved, transcript] = await Promise.all([readFile(backup.path!), readFile(transcriptPath)]);
|
|
262
|
+
return sha256(saved) === backup.sha256 && (backup.bytes === undefined || backup.bytes === saved.length)
|
|
263
|
+
&& transcript.length >= saved.length && transcript.subarray(0, saved.length).equals(saved);
|
|
264
|
+
} catch { return false; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** An existing stale active request must retain exactly the retired placement of the recorded Pi. */
|
|
268
|
+
async function priorRequestIsRetiredLineage(file: string, receipt: PriorReceipt) {
|
|
269
|
+
let bytes: string;
|
|
270
|
+
try { bytes = await readFile(file, "utf8"); } catch { return true; }
|
|
271
|
+
try {
|
|
272
|
+
const { source, target } = yaml.parse<HandoverReplacementRequest>(bytes);
|
|
273
|
+
return source.pi_session === receipt.pi_session && source.pi_session_path === receipt.pi_session_path
|
|
274
|
+
&& [source, target].every((placement) => placement.herdr_session === receipt.herdr_session && placement.pane === receipt.pane);
|
|
275
|
+
} catch { return false; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type Check = [boolean, string];
|
|
279
|
+
const firstFailure = (checks: Check[]) => checks.find(([ok]) => !ok)?.[1];
|
|
280
|
+
|
|
281
|
+
/** The recorded receipt is a complete operator-authorized manual rebinding receipt whose mode equals the seat binding_mode. */
|
|
282
|
+
function authorityFailure(receipt: PriorReceipt, record: Seat) {
|
|
283
|
+
return firstFailure([
|
|
284
|
+
[receipt.schema === MANUAL_REBINDING_SCHEMA, "the recorded receipt is not a manual rebinding receipt"],
|
|
285
|
+
[receipt.seat === record.address, "the receipt names another seat"],
|
|
286
|
+
[receipt.mode === MANUAL_REBINDING_MODE && record.runtime?.binding_mode === receipt.mode, "the receipt mode must equal the seat binding_mode"],
|
|
287
|
+
[receipt.authorized_by === "operator@desk" && Boolean(receipt.authorization), "the receipt lacks operator@desk authorization"],
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The receipt describes the exact recorded Pi, the canonical worktree and one retired placement. */
|
|
292
|
+
function identityFailure(receipt: PriorReceipt, record: Seat, session: string, sessionPath: string) {
|
|
293
|
+
const placement = receipt.target;
|
|
294
|
+
return firstFailure([
|
|
295
|
+
[receipt.pi_session === session && receipt.pi_session_path === sessionPath, "the receipt does not name the exact recorded Pi session"],
|
|
296
|
+
[placement?.pi_session === session && placement.pi_session_path === sessionPath, "the receipt target does not name the exact recorded Pi session"],
|
|
297
|
+
[Boolean(placement?.cwd) && resolve(placement!.cwd!) === resolve(record.worktree), "the receipt cwd is not the canonical main worktree"],
|
|
298
|
+
[Boolean(receipt.pane) && placement?.herdr_session === receipt.herdr_session && placement.pane === receipt.pane, "the receipt placement is inconsistent"],
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The request moves the exact recorded Pi from the retired placement to the seat's current locator. */
|
|
303
|
+
function requestFailure(receipt: PriorReceipt, source: Source, target: Target, recorded: { session: string; sessionPath: string; locator: { session: string; pane: string } }) {
|
|
304
|
+
return firstFailure([
|
|
305
|
+
[source.pi_session === recorded.session && source.pi_session_path === recorded.sessionPath, "the request source is not the exact recorded Pi"],
|
|
306
|
+
[source.herdr_session === receipt.herdr_session && source.pane === receipt.pane, "the request source must be the receipt's retired placement"],
|
|
307
|
+
[target.herdr_session === recorded.locator.session && target.pane === recorded.locator.pane, "the request target must be the seat's current locator"],
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validate a relocated main before any authorization or Herdr evidence is consulted: the recorded receipt must be a
|
|
313
|
+
* complete operator manual rebinding receipt whose mode equals the seat binding_mode, for the exact recorded Pi, whose
|
|
314
|
+
* backup is a prefix of the adopted transcript, and whose retired placement is the request source and the stale request's
|
|
315
|
+
* placement while the request target is the seat's current locator. Returns the lineage to record.
|
|
316
|
+
*/
|
|
317
|
+
export async function assertRelocatedBinding(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation> {
|
|
318
|
+
const { locator, session, sessionPath, receipt: path } = completeRuntime(record.runtime);
|
|
319
|
+
let receipt: PriorReceipt;
|
|
320
|
+
try { receipt = yaml.parse<PriorReceipt>(await readFile(path, "utf8")); } catch { throw refuse("the recorded relocation receipt is unreadable"); }
|
|
321
|
+
const failure = authorityFailure(receipt, record) ?? identityFailure(receipt, record, session, sessionPath)
|
|
322
|
+
?? requestFailure(receipt, source, target, { session, sessionPath, locator })
|
|
323
|
+
?? firstFailure([
|
|
324
|
+
[await backupIsTranscriptPrefix(receipt.backup ?? {}, receipt.pi_session_path!), "the receipt backup is missing, mismatched, or not a prefix of the adopted transcript"],
|
|
325
|
+
[await priorRequestIsRetiredLineage(activeFile, receipt), "the stale active request does not retain the receipt's retired placement"],
|
|
326
|
+
]);
|
|
327
|
+
if (failure) throw refuse(failure);
|
|
328
|
+
return { mode: receipt.mode!, retired: { herdr_session: receipt.herdr_session!, pane: receipt.pane! } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Build the relocated recovery record: the live source (injected) is the exact recorded Pi at the current locator. */
|
|
332
|
+
export async function buildRelocatedStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, relocation: Relocation, active: { file: string }, observe: { verify: () => Promise<LiveSourceProof>; agentList: () => Promise<string> }): Promise<StaleMainRecovery> {
|
|
333
|
+
const { locator, session, sessionPath, receipt } = completeRuntime(intent.record.runtime);
|
|
334
|
+
const canonical = { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: intent.target.cwd, launch_receipt: receipt };
|
|
335
|
+
let live_source: LiveSourceProof;
|
|
336
|
+
try {
|
|
337
|
+
live_source = await observe.verify();
|
|
338
|
+
assertRelocatedAgentList(await observe.agentList(), intent.source, intent.target, relocation.retired);
|
|
339
|
+
} catch (error) { throw refuse((error as Error).message.replace(/\.$/, "")); }
|
|
340
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, () => false, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async () => {} });
|
|
341
|
+
const prior_receipt = await archiveStaleReceipt(root, seat, canonical, relocation);
|
|
342
|
+
return { canonical_source: canonical, prior_receipt, ...(prior_request ? { prior_request } : {}), live_source, relocation };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Pi agents from one complete global Herdr agent list, each with its pane and exact session path; `who` names the refusing operation. */
|
|
346
|
+
export function listedPiAgents(raw: string, who: string) {
|
|
347
|
+
const incomplete = () => new Error(`${who} requires complete global Herdr agent-list evidence.`);
|
|
348
|
+
const parsed = JSON.parse(raw) as { result?: { agents?: unknown }; agents?: unknown };
|
|
349
|
+
const agents = (parsed.result ?? parsed).agents;
|
|
350
|
+
if (!Array.isArray(agents)) throw incomplete();
|
|
351
|
+
return agents.filter((entry) => {
|
|
352
|
+
if (!entry || typeof entry !== "object") throw incomplete();
|
|
353
|
+
return (entry as { agent?: unknown }).agent === "pi";
|
|
354
|
+
}).map((entry) => {
|
|
355
|
+
const { pane_id, agent_session } = entry as { pane_id?: string; agent_session?: { kind?: string; value?: string } };
|
|
356
|
+
if (agent_session?.kind !== "path" || !agent_session.value) throw incomplete();
|
|
357
|
+
return { pane: pane_id, path: resolve(agent_session.value) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A relocated recovery requires the exact recorded Pi once, at the current pane, and no Pi at the retired pane. */
|
|
362
|
+
export function assertRelocatedAgentList(raw: string, source: Pick<Source, "pi_session_path">, current: { pane: string }, retired: { pane: string }) {
|
|
363
|
+
const agents = listedPiAgents(raw, "Relocated recovery"), recorded = agents.filter((agent) => agent.path === resolve(source.pi_session_path));
|
|
364
|
+
if (agents.some((agent) => agent.pane === retired.pane)) throw new Error("A Pi is live at the retired relocation pane.");
|
|
365
|
+
if (recorded.some((agent) => agent.pane !== current.pane)) throw new Error("The recorded Pi is also live outside the current pane.");
|
|
366
|
+
if (recorded.length !== 1) throw new Error("The recorded Pi must be listed exactly once at the current pane.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A target pane that differs from the source pane is a relocation only when the seat records a manual binding; otherwise undefined. */
|
|
370
|
+
export async function relocationFor(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
371
|
+
return target.pane !== source.pane && await isManualRebindingReceipt(record) ? assertRelocatedBinding(record, source, target, activeFile) : undefined;
|
|
372
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// URN: component:flow:e022:task-supersede-authorization:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:coordinator-stale-task-supersede-authorization
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Owner-authorized supersession of a stale coordinator task.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { atomicYaml, paths, readYaml, seat, yaml } from "./core";
|
|
11
|
+
|
|
12
|
+
/** The one exact tuple an operator@desk message may carry to authorize `task supersede` by main. */
|
|
13
|
+
export type SupersedeAuthorization = {
|
|
14
|
+
schema: "atdd-workflow/task-supersede-authorization/v1";
|
|
15
|
+
project: string;
|
|
16
|
+
task: string;
|
|
17
|
+
coordinator: string;
|
|
18
|
+
action: "supersede";
|
|
19
|
+
reason: string;
|
|
20
|
+
task_sha256: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sha256 = (content: string) => createHash("sha256").update(content).digest("hex");
|
|
24
|
+
|
|
25
|
+
/** Locate the one operator@desk message `reference`, addressed to main in a thread main joined, and return its typed payload. */
|
|
26
|
+
export async function operatorAuthorizationPayload(root: string, reference: string, main: string): Promise<unknown> {
|
|
27
|
+
type Found = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: unknown };
|
|
28
|
+
const folders = await readdir(paths(root).threads, { withFileTypes: true });
|
|
29
|
+
const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
|
|
30
|
+
.map(async (entry) => {
|
|
31
|
+
try {
|
|
32
|
+
const [thread, message] = await Promise.all([
|
|
33
|
+
readYaml<{ schema?: string; participants?: string[] }>(paths(root).threadFile(entry.name)),
|
|
34
|
+
readYaml<Found>(paths(root).message(entry.name, reference)),
|
|
35
|
+
]);
|
|
36
|
+
return { thread, message };
|
|
37
|
+
} catch { return undefined; }
|
|
38
|
+
}))).filter((entry): entry is NonNullable<typeof entry> =>
|
|
39
|
+
entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
|
|
40
|
+
&& entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
|
|
41
|
+
if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
|
|
42
|
+
throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
|
|
43
|
+
}
|
|
44
|
+
const message = matches[0].message;
|
|
45
|
+
if (message.to !== "all" && !message.to?.includes(main)) throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
|
|
46
|
+
return message.authorization;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Create-only evidence: an existing file must hold exactly the same bytes. */
|
|
50
|
+
async function publishOrVerify(file: string, bytes: string) {
|
|
51
|
+
try { await writeFile(file, bytes, { encoding: "utf8", flag: "wx" }); }
|
|
52
|
+
catch (error) { if ((error as { code?: string }).code !== "EEXIST") throw error; }
|
|
53
|
+
if (await readFile(file, "utf8") !== bytes) throw new Error("Task supersede archive collides with different immutable evidence.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Archive the pre-mutation task bytes and the authorization by task hash, create-only. */
|
|
57
|
+
async function archiveEvidence(root: string, projectName: string, id: string, hash: string, taskBytes: string, evidence: Record<string, unknown>) {
|
|
58
|
+
const folder = join(root, ".atdd-flow", "task-supersede-archive", projectName, id, hash);
|
|
59
|
+
await mkdir(folder, { recursive: true });
|
|
60
|
+
await publishOrVerify(join(folder, "task.yaml"), taskBytes);
|
|
61
|
+
await publishOrVerify(join(folder, "authorization.yaml"), yaml.print(evidence));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Exact tuple equality: the same keys with the same values, and nothing more. */
|
|
65
|
+
function sameTuple(actual: unknown, expected: SupersedeAuthorization) {
|
|
66
|
+
const record = actual && typeof actual === "object" ? actual as Record<string, unknown> : undefined;
|
|
67
|
+
const keys = Object.keys(expected) as (keyof SupersedeAuthorization)[];
|
|
68
|
+
return Boolean(record) && Object.keys(record!).length === keys.length && keys.every((key) => record![key] === expected[key]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Subject = { project: string; id: string; coordinator: string; reason: string; main: string; reference: string };
|
|
72
|
+
|
|
73
|
+
async function assertStaleCoordinatorTask(root: string, subject: Subject) {
|
|
74
|
+
const owner = await seat(root, subject.coordinator).catch(() => undefined);
|
|
75
|
+
if (owner?.role !== "coordinator" || owner.project !== subject.project) {
|
|
76
|
+
throw new Error(`Task ${subject.id} is not owned by a named coordinator of ${subject.project}; the owner authorization applies only to coordinator tasks.`);
|
|
77
|
+
}
|
|
78
|
+
const { launchEligibleTasks } = await import("./pi-runtime-launch");
|
|
79
|
+
if ((await launchEligibleTasks(root, owner, subject.coordinator)).length) {
|
|
80
|
+
throw new Error(`${subject.coordinator} has a launch-eligible task and can supersede task ${subject.id} itself.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate every exact owner-authorization and staleness condition, then archive the evidence. Nothing is written until
|
|
86
|
+
* all checks pass; the caller records the terminal disposition afterwards. Returns the authorization reference.
|
|
87
|
+
*/
|
|
88
|
+
export async function authorizeSupersede(root: string, subject: Subject): Promise<string> {
|
|
89
|
+
const taskBytes = await readFile(paths(root).taskFile(subject.project, subject.id), "utf8");
|
|
90
|
+
const expected: SupersedeAuthorization = {
|
|
91
|
+
schema: "atdd-workflow/task-supersede-authorization/v1", project: subject.project, task: subject.id,
|
|
92
|
+
coordinator: subject.coordinator, action: "supersede", reason: subject.reason, task_sha256: sha256(taskBytes),
|
|
93
|
+
};
|
|
94
|
+
const authorization = await operatorAuthorizationPayload(root, subject.reference, subject.main);
|
|
95
|
+
if (!sameTuple(authorization, expected)) throw new Error(`Authorization ${subject.reference} is not an exact supersede authorization.`);
|
|
96
|
+
await assertStaleCoordinatorTask(root, subject);
|
|
97
|
+
await archiveEvidence(root, subject.project, subject.id, expected.task_sha256, taskBytes, { id: subject.reference, from: "operator@desk", authorization: expected });
|
|
98
|
+
return subject.reference;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse `post --task-supersede-authorization`: operator@desk only, one exact task-supersede-authorization/v1 tuple. */
|
|
102
|
+
export function parseSupersedeAuthorization(encoded: string | undefined, from: string): SupersedeAuthorization | undefined {
|
|
103
|
+
if (!encoded) return undefined;
|
|
104
|
+
if (from !== "operator@desk") throw new Error("Only operator@desk may create a task supersede authorization.");
|
|
105
|
+
let value: Partial<SupersedeAuthorization> | undefined;
|
|
106
|
+
try { value = JSON.parse(encoded); } catch { throw new Error("--task-supersede-authorization must be valid JSON."); }
|
|
107
|
+
const fields = [value?.project, value?.task, value?.coordinator, value?.reason, value?.task_sha256];
|
|
108
|
+
if (value?.schema !== "atdd-workflow/task-supersede-authorization/v1" || value.action !== "supersede"
|
|
109
|
+
|| !fields.every((entry) => typeof entry === "string" && entry.length) || !/^[a-f0-9]{64}$/i.test(value.task_sha256!)) {
|
|
110
|
+
throw new Error("--task-supersede-authorization must contain one exact task-supersede-authorization/v1 tuple.");
|
|
111
|
+
}
|
|
112
|
+
return value as SupersedeAuthorization;
|
|
113
|
+
}
|
package/src/tasks.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
5
5
|
} from "./core";
|
|
6
|
+
import { operatorAuthorizationPayload } from "./task-supersede-authorization";
|
|
6
7
|
import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
|
|
7
8
|
|
|
8
9
|
export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "superseded" | "rejected";
|
|
@@ -27,7 +28,7 @@ export type CoordinatorTransfer = {
|
|
|
27
28
|
lineage_ref?: LineageRef;
|
|
28
29
|
effective_at: string;
|
|
29
30
|
};
|
|
30
|
-
export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
|
|
31
|
+
export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string; authorization?: string };
|
|
31
32
|
export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
|
|
32
33
|
export type DispatchAlert = { key: string; message: string; observed_at: string };
|
|
33
34
|
/** Persisted evidence for an immediate assignment; each field is written only by its own typed command. */
|
|
@@ -463,28 +464,7 @@ type TransferAuthorization = {
|
|
|
463
464
|
};
|
|
464
465
|
|
|
465
466
|
async function operatorAuthorization(root: string, reference: string, main: string, expected: TransferAuthorization) {
|
|
466
|
-
|
|
467
|
-
type AuthorizationThread = { schema?: string; participants?: string[] };
|
|
468
|
-
const folders = await readdir(paths(root).threads, { withFileTypes: true });
|
|
469
|
-
const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
|
|
470
|
-
.map(async (entry) => {
|
|
471
|
-
try {
|
|
472
|
-
const [thread, message] = await Promise.all([
|
|
473
|
-
readYaml<AuthorizationThread>(paths(root).threadFile(entry.name)),
|
|
474
|
-
readYaml<AuthorizationMessage>(paths(root).message(entry.name, reference)),
|
|
475
|
-
]);
|
|
476
|
-
return { thread, message };
|
|
477
|
-
} catch { return undefined; }
|
|
478
|
-
}))).filter((entry): entry is NonNullable<typeof entry> =>
|
|
479
|
-
entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
|
|
480
|
-
&& entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
|
|
481
|
-
if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
|
|
482
|
-
throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
|
|
483
|
-
}
|
|
484
|
-
if (matches[0].message.to !== "all" && !matches[0].message.to?.includes(main)) {
|
|
485
|
-
throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
|
|
486
|
-
}
|
|
487
|
-
const authorization = matches[0].message.authorization;
|
|
467
|
+
const authorization = await operatorAuthorizationPayload(root, reference, main) as TransferAuthorization | undefined;
|
|
488
468
|
if (!authorization || authorization.schema !== expected.schema
|
|
489
469
|
|| authorization.project !== expected.project || authorization.task !== expected.task
|
|
490
470
|
|| authorization.from !== expected.from || authorization.to !== expected.to
|
|
@@ -721,10 +701,14 @@ export async function block(root: string, projectName: string, id: string, args:
|
|
|
721
701
|
export async function disposition(root: string, projectName: string, id: string, kind: TerminalDisposition["kind"], args: string[]) {
|
|
722
702
|
const task = await readTask(root, projectName, taskId(id));
|
|
723
703
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
724
|
-
|
|
704
|
+
const owner = kind === "superseded" && actor === `main@${projectName}` && actor !== task.coordinator && Boolean(words(args, "--authorization"));
|
|
705
|
+
if (actor !== task.coordinator && !owner) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
|
|
725
706
|
if (task.disposition || task.status === "done") throw new Error(`Task ${id} already has terminal history and cannot be ${kind}.`);
|
|
707
|
+
const reason = required(words(args, "--reason"), "--reason");
|
|
708
|
+
const reference = owner ? words(args, "--authorization") : undefined;
|
|
709
|
+
const authorization = reference ? await (await import("./task-supersede-authorization")).authorizeSupersede(root, { project: projectName, id, coordinator: task.coordinator, reason, main: actor, reference }) : undefined;
|
|
726
710
|
task.status = kind;
|
|
727
|
-
task.disposition = { kind, by: actor, at: now(), reason
|
|
711
|
+
task.disposition = { kind, by: actor, at: now(), reason, ...(authorization ? { authorization } : {}) };
|
|
728
712
|
delete task.blocker;
|
|
729
713
|
await writeTask(root, projectName, taskId(id), task);
|
|
730
714
|
console.log(`${id} ${kind}`);
|
package/src/threads.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { notify } from "./adapters";
|
|
4
4
|
import { seatTasks, terminal } from "./tasks";
|
|
5
|
+
import { type SupersedeAuthorization, parseSupersedeAuthorization } from "./task-supersede-authorization";
|
|
5
6
|
import {
|
|
6
7
|
atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
|
|
7
8
|
required, runtimeAddress, seat, words,
|
|
@@ -29,7 +30,7 @@ export type Message = {
|
|
|
29
30
|
created_at: string;
|
|
30
31
|
body: string;
|
|
31
32
|
/** Immutable owner authorization for one exact coordinator-transfer tuple. */
|
|
32
|
-
authorization?: TransferAuthorization;
|
|
33
|
+
authorization?: TransferAuthorization | SupersedeAuthorization;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
async function thread(root: string, threadId: string) {
|
|
@@ -280,6 +281,11 @@ function transferAuthorization(args: string[], from: string) {
|
|
|
280
281
|
return value as TransferAuthorization;
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
/** The one typed owner authorization a post may carry: a transfer tuple or a supersede tuple. */
|
|
285
|
+
function typedAuthorization(args: string[], from: string) {
|
|
286
|
+
return transferAuthorization(args, from) ?? parseSupersedeAuthorization(words(args, "--task-supersede-authorization"), from);
|
|
287
|
+
}
|
|
288
|
+
|
|
283
289
|
async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
|
|
284
290
|
const record = await thread(root, threadId);
|
|
285
291
|
const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
|
|
@@ -289,7 +295,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
|
|
|
289
295
|
if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
|
|
290
296
|
await assertRoute(root, from, recipients.filter((address) => address !== from));
|
|
291
297
|
const kind = overrides.kind ?? "message";
|
|
292
|
-
const authorization = overrides.authorization ??
|
|
298
|
+
const authorization = overrides.authorization ?? typedAuthorization(args, from);
|
|
293
299
|
// Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
|
|
294
300
|
const label = words(args, "--label") ?? kind;
|
|
295
301
|
const message: Message = {
|