agents-pod 0.3.2 → 0.3.4

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.
@@ -0,0 +1,395 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { readListeners } from "./listeners.ts";
5
+ import { listenersFile, podDir, podsFile, projectsFile } from "./paths.ts";
6
+ import { readProjects } from "./projects.ts";
7
+ import { readPods, withRegistryLock } from "./registry.ts";
8
+
9
+ export type MoveArtifact = "present" | "missing" | "moved";
10
+
11
+ export interface ProjectMovePod {
12
+ id: string;
13
+ from: string;
14
+ to: string;
15
+ artifact: MoveArtifact;
16
+ }
17
+
18
+ export interface ProjectMoveListener {
19
+ id: string;
20
+ from: string;
21
+ to: string;
22
+ }
23
+
24
+ export interface ProjectMoveCapacity {
25
+ from: string;
26
+ to: string;
27
+ maxActive: number;
28
+ }
29
+
30
+ export interface ProjectMoveRefusal {
31
+ reason: string;
32
+ }
33
+
34
+ export interface ProjectMovePlan {
35
+ oldPath: string;
36
+ newPath: string;
37
+ moveArtifacts: boolean;
38
+ pods: ProjectMovePod[];
39
+ listeners: ProjectMoveListener[];
40
+ projects: ProjectMoveCapacity[];
41
+ refusals: ProjectMoveRefusal[];
42
+ matched: number;
43
+ }
44
+
45
+ export interface ProjectMoveResult {
46
+ files: Array<{ name: "pods.json" | "listeners.json" | "projects.json"; changed: number }>;
47
+ }
48
+
49
+ async function exists(path: string): Promise<boolean> {
50
+ return stat(path).then(
51
+ () => true,
52
+ () => false,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A project path is a location, not Pod identity (ADR 0028). The plan is a
58
+ * read-only snapshot; apply re-reads every record under the registry lock.
59
+ */
60
+ export async function planProjectMove(
61
+ oldPath: string,
62
+ newPath: string,
63
+ { moveArtifacts = false }: { moveArtifacts?: boolean },
64
+ ): Promise<ProjectMovePlan> {
65
+ const oldRoot = resolve(oldPath);
66
+ const newRoot = resolve(newPath);
67
+ const [pods, listeners, projects] = await Promise.all([
68
+ readPods(),
69
+ readListeners(),
70
+ readProjects(),
71
+ ]);
72
+
73
+ const movingPods = pods.filter((pod) => resolve(pod.project) === oldRoot);
74
+ const movingListeners = listeners.filter(
75
+ (listener) =>
76
+ listener.mode === "template" &&
77
+ listener.project !== undefined &&
78
+ resolve(listener.project) === oldRoot,
79
+ );
80
+ const movingProjects = projects.filter((project) => resolve(project.project) === oldRoot);
81
+ const matched = movingPods.length + movingListeners.length + movingProjects.length;
82
+ const plan: ProjectMovePlan = {
83
+ oldPath: oldRoot,
84
+ newPath: newRoot,
85
+ moveArtifacts,
86
+ pods: [],
87
+ listeners: movingListeners.map((listener) => ({
88
+ id: listener.id,
89
+ from: oldRoot,
90
+ to: newRoot,
91
+ })),
92
+ projects: movingProjects.map((project) => ({
93
+ from: oldRoot,
94
+ to: newRoot,
95
+ maxActive: project.maxActive,
96
+ })),
97
+ refusals: [],
98
+ matched,
99
+ };
100
+
101
+ // An empty old binding is already the desired end state. In particular, a
102
+ // repeated apply must not mistake the records just moved to newPath for an
103
+ // ambiguous target.
104
+ if (matched === 0) return plan;
105
+
106
+ for (const pod of pods) {
107
+ if (resolve(pod.project) === newRoot) {
108
+ plan.refusals.push({
109
+ reason: `target ${newRoot} is already bound to pod ${pod.id}; choose a different new path`,
110
+ });
111
+ }
112
+ }
113
+ for (const listener of listeners) {
114
+ if (
115
+ listener.mode === "template" &&
116
+ listener.project !== undefined &&
117
+ resolve(listener.project) === newRoot
118
+ ) {
119
+ plan.refusals.push({
120
+ reason: `target ${newRoot} is already bound to template listener ${listener.id}; choose a different new path`,
121
+ });
122
+ }
123
+ }
124
+ if (projects.some((project) => resolve(project.project) === newRoot)) {
125
+ plan.refusals.push({
126
+ reason: `target ${newRoot} is already bound to a capacity record; choose a different new path`,
127
+ });
128
+ }
129
+
130
+ for (const pod of movingPods) {
131
+ const from = podDir(oldRoot, pod.id);
132
+ const to = podDir(newRoot, pod.id);
133
+ const present = await exists(to);
134
+ const artifact: MoveArtifact = present ? "present" : moveArtifacts ? "moved" : "missing";
135
+ plan.pods.push({ id: pod.id, from, to, artifact });
136
+ if (artifact === "missing") {
137
+ plan.refusals.push({
138
+ reason: `pod ${pod.id}: artifacts missing at ${to} (pass --move-artifacts)`,
139
+ });
140
+ }
141
+ }
142
+
143
+ // Normally an existing old root means the operator has not moved the tree.
144
+ // --move-artifacts is the explicit exception: its source workspace must
145
+ // still exist under oldRoot so apply can carry it to newRoot.
146
+ if (!moveArtifacts && (await exists(oldRoot))) {
147
+ plan.refusals.push({
148
+ reason: `old path ${oldRoot} still exists; move it to ${newRoot} before relocating records`,
149
+ });
150
+ }
151
+
152
+ return plan;
153
+ }
154
+
155
+ interface RegistryUpdate {
156
+ name: "pods.json" | "listeners.json" | "projects.json";
157
+ file: string;
158
+ changed: number;
159
+ body: string;
160
+ }
161
+
162
+ interface StagedUpdate extends RegistryUpdate {
163
+ tmp: string;
164
+ original: string | undefined;
165
+ committed: boolean;
166
+ }
167
+
168
+ async function readOriginal(file: string): Promise<string | undefined> {
169
+ try {
170
+ return await readFile(file, "utf8");
171
+ } catch (error) {
172
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async function stageUpdate(update: RegistryUpdate): Promise<StagedUpdate> {
178
+ await mkdir(dirname(update.file), { recursive: true });
179
+ const tmp = `${update.file}.${randomBytes(4).toString("hex")}.tmp`;
180
+ const original = await readOriginal(update.file);
181
+ await writeFile(tmp, update.body, { flag: "wx" });
182
+ return { ...update, tmp, original, committed: false };
183
+ }
184
+
185
+ async function restoreOriginal(update: StagedUpdate): Promise<void> {
186
+ if (update.original === undefined) {
187
+ await rm(update.file, { force: true });
188
+ return;
189
+ }
190
+ const tmp = `${update.file}.${randomBytes(4).toString("hex")}.tmp`;
191
+ try {
192
+ await writeFile(tmp, update.original, { flag: "wx" });
193
+ await rename(tmp, update.file);
194
+ } catch (error) {
195
+ await rm(tmp, { force: true }).catch(() => undefined);
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async function moveDirectory(from: string, to: string): Promise<void> {
201
+ await mkdir(dirname(to), { recursive: true });
202
+ try {
203
+ await rename(from, to);
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
206
+ // rename cannot cross filesystems. Copy the complete tree before removing
207
+ // the source so an EXDEV move has the same all-or-nothing visible result.
208
+ await cp(from, to, { recursive: true, force: false, errorOnExist: true });
209
+ await rm(from, { recursive: true, force: true });
210
+ }
211
+ }
212
+
213
+ function changedCount<T>(before: T[], after: T[]): number {
214
+ let changed = 0;
215
+ for (let index = 0; index < before.length; index += 1) {
216
+ if (before[index] !== after[index]) changed += 1;
217
+ }
218
+ return changed;
219
+ }
220
+
221
+ /**
222
+ * Commit all three registries as one lock-held operation (ADRs 0018, 0028).
223
+ * Every complete JSON body and temp file is prepared before an artifact or
224
+ * registry changes. A later failure restores earlier renames and moved dirs.
225
+ */
226
+ export async function applyProjectMove(plan: ProjectMovePlan): Promise<ProjectMoveResult> {
227
+ if (plan.matched === 0) return { files: [] };
228
+ if (plan.refusals.length > 0) {
229
+ throw new Error(`project move refused: ${plan.refusals.map((refusal) => refusal.reason).join("; ")}`);
230
+ }
231
+
232
+ const applied = await withRegistryLock(async (): Promise<ProjectMoveResult> => {
233
+ const [pods, listeners, projects] = await Promise.all([
234
+ readPods(),
235
+ readListeners(),
236
+ readProjects(),
237
+ ]);
238
+ const podIds = new Set(plan.pods.map((pod) => pod.id));
239
+ const listenerIds = new Set(plan.listeners.map((listener) => listener.id));
240
+
241
+ // "The target is already bound" was decided by the dry run, outside this
242
+ // lock: a spawn or a second listener could have bound it in between, and
243
+ // merging two projects' records into one path is the ambiguity the refusal
244
+ // exists for. Re-read the fact instead of trusting the plan.
245
+ const foreignPod = pods.find(
246
+ (pod) => resolve(pod.project) === plan.newPath && !podIds.has(pod.id),
247
+ );
248
+ if (foreignPod !== undefined) {
249
+ throw new Error(
250
+ `target ${plan.newPath} is now bound to pod ${foreignPod.id}; nothing was moved — re-run project move`,
251
+ );
252
+ }
253
+ const foreignListener = listeners.find(
254
+ (listener) =>
255
+ listener.mode === "template" &&
256
+ listener.project !== undefined &&
257
+ resolve(listener.project) === plan.newPath &&
258
+ !listenerIds.has(listener.id),
259
+ );
260
+ if (foreignListener !== undefined) {
261
+ throw new Error(
262
+ `target ${plan.newPath} is now bound to template listener ${foreignListener.id}; nothing was moved — re-run project move`,
263
+ );
264
+ }
265
+ if (projects.some((project) => resolve(project.project) === plan.newPath)) {
266
+ throw new Error(
267
+ `target ${plan.newPath} is now bound to a capacity record; nothing was moved — re-run project move`,
268
+ );
269
+ }
270
+
271
+ const nextPods = pods.map((pod) =>
272
+ podIds.has(pod.id) && resolve(pod.project) === plan.oldPath
273
+ ? { ...pod, project: plan.newPath }
274
+ : pod,
275
+ );
276
+ const nextListeners = listeners.map((listener) =>
277
+ listenerIds.has(listener.id) &&
278
+ listener.mode === "template" &&
279
+ listener.project !== undefined &&
280
+ resolve(listener.project) === plan.oldPath
281
+ ? { ...listener, project: plan.newPath }
282
+ : listener,
283
+ );
284
+ const nextProjects = projects.map((project) =>
285
+ plan.projects.length > 0 && resolve(project.project) === plan.oldPath
286
+ ? { ...project, project: plan.newPath }
287
+ : project,
288
+ );
289
+
290
+ const updates: RegistryUpdate[] = [];
291
+ const podChanges = changedCount(pods, nextPods);
292
+ const listenerChanges = changedCount(listeners, nextListeners);
293
+ const projectChanges = changedCount(projects, nextProjects);
294
+ if (podChanges > 0) {
295
+ updates.push({
296
+ name: "pods.json",
297
+ file: podsFile(),
298
+ changed: podChanges,
299
+ body: `${JSON.stringify(nextPods, null, 2)}\n`,
300
+ });
301
+ }
302
+ if (listenerChanges > 0) {
303
+ updates.push({
304
+ name: "listeners.json",
305
+ file: listenersFile(),
306
+ changed: listenerChanges,
307
+ body: `${JSON.stringify(nextListeners, null, 2)}\n`,
308
+ });
309
+ }
310
+ if (projectChanges > 0) {
311
+ updates.push({
312
+ name: "projects.json",
313
+ file: projectsFile(),
314
+ changed: projectChanges,
315
+ body: `${JSON.stringify(nextProjects, null, 2)}\n`,
316
+ });
317
+ }
318
+
319
+ const changedPodIds = new Set(
320
+ plan.pods
321
+ .filter((pod) => {
322
+ const before = pods.find((candidate) => candidate.id === pod.id);
323
+ return before !== undefined && resolve(before.project) === plan.oldPath;
324
+ })
325
+ .map((pod) => pod.id),
326
+ );
327
+ const artifactMoves = plan.pods.filter(
328
+ (pod) => pod.artifact === "moved" && changedPodIds.has(pod.id),
329
+ );
330
+ if (updates.length === 0 && artifactMoves.length === 0) return { files: [] };
331
+
332
+ // Re-check the dry-run's filesystem facts before any write. A workspace
333
+ // that vanished between planning and apply must not become an empty Pod.
334
+ for (const pod of plan.pods) {
335
+ if (pod.artifact === "present" && changedPodIds.has(pod.id) && !(await exists(pod.to))) {
336
+ throw new Error(`pod ${pod.id}: artifacts missing at ${pod.to}; run project move again`);
337
+ }
338
+ }
339
+
340
+ const staged: StagedUpdate[] = [];
341
+ const moved: ProjectMovePod[] = [];
342
+ try {
343
+ for (const update of updates) staged.push(await stageUpdate(update));
344
+ for (const pod of artifactMoves) {
345
+ if (!(await exists(pod.from))) {
346
+ throw new Error(`pod ${pod.id}: artifacts missing at ${pod.from}`);
347
+ }
348
+ if (await exists(pod.to)) {
349
+ throw new Error(`pod ${pod.id}: artifacts already exist at ${pod.to}`);
350
+ }
351
+ await moveDirectory(pod.from, pod.to);
352
+ moved.push(pod);
353
+ }
354
+ for (const update of staged) {
355
+ await rename(update.tmp, update.file);
356
+ update.committed = true;
357
+ }
358
+ } catch (error) {
359
+ const rollbackErrors: unknown[] = [];
360
+ for (const update of [...staged].reverse()) {
361
+ if (!update.committed) continue;
362
+ try {
363
+ await restoreOriginal(update);
364
+ } catch (rollbackError) {
365
+ rollbackErrors.push(rollbackError);
366
+ }
367
+ }
368
+ for (const pod of [...moved].reverse()) {
369
+ try {
370
+ await moveDirectory(pod.to, pod.from);
371
+ } catch (rollbackError) {
372
+ rollbackErrors.push(rollbackError);
373
+ }
374
+ }
375
+ if (rollbackErrors.length > 0) {
376
+ throw new AggregateError(
377
+ [error, ...rollbackErrors],
378
+ "project move failed and rollback was incomplete",
379
+ );
380
+ }
381
+ throw error;
382
+ } finally {
383
+ for (const update of staged) {
384
+ if (!update.committed) await rm(update.tmp, { force: true }).catch(() => undefined);
385
+ }
386
+ }
387
+
388
+ return {
389
+ files: updates.map((update) => ({ name: update.name, changed: update.changed })),
390
+ };
391
+ });
392
+
393
+ if (applied === undefined) throw new Error("registry lock unavailable: project move not applied");
394
+ return applied;
395
+ }