@afokapu/atdd-flow 0.3.9 → 0.3.11
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 +19 -3
- package/src/core.ts +13 -1
- package/src/ephemeral-resources.ts +236 -0
- package/src/seats.ts +136 -11
- package/src/tasks.ts +4 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
|
|
4
|
+
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
5
5
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
6
6
|
import * as tasks from "./tasks";
|
|
7
|
-
import { required, values, words } from "./core";
|
|
7
|
+
import { required, values, words, yaml } from "./core";
|
|
8
8
|
import { addressBook } from "./address-book";
|
|
9
9
|
import * as judgment from "./judgment";
|
|
10
10
|
import * as reviews from "./reviews";
|
|
11
11
|
import { status } from "./overview";
|
|
12
12
|
import { multiplexer } from "./multiplexer";
|
|
13
|
+
import * as cleanup from "./ephemeral-resources";
|
|
13
14
|
|
|
14
15
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
15
16
|
|
|
@@ -24,6 +25,7 @@ Usage:
|
|
|
24
25
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
25
26
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
26
27
|
atdd-flow pi extension-path
|
|
28
|
+
atdd-flow pi runtime launch <address> --pane <existing-herdr-pane> --herdr-session <session> [--resume] [--dry-run]
|
|
27
29
|
atdd-flow application use <address> <application>
|
|
28
30
|
atdd-flow multiplexer status|apply herdr [--session <name>]
|
|
29
31
|
atdd-flow describe <address> --purpose <one-line responsibility>
|
|
@@ -39,6 +41,8 @@ Usage:
|
|
|
39
41
|
atdd-flow task unblock <project> <task-id> --by <coordinator-address>
|
|
40
42
|
atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
|
|
41
43
|
atdd-flow task open <project> <task-id>
|
|
44
|
+
atdd-flow cleanup status <project>
|
|
45
|
+
atdd-flow cleanup checklist <project> <task-id>
|
|
42
46
|
atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
|
|
43
47
|
atdd-flow thread add <thread-id> <address>
|
|
44
48
|
atdd-flow thread open <thread-id>
|
|
@@ -82,7 +86,13 @@ async function main() {
|
|
|
82
86
|
attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
|
|
83
87
|
pi: async () => {
|
|
84
88
|
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
85
|
-
|
|
89
|
+
if (rest[0] === "runtime" && rest[1] === "launch") {
|
|
90
|
+
const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
|
|
91
|
+
if (plan.dryRun) console.log(yaml.print(plan));
|
|
92
|
+
else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("Use `pi extension-path` or `pi runtime launch <address> --pane <pane> --herdr-session <session> [--resume] [--dry-run]`.");
|
|
86
96
|
},
|
|
87
97
|
application: async () => {
|
|
88
98
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
|
@@ -108,6 +118,12 @@ async function main() {
|
|
|
108
118
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
109
119
|
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|unblock|list|open`.");
|
|
110
120
|
},
|
|
121
|
+
cleanup: async () => {
|
|
122
|
+
const [subcommand, projectName, taskId] = rest;
|
|
123
|
+
if (subcommand === "status") return console.log(Bun.YAML.stringify(await cleanup.coordinatorStatus(root, required(projectName, "project"))));
|
|
124
|
+
if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
|
|
125
|
+
throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
|
|
126
|
+
},
|
|
111
127
|
thread: async () => {
|
|
112
128
|
const [subcommand, ...tail] = rest;
|
|
113
129
|
if (subcommand === "start") return startThread(root, tail);
|
package/src/core.ts
CHANGED
|
@@ -51,7 +51,19 @@ export type Project = {
|
|
|
51
51
|
export type HerdrLocator = { session: string; pane: string };
|
|
52
52
|
/** Strings are legacy native addresses; new Herdr bindings retain their session. */
|
|
53
53
|
export type RuntimeAddress = string | HerdrLocator;
|
|
54
|
-
export type Runtime = {
|
|
54
|
+
export type Runtime = {
|
|
55
|
+
application: string;
|
|
56
|
+
addresses: Record<string, RuntimeAddress>;
|
|
57
|
+
attached_at?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
wake?: "host" | "native";
|
|
60
|
+
/** Exact Pi session used by the narrow Pi/Herdr launch command. */
|
|
61
|
+
pi_session?: string;
|
|
62
|
+
/** Exact durable JSONL path reported by Herdr for that Pi session. */
|
|
63
|
+
pi_session_path?: string;
|
|
64
|
+
/** Immutable, non-secret receipt for that selected launch. */
|
|
65
|
+
launch_receipt?: string;
|
|
66
|
+
};
|
|
55
67
|
|
|
56
68
|
export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
|
|
57
69
|
if (application !== "herdr" || typeof value === "string") return { address: value, ...(application === "herdr" && legacyHerdrSession ? { session: legacyHerdrSession } : {}) };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { atomicYaml, exists, now, readYaml, taskId } from "./core";
|
|
5
|
+
|
|
6
|
+
export type InventoryKind = "fixture-directory" | "git-worktree" | "git-branch" | "process" | "port" | "herdr-workspace" | "github-preview";
|
|
7
|
+
export type InventoryEntry = { kind: InventoryKind; locator: string };
|
|
8
|
+
export type Parent = { project: string; task: string; pr?: string };
|
|
9
|
+
export type Declaration = {
|
|
10
|
+
id: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
purpose: string;
|
|
13
|
+
parent: Parent;
|
|
14
|
+
trigger: { merge?: boolean; close?: boolean; expiresAt?: string };
|
|
15
|
+
inventory: InventoryEntry[];
|
|
16
|
+
cleanupAssignee: string;
|
|
17
|
+
authorizedScope: "local" | "git" | "herdr" | "github";
|
|
18
|
+
};
|
|
19
|
+
type Status = "pending" | "ready" | "cancelled" | "received" | "failed" | "timeout" | "retained";
|
|
20
|
+
type Record = {
|
|
21
|
+
schema: "atdd-workflow/ephemeral-resource/v1";
|
|
22
|
+
id: string;
|
|
23
|
+
declaration: Declaration;
|
|
24
|
+
cleanupTask: string;
|
|
25
|
+
status: Status;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
};
|
|
28
|
+
type CleanupRecord = {
|
|
29
|
+
schema: "atdd-workflow/cleanup-record/v1";
|
|
30
|
+
id: string;
|
|
31
|
+
parent: Parent;
|
|
32
|
+
assignee: string;
|
|
33
|
+
status: Status;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
};
|
|
36
|
+
type Audit = { schema: "atdd-workflow/ephemeral-resource-audit/v1"; event: string; immutable: true; at: string; detail?: string };
|
|
37
|
+
|
|
38
|
+
const resourceRoot = (root: string) => join(root, ".atdd-flow", "ephemeral-resources");
|
|
39
|
+
const resourceFile = (root: string, id: string) => join(resourceRoot(root), `${taskId(id)}.yaml`);
|
|
40
|
+
const cleanupFile = (root: string, id: string) => join(resourceRoot(root), "cleanup", `${taskId(id)}.yaml`);
|
|
41
|
+
const auditRoot = (root: string, id: string) => join(resourceRoot(root), "audit", taskId(id));
|
|
42
|
+
const tombstoneFile = (root: string, id: string) => join(resourceRoot(root), "tombstones", `${taskId(id)}.yaml`);
|
|
43
|
+
|
|
44
|
+
function required(value: unknown, name: string): asserts value {
|
|
45
|
+
if (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length) throw new Error(`Declaration requires ${name}.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const scopeForKind: Record<InventoryKind, Declaration["authorizedScope"]> = {
|
|
49
|
+
"fixture-directory": "local",
|
|
50
|
+
"git-worktree": "git",
|
|
51
|
+
"git-branch": "git",
|
|
52
|
+
process: "local",
|
|
53
|
+
port: "local",
|
|
54
|
+
"herdr-workspace": "herdr",
|
|
55
|
+
"github-preview": "github",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function validate(value: Declaration) {
|
|
59
|
+
required(value?.id, "id");
|
|
60
|
+
taskId(value.id);
|
|
61
|
+
required(value.owner, "owner");
|
|
62
|
+
required(value.purpose, "purpose");
|
|
63
|
+
required(value.parent, "authoritative parent");
|
|
64
|
+
required(value.parent.project, "parent project");
|
|
65
|
+
required(value.parent.task, "parent task");
|
|
66
|
+
taskId(value.parent.task);
|
|
67
|
+
required(value.trigger, "expiry or trigger");
|
|
68
|
+
if (!value.trigger.merge && !value.trigger.close && !value.trigger.expiresAt) throw new Error("Declaration requires an expiry or trigger.");
|
|
69
|
+
required(value.inventory, "exact inventory");
|
|
70
|
+
required(value.cleanupAssignee, "cleanup assignee");
|
|
71
|
+
required(value.authorizedScope, "authorized scope");
|
|
72
|
+
if (!(["local", "git", "herdr", "github"] as string[]).includes(value.authorizedScope)) throw new Error("Declaration has an unsupported authorized scope.");
|
|
73
|
+
for (const entry of value.inventory) {
|
|
74
|
+
required(entry?.kind, "inventory kind");
|
|
75
|
+
required(entry.locator, "inventory locator");
|
|
76
|
+
if (!(["fixture-directory", "git-worktree", "git-branch", "process", "port", "herdr-workspace", "github-preview"] as string[]).includes(entry.kind)) {
|
|
77
|
+
throw new Error(`Declaration has unsupported inventory kind: ${entry.kind}.`);
|
|
78
|
+
}
|
|
79
|
+
if (scopeForKind[entry.kind] !== value.authorizedScope) {
|
|
80
|
+
throw new Error(`Declaration scope ${value.authorizedScope} is incompatible with ${entry.kind}; it requires ${scopeForKind[entry.kind]} scope.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function read(root: string, id: string) {
|
|
86
|
+
return readYaml<Record>(resourceFile(root, id));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function write(root: string, value: Record) {
|
|
90
|
+
value.updated_at = now();
|
|
91
|
+
await atomicYaml(resourceFile(root, value.id), value);
|
|
92
|
+
await atomicYaml(cleanupFile(root, value.id), {
|
|
93
|
+
schema: "atdd-workflow/cleanup-record/v1", id: value.cleanupTask, parent: value.declaration.parent,
|
|
94
|
+
assignee: value.declaration.cleanupAssignee, status: value.status, updated_at: value.updated_at,
|
|
95
|
+
} satisfies CleanupRecord);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Audit entries are write-once files; state records only point at that history. */
|
|
99
|
+
async function appendAudit(root: string, id: string, event: string, detail?: string) {
|
|
100
|
+
const folder = auditRoot(root, id);
|
|
101
|
+
await mkdir(folder, { recursive: true });
|
|
102
|
+
// A collision-proof name keeps concurrent signal records write-once; do not
|
|
103
|
+
// derive a filename from a racy directory listing.
|
|
104
|
+
await atomicYaml(join(folder, `${now().replace(/[^0-9]/g, "")}-${randomUUID()}-${event}.yaml`), {
|
|
105
|
+
schema: "atdd-workflow/ephemeral-resource-audit/v1", event, immutable: true, at: now(), ...(detail ? { detail } : {}),
|
|
106
|
+
} satisfies Audit);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function declare(root: string, declaration: Declaration) {
|
|
110
|
+
validate(declaration);
|
|
111
|
+
if (await exists(resourceFile(root, declaration.id))) throw new Error(`Ephemeral resource ${declaration.id} is already declared.`);
|
|
112
|
+
const value: Record = {
|
|
113
|
+
schema: "atdd-workflow/ephemeral-resource/v1", id: declaration.id, declaration,
|
|
114
|
+
cleanupTask: `cleanup-${taskId(declaration.id)}`, status: "pending", updated_at: now(),
|
|
115
|
+
};
|
|
116
|
+
// Persist the resource and cleanup record before publishing the declaration audit.
|
|
117
|
+
await write(root, value);
|
|
118
|
+
await appendAudit(root, declaration.id, "declared");
|
|
119
|
+
return { id: value.id, cleanupTask: value.cleanupTask, status: value.status as "pending" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function signal(root: string, id: string, event: "merged" | "closed" | "expired" | "cancelled") {
|
|
123
|
+
const value = await read(root, id);
|
|
124
|
+
if (value.status === "received" || value.status === "retained") return { status: value.status };
|
|
125
|
+
value.status = event === "cancelled" ? "cancelled" : "ready";
|
|
126
|
+
await write(root, value);
|
|
127
|
+
await appendAudit(root, id, event);
|
|
128
|
+
return { status: value.status as "ready" | "cancelled" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fixtureRefusal(root: string, declaration: Declaration, entry: InventoryEntry) {
|
|
132
|
+
if (entry.kind !== "fixture-directory" || declaration.authorizedScope !== "local") {
|
|
133
|
+
return `${entry.kind} cleanup requires a separately authorized typed adapter with verified scope, ownership, liveness, and Git safety attestations.`;
|
|
134
|
+
}
|
|
135
|
+
const fixtureRoot = resolve(root, ".atdd-flow", "fixtures");
|
|
136
|
+
const target = resolve(entry.locator);
|
|
137
|
+
const relation = relative(fixtureRoot, target);
|
|
138
|
+
if (!relation || relation.startsWith("..") || isAbsolute(relation)) {
|
|
139
|
+
return `Fixture target ${entry.locator} is outside the declared bounded fixture scope ${fixtureRoot}.`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* This delivery has no typed destructive adapter. It can execute only a
|
|
145
|
+
* declared local fixture below the Desk's bounded fixture root; all OS, git,
|
|
146
|
+
* process, Herdr, and GitHub targets fail closed before an executor is called.
|
|
147
|
+
* A future adapter must verify and immutably attest scope, ownership, liveness,
|
|
148
|
+
* and Git safety before it is allowed to extend this boundary. No daemon or GC.
|
|
149
|
+
*/
|
|
150
|
+
export async function execute(root: string, id: string, executor: (entry: InventoryEntry) => Promise<"removed" | "failed" | "timeout" | "cancelled">) {
|
|
151
|
+
const value = await read(root, id);
|
|
152
|
+
if (value.status === "received") return { status: "received" as const };
|
|
153
|
+
if (value.status === "retained") throw new Error(`Cleanup ${id} is retained and cannot be executed.`);
|
|
154
|
+
if (!(["ready", "cancelled"] as Status[]).includes(value.status)) throw new Error(`Cleanup ${id} is not ready for execution.`);
|
|
155
|
+
for (const entry of value.declaration.inventory) {
|
|
156
|
+
const reason = fixtureRefusal(root, value.declaration, entry);
|
|
157
|
+
if (reason) {
|
|
158
|
+
await appendAudit(root, id, "cleanup-refused", reason);
|
|
159
|
+
throw new Error(reason);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const entry of value.declaration.inventory) {
|
|
163
|
+
const outcome = await executor(entry);
|
|
164
|
+
if (outcome !== "removed") {
|
|
165
|
+
value.status = outcome;
|
|
166
|
+
await write(root, value);
|
|
167
|
+
await appendAudit(root, id, `cleanup-${outcome}`, entry.locator);
|
|
168
|
+
return { status: outcome };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
value.status = "received";
|
|
172
|
+
await write(root, value);
|
|
173
|
+
await appendAudit(root, id, "cleanup-received");
|
|
174
|
+
return { status: "received" as const };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function receipt(root: string, id: string) {
|
|
178
|
+
const value = await read(root, id);
|
|
179
|
+
if (value.status !== "received") throw new Error(`Cleanup ${id} has no receipt; it is ${value.status}.`);
|
|
180
|
+
return { status: "received" as const };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function retain(root: string, id: string, decision: { by: string; reason: string }) {
|
|
184
|
+
required(decision?.by, "retained decision author");
|
|
185
|
+
required(decision?.reason, "retained decision reason");
|
|
186
|
+
const value = await read(root, id);
|
|
187
|
+
if (value.status === "received") throw new Error(`Cleanup ${id} already has a receipt.`);
|
|
188
|
+
value.status = "retained";
|
|
189
|
+
await write(root, value);
|
|
190
|
+
const tombstone = tombstoneFile(root, id);
|
|
191
|
+
if (!await exists(tombstone)) await atomicYaml(tombstone, {
|
|
192
|
+
schema: "atdd-workflow/ephemeral-resource-tombstone/v1", resource: id, immutable: true, retained_by: decision.by, reason: decision.reason, at: now(),
|
|
193
|
+
});
|
|
194
|
+
await appendAudit(root, id, "retained", `${decision.by}: ${decision.reason}`);
|
|
195
|
+
return { status: "retained" as const, tombstone };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function audit(root: string, id: string) {
|
|
199
|
+
const folder = auditRoot(root, id);
|
|
200
|
+
try {
|
|
201
|
+
return await Promise.all((await readdir(folder)).filter((file) => file.endsWith(".yaml")).sort().map((file) => readYaml<Audit>(join(folder, file))));
|
|
202
|
+
} catch { return []; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function matching(root: string, parent: Parent) {
|
|
206
|
+
try {
|
|
207
|
+
const files = (await readdir(resourceRoot(root))).filter((file) => file.endsWith(".yaml"));
|
|
208
|
+
const records = await Promise.all(files.map((file) => readYaml<Record>(join(resourceRoot(root), file))));
|
|
209
|
+
return records.filter((value) => value.declaration.parent.project === parent.project && value.declaration.parent.task === parent.task);
|
|
210
|
+
} catch { return []; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function assertSettled(root: string, parent: Parent) {
|
|
214
|
+
const pending = (await matching(root, parent)).filter((value) => !(["received", "retained"] as Status[]).includes(value.status));
|
|
215
|
+
if (pending.length) throw new Error(`Parent ${parent.project}/${parent.task} is held by cleanup receipt or retained decision: ${pending.map((value) => value.id).join(", ")}.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const assertParentMayComplete = assertSettled;
|
|
219
|
+
export const assertDependentsMayUnblock = assertSettled;
|
|
220
|
+
|
|
221
|
+
export async function coordinatorStatus(root: string, project: string) {
|
|
222
|
+
// Coordinator status is project-wide, while completion gates match one parent task.
|
|
223
|
+
let all: Record[] = [];
|
|
224
|
+
try {
|
|
225
|
+
all = await Promise.all((await readdir(resourceRoot(root))).filter((file) => file.endsWith(".yaml")).map((file) => readYaml<Record>(join(resourceRoot(root), file))));
|
|
226
|
+
} catch { /* no declarations is a clean status */ }
|
|
227
|
+
return { pending: all.filter((value) => value.declaration.parent.project === project && !(["received", "retained"] as Status[]).includes(value.status)).map((value) => value.id).sort() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function checklist(root: string, parent: Parent) {
|
|
231
|
+
const records = await matching(root, parent);
|
|
232
|
+
const pending = records.filter((value) => !(["received", "retained"] as Status[]).includes(value.status));
|
|
233
|
+
return pending.length
|
|
234
|
+
? `Cleanup pending for ${parent.project}/${parent.task}: obtain cleanup receipt or retained-resource decision for ${pending.map((value) => value.id).join(", ")}.`
|
|
235
|
+
: `Cleanup checklist for ${parent.project}/${parent.task}: every declared resource has a cleanup receipt or retained-resource decision.`;
|
|
236
|
+
}
|
package/src/seats.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
5
5
|
import {
|
|
6
6
|
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
7
7
|
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
|
-
required, run, runtimeAddress, seat, words, yaml,
|
|
8
|
+
required, run, runOutput, runtimeAddress, seat, words, yaml,
|
|
9
9
|
} from "./core";
|
|
10
|
-
import { selectModel } from "./judgment";
|
|
10
|
+
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
11
11
|
import { seatTasks } from "./tasks";
|
|
12
12
|
|
|
13
13
|
const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
|
|
@@ -92,7 +92,7 @@ export async function spawn(root: string, projectName: string, roleName: string,
|
|
|
92
92
|
console.log(address);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"]) {
|
|
95
|
+
export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) {
|
|
96
96
|
const resolved = await canonicalAddress(root, address);
|
|
97
97
|
const record = await seat(root, resolved);
|
|
98
98
|
const config = await desk(root);
|
|
@@ -114,6 +114,9 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
114
114
|
attached_at: now(),
|
|
115
115
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
116
116
|
...(selectedWake ? { wake: selectedWake } : {}),
|
|
117
|
+
...(piSession ? { pi_session: piSession } : {}),
|
|
118
|
+
...(piSessionPath ? { pi_session_path: piSessionPath } : {}),
|
|
119
|
+
...(launchReceipt ? { launch_receipt: launchReceipt } : {}),
|
|
117
120
|
};
|
|
118
121
|
// Legacy Desks use this field as the executable chosen by a later `launch`.
|
|
119
122
|
// Keep it aligned when an existing seat is deliberately re-homed to Pi.
|
|
@@ -159,11 +162,11 @@ export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
|
159
162
|
return { agent: resolveExecutable(config, candidate.executable), args: candidate.args ?? [] };
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio) {
|
|
165
|
+
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
163
166
|
const candidates = availableModelCandidates(config, portfolio);
|
|
164
167
|
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
165
168
|
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => entry.task.status !== "done");
|
|
166
|
-
const selection = await
|
|
169
|
+
const selection = await select({
|
|
167
170
|
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
168
171
|
tasks: work.map((entry) => ({
|
|
169
172
|
id: entry.id,
|
|
@@ -175,16 +178,138 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
175
178
|
})),
|
|
176
179
|
candidates,
|
|
177
180
|
});
|
|
178
|
-
if (!selection.available) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
if (!selection.available || selection.confidence < 0.75 || !candidates.some((entry) => entry.id === selection.selected_model)) {
|
|
182
|
+
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
183
|
+
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
184
|
+
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
185
|
+
return { candidate: candidates[0]!, selection, fallback: reason };
|
|
181
186
|
}
|
|
182
|
-
return required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`);
|
|
187
|
+
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
186
191
|
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
187
192
|
|
|
193
|
+
type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
|
|
194
|
+
type Command = (command: string[]) => Promise<string>;
|
|
195
|
+
export type PiRuntimeLaunchDependencies = {
|
|
196
|
+
select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
|
|
197
|
+
command?: Command;
|
|
198
|
+
sessionId?: () => string;
|
|
199
|
+
at?: () => string;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
203
|
+
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
204
|
+
|
|
205
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
206
|
+
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
207
|
+
const number = (value: unknown) => typeof value === "number" ? value : undefined;
|
|
208
|
+
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
209
|
+
function paneReport(value: unknown): PaneReport {
|
|
210
|
+
const pane = object(result(value)?.pane) ?? result(value);
|
|
211
|
+
const session = object(pane?.agent_session);
|
|
212
|
+
const id = text(pane?.pane_id);
|
|
213
|
+
if (!id) throw new Error("Herdr pane get did not report a pane id.");
|
|
214
|
+
return {
|
|
215
|
+
pane: id,
|
|
216
|
+
...(text(pane?.agent) ? { agent: text(pane?.agent) } : {}),
|
|
217
|
+
...(text(pane?.agent_status) ? { status: text(pane?.agent_status) } : {}),
|
|
218
|
+
...(session?.kind === "path" && text(session.value) ? { sessionPath: text(session.value) } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function processReport(value: unknown): ProcessReport {
|
|
222
|
+
const report = object(result(value)?.process_info) ?? result(value);
|
|
223
|
+
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
224
|
+
const process = object(entry);
|
|
225
|
+
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
226
|
+
}) : [];
|
|
227
|
+
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
228
|
+
}
|
|
229
|
+
function agentReport(value: unknown) {
|
|
230
|
+
const agent = object(result(value)?.agent) ?? result(value);
|
|
231
|
+
const name = text(agent?.name);
|
|
232
|
+
const pane = text(agent?.pane_id);
|
|
233
|
+
if (!name || !pane) throw new Error("Herdr agent get did not report agent name and pane id.");
|
|
234
|
+
return { name, pane, status: text(agent?.status) ?? text(agent?.state) };
|
|
235
|
+
}
|
|
236
|
+
function piProcess(processes: ProcessReport["processes"]) {
|
|
237
|
+
return processes.find((entry) => entry.name === "pi" || entry.argv0?.endsWith("/pi") || entry.argv0 === "pi" || /(^|\/)pi(?:\s|$)/.test(entry.cmdline ?? ""));
|
|
238
|
+
}
|
|
239
|
+
/** Pi's durable JSONL session filenames end in `_<session-id>.jsonl`. */
|
|
240
|
+
function sessionIdFromPiJsonlPath(path: string) {
|
|
241
|
+
const match = 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);
|
|
242
|
+
if (!match) throw new Error("Herdr did not report a Pi JSONL path with a documented session ID.");
|
|
243
|
+
return match[1]!;
|
|
244
|
+
}
|
|
245
|
+
function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
246
|
+
function agentName(address: string) { return `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32); }
|
|
247
|
+
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
248
|
+
function startCommand(request: HerdrRequest) {
|
|
249
|
+
const session = request.resume ? ["--session", required(request.sessionPath, "a previously verified Pi session path")] : ["--session-id", request.piSession];
|
|
250
|
+
return herdr(request.session, "agent", "start", agentName(request.seat), "--kind", "pi", "--pane", request.pane, "--", ...session, ...request.args);
|
|
251
|
+
}
|
|
252
|
+
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
253
|
+
|
|
254
|
+
/** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
|
|
255
|
+
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
256
|
+
const resolved = await canonicalAddress(root, address);
|
|
257
|
+
const record = await seat(root, resolved);
|
|
258
|
+
const config = await desk(root);
|
|
259
|
+
const pane = required(words(args, "--pane"), "--pane");
|
|
260
|
+
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
261
|
+
const resume = args.includes("--resume");
|
|
262
|
+
const dryRun = args.includes("--dry-run");
|
|
263
|
+
if (args.some((argument) => !["--pane", pane, "--herdr-session", herdrSession, "--resume", "--dry-run"].includes(argument))) throw new Error("Use `pi runtime launch <seat> --pane <existing-pane> --herdr-session <session> [--resume] [--dry-run]`.");
|
|
264
|
+
const active = (await seatTasks(root, record.project, resolved)).filter((entry) => entry.task.status === "in_progress");
|
|
265
|
+
if (!active.length) throw new Error(`${resolved} has no active bounded task; refusing runtime launch.`);
|
|
266
|
+
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
267
|
+
const model = resolveModelCommand(config, selection.candidate);
|
|
268
|
+
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
269
|
+
const command = dependencies.command ?? runOutput;
|
|
270
|
+
const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
271
|
+
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
272
|
+
const currentProcess = processReport(JSON.parse(processRaw));
|
|
273
|
+
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
274
|
+
if (currentPane.agent || piProcess(currentProcess.processes)) throw new Error(resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
|
|
275
|
+
const piSession = resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
276
|
+
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
277
|
+
if (resume) {
|
|
278
|
+
const prior = record.runtime?.launch_receipt;
|
|
279
|
+
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
280
|
+
try {
|
|
281
|
+
const receipt = await readYaml<{ seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string }>(prior);
|
|
282
|
+
if (receipt.seat !== resolved || receipt.pi_session !== piSession || receipt.pi_session_path !== record.runtime?.pi_session_path || receipt.herdr_session !== herdrSession || receipt.pane !== pane) throw new Error("receipt does not match seat/session/path/pane");
|
|
283
|
+
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
284
|
+
}
|
|
285
|
+
const priorSessionPath = resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
|
|
286
|
+
const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume, args: [...model.args, "--extension", piExtensionPath()] };
|
|
287
|
+
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
|
|
288
|
+
if (dryRun) return plan;
|
|
289
|
+
await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)}`));
|
|
290
|
+
await command(startCommand(request));
|
|
291
|
+
const [agentRaw, verifiedPaneRaw, verifiedProcessRaw] = await Promise.all([command(herdr(herdrSession, "agent", "get", agentName(resolved))), command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
292
|
+
const agent = agentReport(JSON.parse(agentRaw));
|
|
293
|
+
const verifiedPane = paneReport(JSON.parse(verifiedPaneRaw));
|
|
294
|
+
const verifiedProcess = piProcess(processReport(JSON.parse(verifiedProcessRaw)).processes);
|
|
295
|
+
const sessionPath = verifiedPane.sessionPath;
|
|
296
|
+
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
297
|
+
const reportedId = sessionIdFromPiJsonlPath(sessionPath);
|
|
298
|
+
if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
299
|
+
if (resume && sessionPath !== record.runtime?.pi_session_path) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
|
|
300
|
+
if (agent.name !== agentName(resolved) || agent.pane !== pane || ![agent.status, verifiedPane.status].every((status) => status === "idle" || status === "done") || verifiedPane.pane !== pane || !verifiedProcess) throw new Error("Herdr did not verify the replacement Pi process; seat binding was not changed.");
|
|
301
|
+
const receipt = plan.receipt;
|
|
302
|
+
const receiptRecord = {
|
|
303
|
+
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
304
|
+
selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
|
|
305
|
+
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane, created_at: dependencies.at?.() ?? now(),
|
|
306
|
+
};
|
|
307
|
+
await mkdir(dirname(receipt), { recursive: true });
|
|
308
|
+
await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
|
309
|
+
await bind(root, resolved, ["--application", "herdr", "--address", pane, "--session", herdrSession], selection.candidate.id, "native", piSession, sessionPath, receipt);
|
|
310
|
+
return plan;
|
|
311
|
+
}
|
|
312
|
+
|
|
188
313
|
export async function describe(root: string, address: string, args: string[]) {
|
|
189
314
|
const resolved = await canonicalAddress(root, address);
|
|
190
315
|
const record = await seat(root, resolved);
|
package/src/tasks.ts
CHANGED
|
@@ -192,6 +192,8 @@ async function transition(root: string, projectName: string, id: string, next: T
|
|
|
192
192
|
if (next === "done") {
|
|
193
193
|
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may complete task ${id}.`);
|
|
194
194
|
if (!allProofs(task)) throw new Error(`Task ${id} is missing completion proof.`);
|
|
195
|
+
const { assertParentMayComplete } = await import("./ephemeral-resources");
|
|
196
|
+
await assertParentMayComplete(root, { project: projectName, task: id });
|
|
195
197
|
const { assertAcceptedBehavioralReview } = await import("./reviews");
|
|
196
198
|
await assertAcceptedBehavioralReview(root, projectName, id, task);
|
|
197
199
|
if (retire) await retireAssignee(root, projectName, id, task);
|
|
@@ -246,6 +248,8 @@ export async function unblock(root: string, projectName: string, id: string, arg
|
|
|
246
248
|
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may unblock task ${id}.`);
|
|
247
249
|
if (task.status !== "in_progress") throw new Error(`Task ${id} can only be unblocked while in_progress.`);
|
|
248
250
|
if (!task.blocker) throw new Error(`Task ${id} has no blocker to clear.`);
|
|
251
|
+
const { assertDependentsMayUnblock } = await import("./ephemeral-resources");
|
|
252
|
+
await assertDependentsMayUnblock(root, { project: projectName, task: id });
|
|
249
253
|
delete task.blocker;
|
|
250
254
|
await writeTask(root, projectName, id, task);
|
|
251
255
|
console.log(`${id} unblocked`);
|