@afokapu/atdd-flow 0.3.45 → 0.3.47
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 +4 -2
- package/src/seats.ts +146 -10
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { init, initProject, configureProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
4
|
+
import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
5
5
|
import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
6
6
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
7
7
|
import * as tasks from "./tasks";
|
|
@@ -25,6 +25,7 @@ Usage:
|
|
|
25
25
|
atdd-flow desk migrate
|
|
26
26
|
atdd-flow project init <project>
|
|
27
27
|
atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]
|
|
28
|
+
atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
|
|
28
29
|
atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
|
|
29
30
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
30
31
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
@@ -95,7 +96,8 @@ async function main() {
|
|
|
95
96
|
project: async () => {
|
|
96
97
|
if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
|
|
97
98
|
if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
|
|
98
|
-
|
|
99
|
+
if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
|
|
100
|
+
throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
99
101
|
},
|
|
100
102
|
spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
|
|
101
103
|
bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
|
package/src/seats.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
@@ -77,6 +77,102 @@ const configureUsage = "Use `atdd-flow project configure <project> [--repository
|
|
|
77
77
|
* The supported way to declare an existing project's primary checkout and linked-worktree root.
|
|
78
78
|
* Everything is validated before one atomic write; only those two project fields change.
|
|
79
79
|
*/
|
|
80
|
+
type MigrationSeat = { folder: string; record: Seat };
|
|
81
|
+
|
|
82
|
+
const namedTopologyMigrationUsage = "Use `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.";
|
|
83
|
+
|
|
84
|
+
async function migrationSeats(root: string, config: Project): Promise<MigrationSeat[]> {
|
|
85
|
+
const directory = paths(root).seats(config.project);
|
|
86
|
+
if (!await exists(directory)) return [];
|
|
87
|
+
return Promise.all((await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map(async (entry) => ({
|
|
88
|
+
folder: entry.name,
|
|
89
|
+
record: await readYaml<Seat>(join(directory, entry.name, "seat.yaml")),
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validates existing coordinator records without provisioning anything. A policy
|
|
95
|
+
* migration must preserve generic history while refusing ambiguous named resources.
|
|
96
|
+
*/
|
|
97
|
+
function assertMigrationTopology(config: Project, seats: MigrationSeat[]) {
|
|
98
|
+
const primary = resolve(config.repository!);
|
|
99
|
+
const root = resolve(config.worktree_root!);
|
|
100
|
+
const coordinators = seats.filter(({ record }) => record.role === "coordinator");
|
|
101
|
+
const genericAddress = `coordinator@${config.project}`;
|
|
102
|
+
const generic = coordinators.filter(({ record }) => record.address === genericAddress);
|
|
103
|
+
if (generic.length > 1 || generic.some(({ folder }) => folder !== "coordinator")) throw new Error("Legacy generic coordinator collides with another coordinator record.");
|
|
104
|
+
|
|
105
|
+
const named = coordinators.filter(({ record }) => record.address !== genericAddress);
|
|
106
|
+
for (const { record } of generic) for (const namedRecord of named) {
|
|
107
|
+
if (record.branch === namedRecord.branch || record.worktree === namedRecord.worktree) {
|
|
108
|
+
throw new Error("Legacy generic coordinator collides with a named coordinator resource.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const addresses = new Set<string>(), branches = new Set<string>(), worktrees = new Set<string>();
|
|
112
|
+
for (const { record } of named) {
|
|
113
|
+
if (!record.address.match(new RegExp(`^coordinator\\.([a-z0-9_-]+)@${config.project}$`))) throw new Error("Named coordinator seat collision.");
|
|
114
|
+
if (addresses.has(record.address)) throw new Error("Named coordinator seat collision.");
|
|
115
|
+
addresses.add(record.address);
|
|
116
|
+
if (branches.has(record.branch)) throw new Error("Named coordinator branch collision.");
|
|
117
|
+
branches.add(record.branch);
|
|
118
|
+
const worktree = resolve(record.worktree);
|
|
119
|
+
if (worktrees.has(worktree)) throw new Error("Named coordinator worktree collision.");
|
|
120
|
+
worktrees.add(worktree);
|
|
121
|
+
}
|
|
122
|
+
for (const { record } of named) {
|
|
123
|
+
const stream = record.address.slice("coordinator.".length, -(`@${config.project}`).length);
|
|
124
|
+
const worktree = resolve(record.worktree);
|
|
125
|
+
if (record.branch !== `integration/${stream}` || worktree !== resolve(join(root, stream)) || worktree === primary) {
|
|
126
|
+
throw new Error("Named coordinator seat collision.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const { record } of seats.filter(({ record }) => record.role !== "coordinator")) for (const namedRecord of named) {
|
|
130
|
+
if (record.branch === namedRecord.branch) throw new Error("Named coordinator branch collision.");
|
|
131
|
+
if (resolve(record.worktree) === resolve(namedRecord.worktree)) throw new Error("Named coordinator worktree collision.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Migrates only an existing project's coordinator role template. Validation is
|
|
137
|
+
* read-only and precedes the one atomic policy write, so no resource can leak.
|
|
138
|
+
*/
|
|
139
|
+
export async function migrateNamedCoordinatorTopology(root: string, name: string, args: string[]) {
|
|
140
|
+
if (args.length !== 2 || args[0] !== "--baseline" || !args[1]) throw new Error(args.includes("--baseline") ? namedTopologyMigrationUsage : "Missing --baseline.");
|
|
141
|
+
const baseline = args[1]!;
|
|
142
|
+
if (!/^[0-9a-f]{40}$/.test(baseline)) throw new Error("--baseline must be a full lowercase SHA-1.");
|
|
143
|
+
if (!await exists(paths(root).projectFile(name))) throw new Error(`Project ${name} does not exist.`);
|
|
144
|
+
const config = await project(root, name);
|
|
145
|
+
if (!config.repository) throw new Error(`Project ${name} has no repository.`);
|
|
146
|
+
if (!config.worktree_root) throw new Error(`Project ${name} has no worktree_root.`);
|
|
147
|
+
const repository = resolve(config.repository), worktreeRoot = resolve(config.worktree_root);
|
|
148
|
+
const top = existsSync(repository) ? Bun.spawnSync(["git", "-C", repository, "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" }) : undefined;
|
|
149
|
+
if (!top || top.exitCode !== 0 || realpathSync(top.stdout.toString().trim()) !== realpathSync(repository)) throw new Error(`Project ${name} repository is not a Git top level.`);
|
|
150
|
+
try { if (!(await stat(worktreeRoot)).isDirectory()) throw new Error(); }
|
|
151
|
+
catch { throw new Error(`Project ${name} has no worktree_root.`); }
|
|
152
|
+
|
|
153
|
+
const remote = (await runOutput(["git", "-C", repository, "ls-remote", "--exit-code", "origin", "refs/heads/main"])).trim().split(/\s+/)[0];
|
|
154
|
+
if (remote !== baseline) throw new Error(`Baseline ${baseline} does not equal origin/main.`);
|
|
155
|
+
try { await runOutput(["git", "-C", repository, "cat-file", "-e", `${baseline}^{commit}`]); }
|
|
156
|
+
catch { throw new Error(`Baseline ${baseline} is not available locally.`); }
|
|
157
|
+
assertMigrationTopology(config, await migrationSeats(root, config));
|
|
158
|
+
|
|
159
|
+
const coordinator = config.roles.coordinator;
|
|
160
|
+
if (!coordinator) throw new Error(`Project ${name} has no coordinator role policy.`);
|
|
161
|
+
const next: Project = {
|
|
162
|
+
...config,
|
|
163
|
+
roles: {
|
|
164
|
+
...config.roles,
|
|
165
|
+
coordinator: {
|
|
166
|
+
...coordinator,
|
|
167
|
+
address: "coordinator.{name}@{project}", branch: "integration/{name}", base: "main", worktree: "{worktree_root}/{name}",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
try { await atomicYaml(paths(root).projectFile(name), next); }
|
|
172
|
+
catch { throw new Error("Unable to atomically write migration policy."); }
|
|
173
|
+
console.log(`${name} named coordinator topology migrated`);
|
|
174
|
+
}
|
|
175
|
+
|
|
80
176
|
export async function configureProject(root: string, name: string, args: string[]) {
|
|
81
177
|
const config = await project(root, name);
|
|
82
178
|
const values = new Map<string, string>();
|
|
@@ -117,7 +213,7 @@ async function noBranch(config: Project, branch: string) {
|
|
|
117
213
|
if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
|
|
118
214
|
}
|
|
119
215
|
|
|
120
|
-
async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
216
|
+
async function taskCoordinatorBase(root: string, config: Project, task: Task, requireCleanCheckout = true) {
|
|
121
217
|
const coordinatorAddress = await canonicalAddress(root, task.coordinator);
|
|
122
218
|
const suffix = `@${config.project}`;
|
|
123
219
|
if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
|
|
@@ -139,15 +235,17 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
|
139
235
|
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
140
236
|
}
|
|
141
237
|
if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
|
|
238
|
+
const repository = required(config.repository, "project repository");
|
|
239
|
+
const branchHead = await runOutput(["git", "-C", repository, "rev-parse", branch]);
|
|
240
|
+
if (!requireCleanCheckout) return { coordinator, branch, commit: branchHead };
|
|
142
241
|
if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
|
|
143
242
|
throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
|
|
144
243
|
}
|
|
145
244
|
const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
|
|
146
245
|
if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
|
|
147
246
|
const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
|
|
148
|
-
const branchHead = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", branch]);
|
|
149
247
|
if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
|
|
150
|
-
return { coordinator, branch, commit:
|
|
248
|
+
return { coordinator, branch, commit: branchHead };
|
|
151
249
|
}
|
|
152
250
|
|
|
153
251
|
/** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
|
|
@@ -156,6 +254,22 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
156
254
|
throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
|
|
157
255
|
}
|
|
158
256
|
if (has(args, "--worktree") || has(args, "--branch")) throw new Error("Task-aware driver spawn derives its worktree and branch; do not provide --worktree or --branch.");
|
|
257
|
+
const baseArgument = words(args, "--base");
|
|
258
|
+
const dryRun = has(args, "--dry-run");
|
|
259
|
+
const explicit = args.includes("--base") || dryRun;
|
|
260
|
+
if (explicit) {
|
|
261
|
+
const allowed = new Set(["--task", "--base", "--dry-run"]);
|
|
262
|
+
const taskArgument = words(args, "--task");
|
|
263
|
+
const onlyExpectedValues = args.every((arg, index) => allowed.has(arg)
|
|
264
|
+
|| (index > 0 && ["--task", "--base"].includes(args[index - 1]!)));
|
|
265
|
+
if (!onlyExpectedValues || args.filter((arg) => arg === "--task").length !== 1
|
|
266
|
+
|| args.filter((arg) => arg === "--base").length !== 1
|
|
267
|
+
|| args.filter((arg) => arg === "--dry-run").length > 1
|
|
268
|
+
|| !taskArgument || taskArgument.startsWith("--")
|
|
269
|
+
|| !baseArgument || !/^[0-9a-f]{40}$/.test(baseArgument)) {
|
|
270
|
+
throw new Error("Task-aware explicit base requires one --task <task-id> and one --base <full-40-lowercase-commit-sha>.");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
159
273
|
const id = taskId(required(words(args, "--task"), "--task"));
|
|
160
274
|
const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
|
|
161
275
|
if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
@@ -167,7 +281,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
167
281
|
if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
|
|
168
282
|
if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
|
|
169
283
|
await noBranch(config, branch);
|
|
170
|
-
|
|
284
|
+
// An explicit baseline is governed by the repository ref, never by a
|
|
285
|
+
// potentially dirty or stale coordinator checkout. Legacy spawn retains its
|
|
286
|
+
// existing clean-checkout guard and derives exactly as before.
|
|
287
|
+
const coordinatorBase = await taskCoordinatorBase(root, config, task, !baseArgument);
|
|
288
|
+
let base = coordinatorBase;
|
|
289
|
+
if (baseArgument) {
|
|
290
|
+
try { await runOutput(["git", "-C", required(config.repository, "project repository"), "cat-file", "-e", `${baseArgument}^{commit}`]); }
|
|
291
|
+
catch { throw new Error(`Explicit base ${baseArgument} does not exist locally in the configured repository.`); }
|
|
292
|
+
try { await run(["git", "-C", required(config.repository, "project repository"), "merge-base", "--is-ancestor", coordinatorBase.commit, baseArgument], true); }
|
|
293
|
+
catch { throw new Error(`Explicit base ${baseArgument} does not descend ${coordinatorBase.branch}.`); }
|
|
294
|
+
base = { ...coordinatorBase, commit: baseArgument };
|
|
295
|
+
}
|
|
296
|
+
if (dryRun) {
|
|
297
|
+
if (!baseArgument) throw new Error("--dry-run is supported only with explicit --base task-aware spawn.");
|
|
298
|
+
console.log(`${address} dry-run ${id} base ${base.commit} branch ${branch} worktree ${worktree}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
171
301
|
const portfolio = await modelPortfolio(root);
|
|
172
302
|
const requestedAgent = words(args, "--agent");
|
|
173
303
|
if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
|
|
@@ -184,17 +314,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
184
314
|
await mkdir(dirname(worktree), { recursive: true });
|
|
185
315
|
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
|
|
186
316
|
created = true;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
if (
|
|
317
|
+
const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", coordinatorBase.branch]);
|
|
318
|
+
if (currentBranch !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
319
|
+
if (!baseArgument) {
|
|
320
|
+
const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
|
|
321
|
+
if (current !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
322
|
+
}
|
|
190
323
|
await atomicYaml(paths(root).seatFile(address), record);
|
|
191
324
|
await atomicYaml(paths(root).taskFile(config.project, id), task);
|
|
192
325
|
} catch (error) {
|
|
193
|
-
if (created)
|
|
326
|
+
if (created) {
|
|
327
|
+
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
|
|
328
|
+
await run(["git", "-C", required(config.repository, "project repository"), "branch", "-D", branch], true).catch(() => undefined);
|
|
329
|
+
}
|
|
194
330
|
await rm(paths(root).seat(address), { recursive: true, force: true });
|
|
195
331
|
throw error;
|
|
196
332
|
}
|
|
197
|
-
console.log(`${address} assigned ${id} return ${base.branch}`);
|
|
333
|
+
console.log(`${address} assigned ${id} return ${base.branch}${baseArgument ? ` base ${base.commit}` : ""}`);
|
|
198
334
|
}
|
|
199
335
|
|
|
200
336
|
export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
|