@afokapu/atdd-flow 0.3.45 → 0.3.46

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
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
- throw new Error("Use `atdd-flow project init <project>` or `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`.");
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>();