agents-pod 0.3.2 → 0.3.5
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/CHANGELOG.md +87 -0
- package/README.md +2 -0
- package/examples/feishu/README.md +81 -11
- package/examples/feishu/feishu_client.py +27 -7
- package/examples/feishu/feishu_drain.py +144 -31
- package/examples/feishu/test_feishu_drain.py +157 -0
- package/package.json +1 -1
- package/src/adapters.ts +303 -0
- package/src/cli.ts +186 -4
- package/src/doctor.ts +75 -1
- package/src/extension/index.ts +15 -1
- package/src/paths.ts +5 -0
- package/src/relocate.ts +395 -0
package/src/doctor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { adapterFacts, readAdapters, type AdapterRecord } from "./adapters.ts";
|
|
5
6
|
import { readListeners, type ListenerRecord } from "./listeners.ts";
|
|
6
7
|
import { readLock } from "./locks.ts";
|
|
7
8
|
import { loadNotifyConfig } from "./notify/config.ts";
|
|
@@ -89,6 +90,20 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
89
90
|
} else {
|
|
90
91
|
const active = registry.pods.filter((pod) => pod.state === "active").length;
|
|
91
92
|
storeLines.push({ mark: "ok", text: `${registry.pods.length} pods, ${active} active` });
|
|
93
|
+
// A Pod Record bound to no Listener has no other line to carry its path
|
|
94
|
+
// (ADR 0028): terminal Pods are exempt, they block no conversation.
|
|
95
|
+
const bound = new Set(
|
|
96
|
+
listeners.filter((listener) => listener.mode === "pod").map((listener) => listener.podId),
|
|
97
|
+
);
|
|
98
|
+
for (const pod of registry.pods) {
|
|
99
|
+
if (pod.state === "done" || pod.state === "cancelled" || bound.has(pod.id)) continue;
|
|
100
|
+
if (await exists(pod.project)) continue;
|
|
101
|
+
storeLines.push({
|
|
102
|
+
mark: "bad",
|
|
103
|
+
text: `pod ${pod.id}: its project ${pod.project} does not exist`,
|
|
104
|
+
detail: [`next: agents-pod project move ${pod.project} <new>`],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
92
107
|
}
|
|
93
108
|
const lock = await readLock(reconcileLockPath());
|
|
94
109
|
if (lock !== undefined) {
|
|
@@ -102,6 +117,29 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
102
117
|
|
|
103
118
|
sections.push({ title: "pass", lines: await passLines(lastPass, now, sources, listeners, registry.pods) });
|
|
104
119
|
sections.push({ title: "intake", lines: await intakeLines(sources, listeners, now) });
|
|
120
|
+
// A corrupt provenance file is a fact, not silence: swallowing it would
|
|
121
|
+
// restore exactly the blindness adapters.json exists to end (ADR 0029).
|
|
122
|
+
let adapters: AdapterRecord[] = [];
|
|
123
|
+
let adaptersError: string | undefined;
|
|
124
|
+
try {
|
|
125
|
+
adapters = await readAdapters();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
adaptersError = (error as Error).message;
|
|
128
|
+
}
|
|
129
|
+
if (adaptersError !== undefined) {
|
|
130
|
+
sections.push({
|
|
131
|
+
title: "adapters",
|
|
132
|
+
lines: [
|
|
133
|
+
{
|
|
134
|
+
mark: "note",
|
|
135
|
+
text: `adapters.json is unreadable: ${adaptersError}`,
|
|
136
|
+
detail: ["next: agents-pod adapter sync <name> --apply rewrites it"],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
} else if (adapters.length > 0) {
|
|
141
|
+
sections.push({ title: "adapters", lines: await adapterLines(adapters) });
|
|
142
|
+
}
|
|
105
143
|
sections.push({ title: "listeners", lines: await listenerLines(listeners, sources, registry.pods) });
|
|
106
144
|
sections.push({ title: "outbound", lines: await outboundLines(deliverer, registry.pods) });
|
|
107
145
|
sections.push({ title: "notify", lines: notifyLines(notify, warnings, env) });
|
|
@@ -249,6 +287,8 @@ async function listenerLines(
|
|
|
249
287
|
const lines: DoctorLine[] = [];
|
|
250
288
|
for (const listener of listeners) {
|
|
251
289
|
const problems: string[] = [];
|
|
290
|
+
/** The path a `project move` would fix; `undefined` means "not a path problem". */
|
|
291
|
+
let deadPath: string | undefined;
|
|
252
292
|
const origin =
|
|
253
293
|
listener.source !== undefined
|
|
254
294
|
? `source ${listener.source}`
|
|
@@ -265,15 +305,32 @@ async function listenerLines(
|
|
|
265
305
|
}
|
|
266
306
|
if (listener.project !== undefined && !(await exists(listener.project))) {
|
|
267
307
|
problems.push(`its project ${listener.project} does not exist`);
|
|
308
|
+
deadPath = listener.project;
|
|
268
309
|
}
|
|
269
310
|
} else if (!pods.some((pod) => pod.id === listener.podId)) {
|
|
270
311
|
problems.push(`its Pod ${listener.podId ?? "(none)"} is not in the registry`);
|
|
312
|
+
} else {
|
|
313
|
+
// #24: a bound Pod's project is what a wake-up starts in, so a renamed
|
|
314
|
+
// directory makes the whole channel inert while this line used to read ✓.
|
|
315
|
+
const pod = pods.find((candidate) => candidate.id === listener.podId)!;
|
|
316
|
+
if (!(await exists(pod.project))) {
|
|
317
|
+
problems.push(`its Pod ${pod.id} project ${pod.project} does not exist`);
|
|
318
|
+
deadPath = pod.project;
|
|
319
|
+
} else if (!(await exists(join(pod.project, ".agents", "pods", pod.id)))) {
|
|
320
|
+
problems.push(`its Pod ${pod.id} workspace is missing under ${pod.project}`);
|
|
321
|
+
}
|
|
271
322
|
}
|
|
272
323
|
if (problems.length > 0) {
|
|
273
324
|
lines.push({
|
|
274
325
|
mark: "bad",
|
|
275
326
|
text: `${listener.id}: ${problems.join("; ")}`,
|
|
276
|
-
detail:
|
|
327
|
+
detail:
|
|
328
|
+
deadPath === undefined
|
|
329
|
+
? ["next: agents-pod listener rm " + listener.id, " then add it again with a path that exists"]
|
|
330
|
+
: [
|
|
331
|
+
`next: agents-pod project move ${deadPath} <new>`,
|
|
332
|
+
" a renamed repository carries <project>/.agents/pods/<id> with it (ADR 0028)",
|
|
333
|
+
],
|
|
277
334
|
});
|
|
278
335
|
continue;
|
|
279
336
|
}
|
|
@@ -292,6 +349,23 @@ async function listenerLines(
|
|
|
292
349
|
return lines;
|
|
293
350
|
}
|
|
294
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Provenance, not behaviour (issue #26): an adapter's `--check` proves it
|
|
354
|
+
* answers, never that the package's fix reached the copy. Only registered
|
|
355
|
+
* copies appear — an unregistered directory is not something core watches.
|
|
356
|
+
*/
|
|
357
|
+
async function adapterLines(adapters: AdapterRecord[]): Promise<DoctorLine[]> {
|
|
358
|
+
const lines: DoctorLine[] = [];
|
|
359
|
+
for (const fact of await adapterFacts(adapters)) {
|
|
360
|
+
lines.push({
|
|
361
|
+
mark: fact.mark,
|
|
362
|
+
text: fact.text,
|
|
363
|
+
...(fact.next === undefined ? {} : { detail: [fact.next] }),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
|
|
295
369
|
async function outboundLines(deliverer: DelivererRecord | undefined, pods: PodRecord[]): Promise<DoctorLine[]> {
|
|
296
370
|
const lines: DoctorLine[] = [];
|
|
297
371
|
const { pending, dead } = await outboxFacts(pods);
|
package/src/extension/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { registerSelf } from "./self.ts";
|
|
|
12
12
|
export const POD_ID_ENV = "AGENTS_POD_POD_ID";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Set by whichever copy of this extension loads first in a
|
|
15
|
+
* Set by whichever copy of this extension loads first in a runtime. `pi
|
|
16
16
|
* install` and a project-local install can both resolve here, and pi would
|
|
17
17
|
* then load two copies that fight over the same tool and command names;
|
|
18
18
|
* `Symbol.for` is shared across module instances, so the second copy sees the
|
|
@@ -22,6 +22,20 @@ const LOADED_FLAG = Symbol.for("agents-pod.extension.loaded");
|
|
|
22
22
|
|
|
23
23
|
export default function (pi: ExtensionAPI): void {
|
|
24
24
|
const scope = globalThis as Record<symbol, boolean>;
|
|
25
|
+
|
|
26
|
+
// `/reload`, `/new`, `/resume` and `/fork` replace the whole extension
|
|
27
|
+
// runtime: pi re-imports this module in the same process (jiti runs with
|
|
28
|
+
// `moduleCache: false`) and rebuilds every extension's command and tool
|
|
29
|
+
// registry from scratch. globalThis survives that re-import while the
|
|
30
|
+
// registrations do not, so a flag left set would make the replacement copy
|
|
31
|
+
// load inert and /pod, spawn_pod and pod_handoff would vanish until the
|
|
32
|
+
// process restarts. Every runtime replacement is preceded by a
|
|
33
|
+
// `session_shutdown`, so clearing there keeps the flag meaning "claimed by
|
|
34
|
+
// this runtime" instead of "claimed by this process".
|
|
35
|
+
pi.on("session_shutdown", () => {
|
|
36
|
+
delete scope[LOADED_FLAG];
|
|
37
|
+
});
|
|
38
|
+
|
|
25
39
|
if (scope[LOADED_FLAG]) return;
|
|
26
40
|
scope[LOADED_FLAG] = true;
|
|
27
41
|
|
package/src/paths.ts
CHANGED
|
@@ -36,6 +36,11 @@ export function sourcesFile(): string {
|
|
|
36
36
|
return join(storeRoot(), "sources.json");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Registered adapter copies and their provenance (issue #26): beside, not inside. */
|
|
40
|
+
export function adaptersFile(): string {
|
|
41
|
+
return join(storeRoot(), "adapters.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
/** The single outbound delivery command, beside the Source records (ADR 0025). */
|
|
40
45
|
export function delivererFile(): string {
|
|
41
46
|
return join(storeRoot(), "deliverer.json");
|
package/src/relocate.ts
ADDED
|
@@ -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
|
+
}
|