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.
- package/CHANGELOG.md +75 -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/paths.ts +5 -0
- package/src/relocate.ts +395 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { adaptersFile, storeRoot } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adapter copies are the operator's files, outside the repository (ADRs 0023,
|
|
9
|
+
* 0024): core ships an example, the operator copies it beside their
|
|
10
|
+
* credentials. The gap this module closes is *provenance*: `--check` proves an
|
|
11
|
+
* adapter answers, never that it is current, so a copy can sit a release behind
|
|
12
|
+
* and every `doctor` line still reads ✓ (issue #26).
|
|
13
|
+
*
|
|
14
|
+
* Nothing here writes into a copy unless a human ran `adapter sync`: a package
|
|
15
|
+
* update must not silently change a command that holds live credentials.
|
|
16
|
+
*/
|
|
17
|
+
export interface AdapterFile {
|
|
18
|
+
name: string;
|
|
19
|
+
sha256: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One registered copy: where it lives, and the package state it came from. */
|
|
23
|
+
export interface AdapterRecord {
|
|
24
|
+
name: string;
|
|
25
|
+
dir: string;
|
|
26
|
+
packageVersion: string;
|
|
27
|
+
files: AdapterFile[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Files a copy owns but the package must never overwrite: credentials and the
|
|
32
|
+
* caches a platform writes for itself. Enforced even though `examples/` ships
|
|
33
|
+
* none of them — a future example that did would otherwise leak a credential
|
|
34
|
+
* over an operator's real one.
|
|
35
|
+
*/
|
|
36
|
+
const RUNTIME_ONLY = new Set([".env", ".token.json", ".bot.json", "state.json"]);
|
|
37
|
+
|
|
38
|
+
/** `same` needs no action; `update` is a pristine copy one version behind. */
|
|
39
|
+
export type AdapterFileState = "same" | "update" | "missing-in-copy" | "local-edit";
|
|
40
|
+
|
|
41
|
+
export interface AdapterFilePlan {
|
|
42
|
+
name: string;
|
|
43
|
+
state: AdapterFileState;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AdapterSyncPlan {
|
|
47
|
+
name: string;
|
|
48
|
+
dir: string;
|
|
49
|
+
packageDir: string;
|
|
50
|
+
packageVersion: string;
|
|
51
|
+
files: AdapterFilePlan[];
|
|
52
|
+
/** Package entries that are not files an operator copy can take. */
|
|
53
|
+
skipped: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A fact for `doctor`; the caller decides how to print it (ADR 0027). */
|
|
57
|
+
export interface AdapterFact {
|
|
58
|
+
mark: "ok" | "bad" | "note";
|
|
59
|
+
text: string;
|
|
60
|
+
next?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The installed package root, derived from this module's own location: `src/`
|
|
65
|
+
* and `examples/` are siblings there, which is what makes the copy source the
|
|
66
|
+
* *installed* package rather than whatever working tree is nearby.
|
|
67
|
+
*/
|
|
68
|
+
export function packageRoot(): string {
|
|
69
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readPackageVersion(root = packageRoot()): Promise<string> {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
|
75
|
+
version?: string;
|
|
76
|
+
};
|
|
77
|
+
return parsed.version ?? "unknown";
|
|
78
|
+
} catch {
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function adapterPackageDir(name: string, root = packageRoot()): string {
|
|
84
|
+
return join(root, "examples", name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readAdapters(): Promise<AdapterRecord[]> {
|
|
88
|
+
try {
|
|
89
|
+
const parsed: unknown = JSON.parse(await readFile(adaptersFile(), "utf8"));
|
|
90
|
+
return Array.isArray(parsed) ? (parsed as AdapterRecord[]) : [];
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Atomic like every other store write: a crash never leaves half a manifest. */
|
|
98
|
+
export async function writeAdapters(records: AdapterRecord[]): Promise<void> {
|
|
99
|
+
const file = adaptersFile();
|
|
100
|
+
await mkdir(dirname(file), { recursive: true });
|
|
101
|
+
const tmp = `${file}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}.tmp`;
|
|
102
|
+
await writeFile(tmp, `${JSON.stringify(records, null, 2)}\n`);
|
|
103
|
+
await rename(tmp, file);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sha256(content: Buffer | string): string {
|
|
107
|
+
return createHash("sha256").update(content).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fileSha(path: string): Promise<string | undefined> {
|
|
111
|
+
try {
|
|
112
|
+
return sha256(await readFile(path));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The files a copy takes: regular files at the top of the package's example
|
|
121
|
+
* directory. `__pycache__` and a `.pyc` would be the package's build residue,
|
|
122
|
+
* not the adapter; runtime caches are excluded by name in `RUNTIME_ONLY`.
|
|
123
|
+
*/
|
|
124
|
+
async function packageFiles(packageDir: string): Promise<{ files: string[]; skipped: string[] }> {
|
|
125
|
+
const entries = await readdir(packageDir, { withFileTypes: true });
|
|
126
|
+
const files: string[] = [];
|
|
127
|
+
const skipped: string[] = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (RUNTIME_ONLY.has(entry.name) || entry.name === "__pycache__" || entry.name.endsWith(".pyc")) {
|
|
130
|
+
skipped.push(entry.name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!entry.isFile()) {
|
|
134
|
+
skipped.push(entry.name);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
files.push(entry.name);
|
|
138
|
+
}
|
|
139
|
+
files.sort();
|
|
140
|
+
return { files, skipped };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What a sync would do, without doing it. A file whose copy matches the
|
|
145
|
+
* manifest but not the package is one version behind (`update`); a file that
|
|
146
|
+
* matches neither is the operator's own edit (`local-edit`) and is reported,
|
|
147
|
+
* never overwritten without `--force`.
|
|
148
|
+
*/
|
|
149
|
+
export async function planAdapterSync(
|
|
150
|
+
name: string,
|
|
151
|
+
options: { root?: string; dest?: string } = {},
|
|
152
|
+
): Promise<AdapterSyncPlan> {
|
|
153
|
+
const root = options.root ?? packageRoot();
|
|
154
|
+
const packageDir = adapterPackageDir(name, root);
|
|
155
|
+
const info = await stat(packageDir).catch(() => undefined);
|
|
156
|
+
if (info === undefined || !info.isDirectory()) {
|
|
157
|
+
throw new Error(`the package ships no adapter named ${name} (${packageDir})`);
|
|
158
|
+
}
|
|
159
|
+
// The store root, not $HOME: a test store and a secondary install must not
|
|
160
|
+
// sync into the machine-wide copy (ADR 0004).
|
|
161
|
+
const dir = resolve(options.dest ?? join(storeRoot(), "adapters"));
|
|
162
|
+
const packageVersion = await readPackageVersion(root);
|
|
163
|
+
const { files, skipped } = await packageFiles(packageDir);
|
|
164
|
+
const recorded = (await readAdapters()).find((record) => record.name === name);
|
|
165
|
+
|
|
166
|
+
const plan: AdapterFilePlan[] = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const wanted = await fileSha(join(packageDir, file));
|
|
169
|
+
const present = await fileSha(join(dir, file));
|
|
170
|
+
if (present === undefined) {
|
|
171
|
+
plan.push({ name: file, state: "missing-in-copy" });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (present === wanted) {
|
|
175
|
+
plan.push({ name: file, state: "same" });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const known = recorded?.files.find((entry) => entry.name === file)?.sha256;
|
|
179
|
+
plan.push({ name: file, state: known !== undefined && known === present ? "update" : "local-edit" });
|
|
180
|
+
}
|
|
181
|
+
return { name, dir, packageDir, packageVersion, files: plan, skipped };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Writes the copy and records its provenance; returns what it changed. */
|
|
185
|
+
export async function applyAdapterSync(
|
|
186
|
+
plan: AdapterSyncPlan,
|
|
187
|
+
options: { force?: boolean } = {},
|
|
188
|
+
): Promise<{ copied: string[]; kept: string[]; manifest: string }> {
|
|
189
|
+
await mkdir(plan.dir, { recursive: true });
|
|
190
|
+
const copied: string[] = [];
|
|
191
|
+
const kept: string[] = [];
|
|
192
|
+
const files: AdapterFile[] = [];
|
|
193
|
+
|
|
194
|
+
for (const file of plan.files) {
|
|
195
|
+
const from = join(plan.packageDir, file.name);
|
|
196
|
+
const to = join(plan.dir, file.name);
|
|
197
|
+
if (file.state === "local-edit" && options.force !== true) {
|
|
198
|
+
kept.push(file.name);
|
|
199
|
+
} else if (file.state !== "same") {
|
|
200
|
+
await copyFile(from, to);
|
|
201
|
+
copied.push(file.name);
|
|
202
|
+
}
|
|
203
|
+
// The manifest records the *package* hash, never what a kept local edit
|
|
204
|
+
// happens to be: otherwise a kept file would later read as "stale" instead
|
|
205
|
+
// of "the operator edited this", which is the fact worth reporting.
|
|
206
|
+
files.push({ name: file.name, sha256: sha256(await readFile(from)) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const records = await readAdapters();
|
|
210
|
+
const record: AdapterRecord = {
|
|
211
|
+
name: plan.name,
|
|
212
|
+
dir: plan.dir,
|
|
213
|
+
packageVersion: plan.packageVersion,
|
|
214
|
+
files: files.sort((left, right) => left.name.localeCompare(right.name)),
|
|
215
|
+
};
|
|
216
|
+
const remaining = records.filter((entry) => entry.name !== plan.name);
|
|
217
|
+
remaining.push(record);
|
|
218
|
+
await writeAdapters(remaining);
|
|
219
|
+
return { copied, kept, manifest: adaptersFile() };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Provenance facts for `doctor` (ADR 0027). Only registered copies are
|
|
224
|
+
* reported: an unregistered directory is not something core was asked to
|
|
225
|
+
* watch, and guessing would put a platform name into core.
|
|
226
|
+
*/
|
|
227
|
+
export async function adapterFacts(
|
|
228
|
+
records: AdapterRecord[],
|
|
229
|
+
root = packageRoot(),
|
|
230
|
+
): Promise<AdapterFact[]> {
|
|
231
|
+
const facts: AdapterFact[] = [];
|
|
232
|
+
const version = await readPackageVersion(root);
|
|
233
|
+
|
|
234
|
+
for (const record of records) {
|
|
235
|
+
const dirInfo = await stat(record.dir).catch(() => undefined);
|
|
236
|
+
if (dirInfo === undefined || !dirInfo.isDirectory()) {
|
|
237
|
+
facts.push({
|
|
238
|
+
mark: "bad",
|
|
239
|
+
text: `adapters ${record.name}: ${record.dir} is gone (a registered copy)`,
|
|
240
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (record.files.length === 0) {
|
|
246
|
+
// A record without hashes cannot be compared, so it must not read as ✓:
|
|
247
|
+
// an empty manifest is the shape of "registered before hashes were kept".
|
|
248
|
+
facts.push({
|
|
249
|
+
mark: "note",
|
|
250
|
+
text: `adapters ${record.name}: no file hashes recorded, so nothing can be compared`,
|
|
251
|
+
next: `next: agents-pod adapter sync ${record.name} --apply`,
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const stale: string[] = [];
|
|
257
|
+
const edited: string[] = [];
|
|
258
|
+
const absent: string[] = [];
|
|
259
|
+
for (const file of record.files) {
|
|
260
|
+
const wanted = await fileSha(join(adapterPackageDir(record.name, root), file.name));
|
|
261
|
+
const present = await fileSha(join(record.dir, file.name));
|
|
262
|
+
if (present === undefined) {
|
|
263
|
+
absent.push(file.name);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (wanted === undefined) {
|
|
267
|
+
// The package stopped shipping it: the copy's file is now the only one.
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (present === wanted) continue;
|
|
271
|
+
if (present === file.sha256) stale.push(file.name);
|
|
272
|
+
else edited.push(file.name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (absent.length > 0) {
|
|
276
|
+
facts.push({
|
|
277
|
+
mark: "bad",
|
|
278
|
+
text: `adapters ${record.name}: ${absent.join(", ")} missing from ${record.dir}`,
|
|
279
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (stale.length > 0) {
|
|
283
|
+
facts.push({
|
|
284
|
+
mark: "bad",
|
|
285
|
+
text:
|
|
286
|
+
`adapters ${record.name}: ${stale.join(", ")} is from ${record.packageVersion}, ` +
|
|
287
|
+
`the package ships ${version}`,
|
|
288
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (edited.length > 0) {
|
|
292
|
+
facts.push({
|
|
293
|
+
mark: "note",
|
|
294
|
+
text: `adapters ${record.name}: ${edited.join(", ")} differs from the package (kept: a local edit)`,
|
|
295
|
+
next: ` agents-pod adapter sync ${record.name} --dry-run names it; --force overwrites it`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (absent.length === 0 && stale.length === 0 && edited.length === 0) {
|
|
299
|
+
facts.push({ mark: "ok", text: `adapters ${record.name}: matches package ${version}` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return facts;
|
|
303
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,17 @@ import {
|
|
|
46
46
|
readProjects,
|
|
47
47
|
writeProjects,
|
|
48
48
|
} from "./projects.ts";
|
|
49
|
+
import {
|
|
50
|
+
applyProjectMove,
|
|
51
|
+
planProjectMove,
|
|
52
|
+
type ProjectMovePlan,
|
|
53
|
+
} from "./relocate.ts";
|
|
54
|
+
import {
|
|
55
|
+
applyAdapterSync,
|
|
56
|
+
planAdapterSync,
|
|
57
|
+
type AdapterFileState,
|
|
58
|
+
type AdapterSyncPlan,
|
|
59
|
+
} from "./adapters.ts";
|
|
49
60
|
import type { PodRecord } from "./registry.ts";
|
|
50
61
|
import { readPods, withRegistryLock, writePods } from "./registry.ts";
|
|
51
62
|
import {
|
|
@@ -1285,7 +1296,169 @@ async function spawnCmd(
|
|
|
1285
1296
|
return 0;
|
|
1286
1297
|
}
|
|
1287
1298
|
|
|
1288
|
-
|
|
1299
|
+
const PROJECT_USAGE =
|
|
1300
|
+
"Usage: agents-pod project cap [n|unlimited] [--project <dir>]\n" +
|
|
1301
|
+
" agents-pod project move <old> <new> [--apply] [--move-artifacts]";
|
|
1302
|
+
|
|
1303
|
+
function printProjectMove(
|
|
1304
|
+
plan: ProjectMovePlan,
|
|
1305
|
+
out: (line: string) => void,
|
|
1306
|
+
err: (line: string) => void,
|
|
1307
|
+
): void {
|
|
1308
|
+
for (const pod of plan.pods) {
|
|
1309
|
+
if (pod.artifact === "missing") continue; // its refusal below is the pod's line
|
|
1310
|
+
const artifact =
|
|
1311
|
+
pod.artifact === "present"
|
|
1312
|
+
? `artifacts at ${pod.to}`
|
|
1313
|
+
: `move artifacts from ${pod.from}`;
|
|
1314
|
+
out(`✓ relocate ${pod.id} ${plan.oldPath} → ${plan.newPath} (${artifact})`);
|
|
1315
|
+
}
|
|
1316
|
+
for (const listener of plan.listeners) {
|
|
1317
|
+
out(`✓ relocate ${listener.id} (template) ${plan.oldPath} → ${plan.newPath}`);
|
|
1318
|
+
}
|
|
1319
|
+
for (const project of plan.projects) {
|
|
1320
|
+
out(
|
|
1321
|
+
`✓ relocate capacity record ${plan.oldPath} → ${plan.newPath} (maxActive ${project.maxActive})`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
for (const refusal of plan.refusals) err(`✗ ${refusal.reason}`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Relocate records after the operator moved the tree; identity stays put (ADR 0028). */
|
|
1328
|
+
async function projectMoveCmd(
|
|
1329
|
+
words: string[],
|
|
1330
|
+
deps: CliDeps,
|
|
1331
|
+
out: (line: string) => void,
|
|
1332
|
+
err: (line: string) => void,
|
|
1333
|
+
): Promise<number> {
|
|
1334
|
+
const apply = words.includes("--apply");
|
|
1335
|
+
const moveArtifacts = words.includes("--move-artifacts");
|
|
1336
|
+
const positional = words.filter((word) => word !== "--apply" && word !== "--move-artifacts");
|
|
1337
|
+
if (
|
|
1338
|
+
positional.length !== 2 ||
|
|
1339
|
+
positional.some((word) => word.startsWith("--")) ||
|
|
1340
|
+
positional[0] === undefined ||
|
|
1341
|
+
positional[1] === undefined
|
|
1342
|
+
) {
|
|
1343
|
+
err(PROJECT_USAGE);
|
|
1344
|
+
return 2;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1348
|
+
const oldPath = resolve(cwd, positional[0]);
|
|
1349
|
+
const newPath = resolve(cwd, positional[1]);
|
|
1350
|
+
if (oldPath === newPath) {
|
|
1351
|
+
err("old and new paths are the same; nothing to move");
|
|
1352
|
+
err(PROJECT_USAGE);
|
|
1353
|
+
return 2;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const plan = await planProjectMove(oldPath, newPath, { moveArtifacts });
|
|
1357
|
+
if (plan.matched === 0) {
|
|
1358
|
+
out(`no record is bound to ${oldPath}`);
|
|
1359
|
+
if (apply) out("nothing to do");
|
|
1360
|
+
return 0;
|
|
1361
|
+
}
|
|
1362
|
+
printProjectMove(plan, out, err);
|
|
1363
|
+
if (plan.refusals.length > 0) return 1;
|
|
1364
|
+
if (!apply) {
|
|
1365
|
+
out("run again with --apply to write");
|
|
1366
|
+
return 0;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
const result = await applyProjectMove(plan);
|
|
1371
|
+
if (result.files.length === 0) {
|
|
1372
|
+
out("nothing to do");
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
for (const file of result.files) {
|
|
1376
|
+
out(`wrote ${file.name}: ${file.changed} record${file.changed === 1 ? "" : "s"} changed`);
|
|
1377
|
+
}
|
|
1378
|
+
return 0;
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
err(`project move failed: ${(error as Error).message}`);
|
|
1381
|
+
return 1;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const ADAPTER_USAGE =
|
|
1386
|
+
"Usage: agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force]";
|
|
1387
|
+
|
|
1388
|
+
/** One mark per file, so a dry run reads like a diff (ADR 0029). */
|
|
1389
|
+
const ADAPTER_MARKS: Record<AdapterFileState, string> = {
|
|
1390
|
+
same: "=",
|
|
1391
|
+
update: "↑",
|
|
1392
|
+
"missing-in-copy": "+",
|
|
1393
|
+
"local-edit": "!",
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
function printAdapterPlan(plan: AdapterSyncPlan, out: (line: string) => void): void {
|
|
1397
|
+
out(`adapter ${plan.name} ${plan.packageDir} → ${plan.dir} (package ${plan.packageVersion})`);
|
|
1398
|
+
for (const file of plan.files) {
|
|
1399
|
+
const kept = file.state === "local-edit" ? " (kept: a local edit; --force overwrites)" : "";
|
|
1400
|
+
out(` ${ADAPTER_MARKS[file.state]} ${file.name} ${file.state}${kept}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* The package ships, the operator copies, `doctor` reports drift (ADR 0029).
|
|
1406
|
+
* Dry run by default: nothing writes into a directory that holds a live
|
|
1407
|
+
* credential unless a human asked for it in this invocation.
|
|
1408
|
+
*/
|
|
1409
|
+
async function adapterCmd(
|
|
1410
|
+
words: string[],
|
|
1411
|
+
deps: CliDeps,
|
|
1412
|
+
out: (line: string) => void,
|
|
1413
|
+
err: (line: string) => void,
|
|
1414
|
+
): Promise<number> {
|
|
1415
|
+
const [sub, ...rest] = words;
|
|
1416
|
+
if (sub !== "sync") {
|
|
1417
|
+
err(`Unknown adapter command: ${sub ?? "(none)"}. Try: sync`);
|
|
1418
|
+
err(ADAPTER_USAGE);
|
|
1419
|
+
return 2;
|
|
1420
|
+
}
|
|
1421
|
+
const apply = rest.includes("--apply");
|
|
1422
|
+
const force = rest.includes("--force");
|
|
1423
|
+
const { flags, rest: positional } = parseFlags(
|
|
1424
|
+
rest.filter((word) => word !== "--apply" && word !== "--force"),
|
|
1425
|
+
["dest"],
|
|
1426
|
+
);
|
|
1427
|
+
const name = positional[0];
|
|
1428
|
+
if (positional.length !== 1 || name === undefined) {
|
|
1429
|
+
err(ADAPTER_USAGE);
|
|
1430
|
+
return 2;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
let plan: AdapterSyncPlan;
|
|
1434
|
+
try {
|
|
1435
|
+
plan = await planAdapterSync(name, {
|
|
1436
|
+
dest: flags.dest === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), flags.dest),
|
|
1437
|
+
});
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
err((error as Error).message);
|
|
1440
|
+
return 2;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
printAdapterPlan(plan, out);
|
|
1444
|
+
const pending = plan.files.filter((file) => file.state !== "same").length;
|
|
1445
|
+
if (pending === 0) {
|
|
1446
|
+
out(`already matches package ${plan.packageVersion}`);
|
|
1447
|
+
return 0;
|
|
1448
|
+
}
|
|
1449
|
+
if (!apply) {
|
|
1450
|
+
out("run again with --apply to write");
|
|
1451
|
+
return 0;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const result = await applyAdapterSync(plan, { force });
|
|
1455
|
+
if (result.copied.length > 0) out(`copied ${result.copied.join(", ")}`);
|
|
1456
|
+
if (result.kept.length > 0) out(`kept ${result.kept.join(", ")} (local edits; --force overwrites)`);
|
|
1457
|
+
out(`recorded package ${plan.packageVersion} in ${result.manifest}`);
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** Per-project capacity and project-location record maintenance (ADR 0022, 0028). */
|
|
1289
1462
|
async function projectCmd(
|
|
1290
1463
|
words: string[],
|
|
1291
1464
|
deps: CliDeps,
|
|
@@ -1293,10 +1466,17 @@ async function projectCmd(
|
|
|
1293
1466
|
err: (line: string) => void,
|
|
1294
1467
|
): Promise<number> {
|
|
1295
1468
|
const [sub, ...rest] = words;
|
|
1469
|
+
if (sub === "move") return projectMoveCmd(rest, deps, out, err);
|
|
1470
|
+
if (sub !== "cap") {
|
|
1471
|
+
err(`Unknown project command: ${sub ?? "(none)"}. Try: cap | move`);
|
|
1472
|
+
err(PROJECT_USAGE);
|
|
1473
|
+
return 2;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1296
1476
|
const { flags, rest: positional } = parseFlags(rest, ["project"]);
|
|
1297
1477
|
const project = resolve(flags.project ?? deps.cwd ?? process.cwd());
|
|
1298
|
-
if (
|
|
1299
|
-
err(
|
|
1478
|
+
if (positional.length > 1) {
|
|
1479
|
+
err(PROJECT_USAGE);
|
|
1300
1480
|
return 2;
|
|
1301
1481
|
}
|
|
1302
1482
|
|
|
@@ -1928,7 +2108,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
1928
2108
|
out("");
|
|
1929
2109
|
out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
|
|
1930
2110
|
out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
|
|
1931
|
-
out("records agent | listener | source | deliver | project | reconcile");
|
|
2111
|
+
out("records agent | listener | source | deliver | adapter | project | reconcile");
|
|
1932
2112
|
return 0;
|
|
1933
2113
|
}
|
|
1934
2114
|
|
|
@@ -1955,6 +2135,8 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
1955
2135
|
return sourceCmd(rest, deps, out, err);
|
|
1956
2136
|
case "deliver":
|
|
1957
2137
|
return deliverCmd(rest, deps, out, err);
|
|
2138
|
+
case "adapter":
|
|
2139
|
+
return adapterCmd(rest, deps, out, err);
|
|
1958
2140
|
case "project":
|
|
1959
2141
|
return projectCmd(rest, deps, out, err);
|
|
1960
2142
|
case "doctor":
|
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/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");
|