@afokapu/atdd-flow 0.3.77 → 0.3.79
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/handover.ts +2 -1
- package/src/pi-runtime-launch.ts +1 -1
- package/src/stale-main-recovery.ts +92 -10
- 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/handover.ts
CHANGED
|
@@ -739,7 +739,8 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
739
739
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
740
740
|
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
|
|
741
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"))
|
|
742
|
+
() => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "agent", "list")),
|
|
743
|
+
{ paneList: () => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "pane", "list")), authorizationHash: authorization.hash, at: () => dependencies.at?.() ?? now() }) : undefined;
|
|
743
744
|
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
744
745
|
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
745
746
|
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);
|
|
@@ -24,21 +24,26 @@ export type StaleMainRecovery = {
|
|
|
24
24
|
prior_receipt: { path: string; sha256: string; archive: string };
|
|
25
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. */
|
|
26
26
|
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** Absent only for a pane-gone relocation, whose source is proven absent rather than live. */
|
|
28
|
+
live_source?: LiveSourceProof;
|
|
29
|
+
/** Present only for a relocated main: an operator manual rebinding receipt, or a launcher receipt whose retired pane is gone. */
|
|
29
30
|
relocation?: Relocation;
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
export const MANUAL_REBINDING_SCHEMA = "atdd-flow/manual-pi-runtime-rebinding-receipt/v1";
|
|
33
34
|
export const MANUAL_REBINDING_MODE = "operator-authorized-manual-in-place-adoption";
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
36
|
+
/** Relocation mode of a launcher-generated receipt whose canonical pane and exact Pi are both absent from Herdr. */
|
|
37
|
+
export const PANE_GONE_MODE = "launcher-receipt-pane-gone";
|
|
38
|
+
|
|
39
|
+
/** The retired placement named by the receipt; lineage only, never a live source. A pane-gone relocation also records the global absence proof. */
|
|
40
|
+
export type Relocation = { mode: string; retired: { herdr_session: string; pane: string }; absence?: { authorization_hash: string; verified_at: string } };
|
|
37
41
|
|
|
38
42
|
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
39
43
|
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
40
44
|
|
|
41
45
|
type Run = (argv: string[]) => Promise<string>;
|
|
46
|
+
type Recorded = { session: string; sessionPath: string; locator: { session: string; pane: string } };
|
|
42
47
|
type Source = HandoverReplacementRequest["source"];
|
|
43
48
|
type Target = HandoverReplacementRequest["target"];
|
|
44
49
|
type PriorReceipt = {
|
|
@@ -74,6 +79,7 @@ export function staleCanonicalSource(record: Seat, source: Source, target: Targe
|
|
|
74
79
|
|
|
75
80
|
/** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
|
|
76
81
|
export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
|
|
82
|
+
if (relocation?.mode === PANE_GONE_MODE) return launchReceiptProvesCanonical(receipt, seat, { ...canonical, ...relocation.retired });
|
|
77
83
|
return relocation ? manualReceiptProvesCanonical(receipt, seat, canonical, relocation) : launchReceiptProvesCanonical(receipt, seat, canonical);
|
|
78
84
|
}
|
|
79
85
|
|
|
@@ -201,14 +207,24 @@ function targetMatches(request: HandoverReplacementRequest, canonical: StaleMain
|
|
|
201
207
|
|
|
202
208
|
/** A relocated request names the exact recorded Pi at the retired pane, and moves to the current pane that Herdr observed it on. */
|
|
203
209
|
function relocatedTargetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
|
|
204
|
-
return relocation.mode
|
|
210
|
+
return [MANUAL_REBINDING_MODE, PANE_GONE_MODE].includes(relocation.mode)
|
|
205
211
|
&& canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session && canonical.pane === request.target.pane
|
|
206
212
|
&& request.source.pi_session === canonical.pi_session && request.source.pi_session_path === canonical.pi_session_path
|
|
207
213
|
&& request.source.herdr_session === relocation.retired.herdr_session && request.source.pane === relocation.retired.pane
|
|
208
214
|
&& (relocation.retired.herdr_session !== canonical.herdr_session || relocation.retired.pane !== canonical.pane);
|
|
209
215
|
}
|
|
210
216
|
|
|
211
|
-
|
|
217
|
+
/** A pane-gone relocation has no live source: its absence proof must be bound to this request's authorization. */
|
|
218
|
+
function absenceMatches(request: HandoverReplacementRequest, relocation: Relocation) {
|
|
219
|
+
return relocation.absence?.authorization_hash === request.authorization.hash && Boolean(relocation.absence.verified_at);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof | undefined, relocation?: Relocation) {
|
|
223
|
+
if (relocation?.mode === PANE_GONE_MODE) return !live && absenceMatches(request, relocation);
|
|
224
|
+
return live !== undefined && liveSnapshotMatches(request, live, relocation);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function liveSnapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof, relocation?: Relocation) {
|
|
212
228
|
const placement = relocation ? request.target : request.source;
|
|
213
229
|
return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
|
|
214
230
|
&& live.pi_session_path === request.source.pi_session_path && live.herdr_session === placement.herdr_session
|
|
@@ -218,7 +234,7 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
|
|
|
218
234
|
function exitMatches(recovery: StaleMainRecovery) {
|
|
219
235
|
const exited = recovery.prior_request?.source_exited, replaced = recovery.prior_request?.live_source_exited;
|
|
220
236
|
return (!exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at)))
|
|
221
|
-
&& (!replaced || (!sameSource(replaced, recovery.live_source) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
|
|
237
|
+
&& (!replaced || (!(recovery.live_source && sameSource(replaced, recovery.live_source)) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
|
|
222
238
|
}
|
|
223
239
|
|
|
224
240
|
function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
@@ -236,7 +252,8 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
236
252
|
}
|
|
237
253
|
|
|
238
254
|
/** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
|
|
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
|
|
255
|
+
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>, gone: PaneGoneObservation): Promise<StaleMainRecovery> {
|
|
256
|
+
if (intent.relocation?.mode === PANE_GONE_MODE) return buildPaneGoneStaleMainRecovery(root, seat, intent, intent.relocation, active, { agentList, ...gone });
|
|
240
257
|
if (intent.relocation) return buildRelocatedStaleMainRecovery(root, seat, intent, intent.relocation, active, { verify: () => verify(intent.source, agentListOnce()), agentList });
|
|
241
258
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
242
259
|
const once = agentListOnce(), live_source = await verify(canonical, once);
|
|
@@ -366,7 +383,72 @@ export function assertRelocatedAgentList(raw: string, source: Pick<Source, "pi_s
|
|
|
366
383
|
if (recorded.length !== 1) throw new Error("The recorded Pi must be listed exactly once at the current pane.");
|
|
367
384
|
}
|
|
368
385
|
|
|
369
|
-
/** A target pane that differs from the source pane is a relocation only
|
|
386
|
+
/** A target pane that differs from the source pane is a relocation only for a manual binding or an exact pane-gone launcher receipt; otherwise undefined. */
|
|
370
387
|
export async function relocationFor(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
371
|
-
|
|
388
|
+
if (target.pane === source.pane) return undefined;
|
|
389
|
+
return await isManualRebindingReceipt(record) ? assertRelocatedBinding(record, source, target, activeFile) : assertPaneGoneBinding(record, source, target, activeFile);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function readLauncherReceipt(path: string) {
|
|
393
|
+
try {
|
|
394
|
+
const receipt = yaml.parse<PriorReceipt>(await readFile(path, "utf8"));
|
|
395
|
+
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" ? receipt : undefined;
|
|
396
|
+
} catch { return undefined; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The launcher receipt names this seat's exact recorded Pi at a pane other than the seat's current locator. */
|
|
400
|
+
function receiptNamesRetiredPane(receipt: PriorReceipt, record: Seat, recorded: Recorded) {
|
|
401
|
+
return receipt.seat === record.address && receipt.pi_session === recorded.session && receipt.pi_session_path === recorded.sessionPath
|
|
402
|
+
&& Boolean(receipt.herdr_session && receipt.pane) && (receipt.herdr_session !== recorded.locator.session || receipt.pane !== recorded.locator.pane);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The request moves the exact recorded Pi from the receipt's retired pane to the seat's current locator. */
|
|
406
|
+
function requestMovesRetiredPi(receipt: PriorReceipt, source: Source, target: Target, recorded: Recorded) {
|
|
407
|
+
return source.pi_session === recorded.session && source.pi_session_path === recorded.sessionPath
|
|
408
|
+
&& source.herdr_session === receipt.herdr_session && source.pane === receipt.pane
|
|
409
|
+
&& target.herdr_session === recorded.locator.session && target.pane === recorded.locator.pane;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A launcher-generated receipt whose retired canonical pane is the request source while the request target is the seat's current locator; otherwise undefined, so the ordinary released-source-pane guard refuses. */
|
|
413
|
+
async function assertPaneGoneBinding(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
|
|
414
|
+
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
415
|
+
if (!runtime?.launch_receipt || typeof locator === "string" || !locator) return undefined;
|
|
416
|
+
const receipt = await readLauncherReceipt(runtime.launch_receipt);
|
|
417
|
+
if (!receipt || !runtime.pi_session || !runtime.pi_session_path) return undefined;
|
|
418
|
+
const recorded = { session: runtime.pi_session, sessionPath: runtime.pi_session_path, locator };
|
|
419
|
+
if (!receiptNamesRetiredPane(receipt, record, recorded) || !requestMovesRetiredPi(receipt, source, target, recorded) || !await priorRequestIsRetiredLineage(activeFile, receipt)) return undefined;
|
|
420
|
+
return { mode: PANE_GONE_MODE, retired: { herdr_session: receipt.herdr_session!, pane: receipt.pane! } };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Pane ids from one complete global Herdr pane list. */
|
|
424
|
+
function listedPaneIds(raw: string) {
|
|
425
|
+
const parsed = JSON.parse(raw) as { result?: { panes?: unknown }; panes?: unknown };
|
|
426
|
+
const panes = (parsed.result ?? parsed).panes;
|
|
427
|
+
const ids = Array.isArray(panes) ? panes.map((pane) => (pane as { pane_id?: unknown } | null)?.pane_id) : undefined;
|
|
428
|
+
if (!ids || ids.some((id) => typeof id !== "string")) throw new Error("Pane-gone recovery requires complete global Herdr pane-list evidence.");
|
|
429
|
+
return ids as string[];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const piSessionId = (path: string) => 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];
|
|
433
|
+
|
|
434
|
+
/** Pane-gone recovery requires the retired pane and the exact recorded Pi to be absent from one complete global pane list and agent list. */
|
|
435
|
+
export function assertPaneGone(agentsRaw: string, panesRaw: string, source: Pick<Source, "pi_session" | "pi_session_path">, retired: { pane: string }) {
|
|
436
|
+
if (listedPaneIds(panesRaw).includes(retired.pane)) throw new Error("The retired canonical pane is still listed by Herdr.");
|
|
437
|
+
const agents = listedPiAgents(agentsRaw, "Pane-gone recovery");
|
|
438
|
+
if (agents.some((agent) => agent.pane === retired.pane)) throw new Error("A Pi is live at the retired canonical pane.");
|
|
439
|
+
if (agents.some(({ path }) => path === resolve(source.pi_session_path) || piSessionId(path) === source.pi_session)) throw new Error("The exact recorded Pi is still a live Pi agent.");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Herdr pane-list evidence plus the authorization and clock that bind the absence proof to one request. */
|
|
443
|
+
export type PaneGoneObservation = { paneList: () => Promise<string>; authorizationHash: string; at: () => string };
|
|
444
|
+
|
|
445
|
+
async function buildPaneGoneStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, pane: Relocation, active: { file: string }, observe: { agentList: () => Promise<string> } & PaneGoneObservation): Promise<StaleMainRecovery> {
|
|
446
|
+
const { locator, session, sessionPath, receipt } = completeRuntime(intent.record.runtime);
|
|
447
|
+
const canonical = { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: intent.target.cwd, launch_receipt: receipt };
|
|
448
|
+
try { assertPaneGone(await observe.agentList(), await observe.paneList(), intent.source, pane.retired); }
|
|
449
|
+
catch (error) { throw new Error(`Path-A replacement target must be the exact released source pane; pane-gone recovery refused: ${(error as Error).message}`); }
|
|
450
|
+
const verified_at = observe.at(), relocation: Relocation = { ...pane, absence: { authorization_hash: observe.authorizationHash, verified_at } };
|
|
451
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, () => false, { canonical, source: intent.source, verifiedAt: verified_at, assertAbsent: async () => {} });
|
|
452
|
+
const prior_receipt = await archiveStaleReceipt(root, seat, canonical, relocation);
|
|
453
|
+
return { canonical_source: canonical, prior_receipt, ...(prior_request ? { prior_request } : {}), relocation };
|
|
372
454
|
}
|
|
@@ -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 = {
|