agents-pod 0.1.0 → 0.3.0
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 +68 -0
- package/README.md +283 -25
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +472 -47
- package/src/doctor.ts +551 -0
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +15 -0
- package/src/sources.ts +312 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readListeners, type ListenerRecord } from "./listeners.ts";
|
|
6
|
+
import { readLock } from "./locks.ts";
|
|
7
|
+
import { loadNotifyConfig } from "./notify/config.ts";
|
|
8
|
+
import type { NotificationsConfig } from "./notify/types.ts";
|
|
9
|
+
import { readDeliverer, type DelivererRecord } from "./outbox.ts";
|
|
10
|
+
import {
|
|
11
|
+
lastPassFile,
|
|
12
|
+
notifyConfigFile,
|
|
13
|
+
reconcileLockPath,
|
|
14
|
+
storeRoot,
|
|
15
|
+
} from "./paths.ts";
|
|
16
|
+
import { readPods, type PodRecord } from "./registry.ts";
|
|
17
|
+
import { readSources, type SourceRecord } from "./sources.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Doctor is a read-only Health Report over the store (ADR 0027): what is
|
|
21
|
+
* missing for a conversation to reach a Pod and a reply to leave one, and the
|
|
22
|
+
* one command that fixes each gap. It reads records only — never a platform,
|
|
23
|
+
* never a credential — because ADRs 0024/0025 keep both inside the operator's
|
|
24
|
+
* commands. It prints; it never writes.
|
|
25
|
+
*/
|
|
26
|
+
export type DoctorMark = "ok" | "bad" | "note";
|
|
27
|
+
|
|
28
|
+
export interface DoctorLine {
|
|
29
|
+
mark: DoctorMark;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The one command that fixes a `bad` line (ADR 0027). */
|
|
32
|
+
detail?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DoctorSection {
|
|
36
|
+
title: string;
|
|
37
|
+
lines: DoctorLine[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
store: string;
|
|
42
|
+
sections: DoctorSection[];
|
|
43
|
+
/** Present when nothing is configured: the ordered first run (ADR 0027). */
|
|
44
|
+
recipe: string[];
|
|
45
|
+
healthy: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DoctorOptions {
|
|
49
|
+
/** `--probe`: run each operator command's `--check` (ADR 0027). Never default. */
|
|
50
|
+
probe?: boolean;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
/** The environment password probes read; defaults to the process's own. */
|
|
53
|
+
env?: Record<string, string | undefined>;
|
|
54
|
+
now?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A probe's own budget: the delivery and drain budget, unchanged (ADR 0021). */
|
|
58
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
59
|
+
/** A command that does not implement `--check` says so with this code (ADR 0027). */
|
|
60
|
+
const NO_SELF_CHECK = 3;
|
|
61
|
+
const PROBE_OUTPUT_CAP = 8 * 1024;
|
|
62
|
+
const PROBE_LINES = 6;
|
|
63
|
+
/** A pass lock older than this is what the next pass steals (ADR 0018). */
|
|
64
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
65
|
+
|
|
66
|
+
export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport> {
|
|
67
|
+
const now = options.now ?? Date.now();
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const root = storeRoot();
|
|
70
|
+
|
|
71
|
+
const registry = await readRegistry();
|
|
72
|
+
const sources = await readSources().catch(() => [] as SourceRecord[]);
|
|
73
|
+
const listeners = await readListeners();
|
|
74
|
+
const deliverer = await readDeliverer();
|
|
75
|
+
const warnings: string[] = [];
|
|
76
|
+
const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
|
|
77
|
+
const lastPass = await readLastPass();
|
|
78
|
+
|
|
79
|
+
const firstRun =
|
|
80
|
+
sources.length === 0 && deliverer === undefined && notify.channels.length === 0;
|
|
81
|
+
if (firstRun) {
|
|
82
|
+
return { store: root, sections: [], recipe: recipeFor(sources, listeners, deliverer, notify, lastPass), healthy: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sections: DoctorSection[] = [];
|
|
86
|
+
const storeLines: DoctorLine[] = [];
|
|
87
|
+
if (registry.error !== undefined) {
|
|
88
|
+
storeLines.push({ mark: "bad", text: `pods.json is unreadable: ${registry.error}` });
|
|
89
|
+
} else {
|
|
90
|
+
const active = registry.pods.filter((pod) => pod.state === "active").length;
|
|
91
|
+
storeLines.push({ mark: "ok", text: `${registry.pods.length} pods, ${active} active` });
|
|
92
|
+
}
|
|
93
|
+
const lock = await readLock(reconcileLockPath());
|
|
94
|
+
if (lock !== undefined) {
|
|
95
|
+
storeLines.push(
|
|
96
|
+
now - lock.startedAt > LOCK_STALE_MS
|
|
97
|
+
? { mark: "note", text: `a pass lock has been held for ${age(now - lock.startedAt)} (the next pass steals it)` }
|
|
98
|
+
: { mark: "note", text: `a pass is running (pid ${lock.pid})` },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
sections.push({ title: "store", lines: storeLines });
|
|
102
|
+
|
|
103
|
+
sections.push({ title: "pass", lines: await passLines(lastPass, now, sources, listeners, registry.pods) });
|
|
104
|
+
sections.push({ title: "intake", lines: await intakeLines(sources, listeners, now) });
|
|
105
|
+
sections.push({ title: "listeners", lines: await listenerLines(listeners, sources, registry.pods) });
|
|
106
|
+
sections.push({ title: "outbound", lines: await outboundLines(deliverer, registry.pods) });
|
|
107
|
+
sections.push({ title: "notify", lines: notifyLines(notify, warnings, env) });
|
|
108
|
+
|
|
109
|
+
if (options.probe) {
|
|
110
|
+
sections.push({
|
|
111
|
+
title: "checks",
|
|
112
|
+
lines: [
|
|
113
|
+
...(await probeAll(sources, listeners, deliverer, options.cwd ?? process.cwd(), env)),
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const recipe = firstRun
|
|
119
|
+
? []
|
|
120
|
+
: recipeFor(sources, listeners, deliverer, notify, lastPass);
|
|
121
|
+
const healthy = sections.every((section) => section.lines.every((line) => line.mark !== "bad"));
|
|
122
|
+
return { store: root, sections, recipe, healthy };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readRegistry(): Promise<{ pods: PodRecord[]; error?: string }> {
|
|
126
|
+
try {
|
|
127
|
+
return { pods: await readPods() };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { pods: [], error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The pass marker (ADR 0027) exists because "cron is not installed" and "cron
|
|
135
|
+
* runs and finds nothing" are the same silence without it.
|
|
136
|
+
*/
|
|
137
|
+
async function readLastPass(): Promise<number | undefined> {
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(await readFile(lastPassFile(), "utf8"));
|
|
140
|
+
const at = (parsed as { at?: unknown }).at;
|
|
141
|
+
return typeof at === "number" ? at : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function passLines(
|
|
148
|
+
lastPass: number | undefined,
|
|
149
|
+
now: number,
|
|
150
|
+
sources: SourceRecord[],
|
|
151
|
+
listeners: ListenerRecord[],
|
|
152
|
+
pods: PodRecord[],
|
|
153
|
+
): Promise<DoctorLine[]> {
|
|
154
|
+
const expected = sources.length > 0 || listeners.length > 0 || pods.length > 0;
|
|
155
|
+
if (lastPass === undefined) {
|
|
156
|
+
return [
|
|
157
|
+
expected
|
|
158
|
+
? {
|
|
159
|
+
mark: "bad",
|
|
160
|
+
text: "no pass has ever run — nothing is woken, no Source is drained",
|
|
161
|
+
detail: ["next: crontab -e", " * * * * * cd <project> && agents-pod reconcile >> ~/.agents/pods/cron.log 2>&1"],
|
|
162
|
+
}
|
|
163
|
+
: { mark: "note", text: "no pass has run yet" },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
return [{ mark: "ok", text: `last pass ${age(now - lastPass)} ago` }];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function intakeLines(
|
|
170
|
+
sources: SourceRecord[],
|
|
171
|
+
listeners: ListenerRecord[],
|
|
172
|
+
now: number,
|
|
173
|
+
): Promise<DoctorLine[]> {
|
|
174
|
+
const lines: DoctorLine[] = [];
|
|
175
|
+
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
176
|
+
for (const source of sources) {
|
|
177
|
+
const consumer = consumed.has(source.name);
|
|
178
|
+
const executable = await isExecutable(source.cmd);
|
|
179
|
+
if (!executable) {
|
|
180
|
+
lines.push({
|
|
181
|
+
mark: "bad",
|
|
182
|
+
text: `source ${source.name}: ${source.cmd} is not an executable file`,
|
|
183
|
+
detail: [
|
|
184
|
+
`next: agents-pod source rm ${source.name}`,
|
|
185
|
+
` agents-pod source add ${source.name} --cmd <path> --timeout ${source.timeoutMs / 1000}`,
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const drained =
|
|
191
|
+
source.lastDrainAt === undefined
|
|
192
|
+
? "never drained successfully"
|
|
193
|
+
: `last success ${age(now - source.lastDrainAt)} ago, ${
|
|
194
|
+
source.lastDrainItems === undefined
|
|
195
|
+
? "yield not recorded"
|
|
196
|
+
: `${source.lastDrainItems} items`
|
|
197
|
+
}`;
|
|
198
|
+
const cursor = source.cursor === undefined ? "no cursor" : `cursor ${source.cursor}`;
|
|
199
|
+
// A Source that fails every pass is the loudest thing a report can say; the
|
|
200
|
+
// cursor never moving is otherwise indistinguishable from a quiet chat.
|
|
201
|
+
if (source.lastError !== undefined) {
|
|
202
|
+
const failed = source.lastFailureAt === undefined ? "the last drain" : `the last drain (${age(now - source.lastFailureAt)} ago)`;
|
|
203
|
+
lines.push({
|
|
204
|
+
mark: "bad",
|
|
205
|
+
text: `source ${source.name}: ${failed} failed — ${source.lastError}`,
|
|
206
|
+
detail: [
|
|
207
|
+
` ${drained}; ${cursor} (neither moves until a drain succeeds)`,
|
|
208
|
+
" run: agents-pod doctor --probe",
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!consumer) {
|
|
214
|
+
lines.push({
|
|
215
|
+
mark: "note",
|
|
216
|
+
text: `source ${source.name}: ${drained}, ${cursor} — no listener consumes it, so no pass runs it`,
|
|
217
|
+
detail: ["next: agents-pod listener add --source " + source.name + " --template <file> [--project <dir>]"],
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// Zero items is a quiet chat, not a failure — but it is the exact shape of
|
|
222
|
+
// "the pull is pointed at nothing", so it never hides (ADR 0027).
|
|
223
|
+
if (source.lastDrainItems === 0) {
|
|
224
|
+
lines.push({
|
|
225
|
+
mark: "note",
|
|
226
|
+
text: `source ${source.name}: ${drained} — nothing arrives; the command runs, the conversation does not reach it`,
|
|
227
|
+
detail: [` ${cursor}; run: agents-pod doctor --probe`],
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
|
|
232
|
+
lines.push({ mark: "ok", text: ` ${drained}, ${cursor}` });
|
|
233
|
+
}
|
|
234
|
+
if (sources.length === 0) {
|
|
235
|
+
lines.push({
|
|
236
|
+
mark: "note",
|
|
237
|
+
text: "no Source: nothing from outside this machine becomes an Inbox Item",
|
|
238
|
+
detail: ['next: agents-pod source add <name> --cmd <path> (README "Writing a Source drain")'],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function listenerLines(
|
|
245
|
+
listeners: ListenerRecord[],
|
|
246
|
+
sources: SourceRecord[],
|
|
247
|
+
pods: PodRecord[],
|
|
248
|
+
): Promise<DoctorLine[]> {
|
|
249
|
+
const lines: DoctorLine[] = [];
|
|
250
|
+
for (const listener of listeners) {
|
|
251
|
+
const problems: string[] = [];
|
|
252
|
+
const origin =
|
|
253
|
+
listener.source !== undefined
|
|
254
|
+
? `source ${listener.source}`
|
|
255
|
+
: `inbox ${listener.inbox ?? "(none)"}`;
|
|
256
|
+
if (listener.source !== undefined && !sources.some((source) => source.name === listener.source)) {
|
|
257
|
+
problems.push(`its Source ${listener.source} is not registered`);
|
|
258
|
+
}
|
|
259
|
+
if (listener.source === undefined && !(await exists(listener.inbox ?? ""))) {
|
|
260
|
+
problems.push(`its inbox ${listener.inbox ?? "(none)"} does not exist`);
|
|
261
|
+
}
|
|
262
|
+
if (listener.mode === "template") {
|
|
263
|
+
if (!(await exists(listener.template ?? ""))) {
|
|
264
|
+
problems.push(`its template ${listener.template ?? "(none)"} does not exist`);
|
|
265
|
+
}
|
|
266
|
+
if (listener.project !== undefined && !(await exists(listener.project))) {
|
|
267
|
+
problems.push(`its project ${listener.project} does not exist`);
|
|
268
|
+
}
|
|
269
|
+
} else if (!pods.some((pod) => pod.id === listener.podId)) {
|
|
270
|
+
problems.push(`its Pod ${listener.podId ?? "(none)"} is not in the registry`);
|
|
271
|
+
}
|
|
272
|
+
if (problems.length > 0) {
|
|
273
|
+
lines.push({
|
|
274
|
+
mark: "bad",
|
|
275
|
+
text: `${listener.id}: ${problems.join("; ")}`,
|
|
276
|
+
detail: [`next: agents-pod listener rm ${listener.id}`, " then add it again with a path that exists"],
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const target =
|
|
281
|
+
listener.mode === "template" ? `template ${listener.template ?? "(none)"}` : `pod ${listener.podId}`;
|
|
282
|
+
const project = listener.project !== undefined ? ` → ${listener.project}` : "";
|
|
283
|
+
lines.push({ mark: "ok", text: `${listener.id}: ${origin} → ${target}${project}` });
|
|
284
|
+
}
|
|
285
|
+
if (listeners.length === 0) {
|
|
286
|
+
lines.push({
|
|
287
|
+
mark: "note",
|
|
288
|
+
text: "no Listener: a Source is drained only for a Listener that consumes it",
|
|
289
|
+
detail: ["next: agents-pod listener add --source <name> --template <file> [--project <dir>]"],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function outboundLines(deliverer: DelivererRecord | undefined, pods: PodRecord[]): Promise<DoctorLine[]> {
|
|
296
|
+
const lines: DoctorLine[] = [];
|
|
297
|
+
const { pending, dead } = await outboxFacts(pods);
|
|
298
|
+
if (deliverer === undefined) {
|
|
299
|
+
lines.push({
|
|
300
|
+
mark: "bad",
|
|
301
|
+
text:
|
|
302
|
+
pending > 0
|
|
303
|
+
? `no deliverer: ${pending} entries in Pod outboxes are never sent`
|
|
304
|
+
: "no deliverer: a Pod's outbox is never sent",
|
|
305
|
+
detail: [
|
|
306
|
+
"next: agents-pod deliver add <name> --cmd <path> --timeout <s>",
|
|
307
|
+
" the command owns the platform credentials (ADR 0025)",
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
} else if (!(await isExecutable(deliverer.cmd))) {
|
|
311
|
+
lines.push({
|
|
312
|
+
mark: "bad",
|
|
313
|
+
text: `deliverer ${deliverer.name}: ${deliverer.cmd} is not an executable file`,
|
|
314
|
+
detail: [`next: agents-pod deliver rm ${deliverer.name}, then deliver add with a real path`],
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
lines.push({ mark: "ok", text: `deliverer ${deliverer.name}: ${deliverer.cmd}` });
|
|
318
|
+
}
|
|
319
|
+
if (pending > 0) lines.push({ mark: "note", text: `${pending} entries waiting in Pod outboxes` });
|
|
320
|
+
if (dead > 0) {
|
|
321
|
+
lines.push({
|
|
322
|
+
mark: "note",
|
|
323
|
+
text: `${dead} dead entries retained (older than an hour or malformed; kept, never retried)`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return lines;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Outbox entries are facts of the Pod's workspace, not of the registry (ADR 0025). */
|
|
330
|
+
async function outboxFacts(pods: PodRecord[]): Promise<{ pending: number; dead: number }> {
|
|
331
|
+
let pending = 0;
|
|
332
|
+
let dead = 0;
|
|
333
|
+
for (const pod of pods) {
|
|
334
|
+
const dir = join(pod.project, ".agents", "pods", pod.id, "outbox");
|
|
335
|
+
let names: string[];
|
|
336
|
+
try {
|
|
337
|
+
names = await readdir(dir);
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (const name of names) {
|
|
342
|
+
if (name.endsWith(".dead")) dead += 1;
|
|
343
|
+
else if (name.endsWith(".json")) pending += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { pending, dead };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function notifyLines(
|
|
350
|
+
notify: NotificationsConfig,
|
|
351
|
+
warnings: string[],
|
|
352
|
+
env: Record<string, string | undefined>,
|
|
353
|
+
): DoctorLine[] {
|
|
354
|
+
const lines: DoctorLine[] = warnings.map((warning) => ({ mark: "bad" as const, text: warning }));
|
|
355
|
+
if (notify.channels.length === 0) {
|
|
356
|
+
lines.push({
|
|
357
|
+
mark: "note",
|
|
358
|
+
text: "no notification channels: parks and dones stay silent",
|
|
359
|
+
detail: [`next: add channels to ${notifyConfigFile()} (README "Notifications")`],
|
|
360
|
+
});
|
|
361
|
+
return lines;
|
|
362
|
+
}
|
|
363
|
+
lines.push({ mark: "ok", text: `${notify.channels.length} channels (on: ${notify.on})` });
|
|
364
|
+
if (notify.on === "never") {
|
|
365
|
+
lines.push({ mark: "note", text: "notifications.on is never: every channel is ignored" });
|
|
366
|
+
}
|
|
367
|
+
for (const channel of notify.channels) {
|
|
368
|
+
if (channel.type !== "email" || channel.user === undefined) continue;
|
|
369
|
+
const passwordEnv = channel.passwordEnv ?? "AGENTS_POD_SMTP_PASSWORD";
|
|
370
|
+
if (!env[passwordEnv]) {
|
|
371
|
+
lines.push({
|
|
372
|
+
mark: "bad",
|
|
373
|
+
text: `channel ${channel.id}: ${passwordEnv} is not set in this environment`,
|
|
374
|
+
detail: [" an authenticated SMTP send will fail; core reads no credentials (ADR 0021)"],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `--check` is the only way core can learn about a credential it must not hold
|
|
383
|
+
* (ADR 0027): exit 0 healthy, exit 3 no self-check, anything else unhealthy
|
|
384
|
+
* with the command's own words quoted.
|
|
385
|
+
*/
|
|
386
|
+
async function probeAll(
|
|
387
|
+
sources: SourceRecord[],
|
|
388
|
+
listeners: ListenerRecord[],
|
|
389
|
+
deliverer: DelivererRecord | undefined,
|
|
390
|
+
cwd: string,
|
|
391
|
+
env: Record<string, string | undefined>,
|
|
392
|
+
): Promise<DoctorLine[]> {
|
|
393
|
+
const lines: DoctorLine[] = [];
|
|
394
|
+
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
395
|
+
for (const source of sources) {
|
|
396
|
+
lines.push(
|
|
397
|
+
...(await trace(`source ${source.name}`, source.cmd, {
|
|
398
|
+
...env,
|
|
399
|
+
AGENTS_POD_SOURCE_ID: source.name,
|
|
400
|
+
AGENTS_POD_CURSOR: source.cursor ?? "",
|
|
401
|
+
}, cwd)),
|
|
402
|
+
);
|
|
403
|
+
if (!consumed.has(source.name)) {
|
|
404
|
+
lines.push({ mark: "note", text: `source ${source.name}: no listener consumes it, so no pass runs it` });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (deliverer !== undefined) {
|
|
408
|
+
lines.push(...(await trace(`deliverer ${deliverer.name}`, deliverer.cmd, env, cwd)));
|
|
409
|
+
}
|
|
410
|
+
if (lines.length === 0) {
|
|
411
|
+
lines.push({ mark: "note", text: "nothing to probe: no Source and no deliverer configured" });
|
|
412
|
+
}
|
|
413
|
+
return lines;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function trace(
|
|
417
|
+
label: string,
|
|
418
|
+
cmd: string,
|
|
419
|
+
env: Record<string, string | undefined>,
|
|
420
|
+
cwd: string,
|
|
421
|
+
): Promise<DoctorLine[]> {
|
|
422
|
+
if (!(await isExecutable(cmd))) {
|
|
423
|
+
return [{ mark: "bad", text: `${label}: ${cmd} is not an executable file` }];
|
|
424
|
+
}
|
|
425
|
+
const { code, signal, text } = await runCheck(cmd, env, cwd);
|
|
426
|
+
const quoted = text
|
|
427
|
+
.split("\n")
|
|
428
|
+
.map((line) => line.trim())
|
|
429
|
+
.filter((line) => line !== "")
|
|
430
|
+
.slice(0, PROBE_LINES)
|
|
431
|
+
.map((line) => ` ${line}`);
|
|
432
|
+
if (code === 0) {
|
|
433
|
+
return [{ mark: "ok", text: `${label}: self-check passed`, ...(quoted.length ? { detail: quoted } : {}) }];
|
|
434
|
+
}
|
|
435
|
+
if (code === NO_SELF_CHECK) {
|
|
436
|
+
return [
|
|
437
|
+
{
|
|
438
|
+
mark: "note",
|
|
439
|
+
text: `${label}: no self-check (exit ${NO_SELF_CHECK}) — core cannot verify anything it owns`,
|
|
440
|
+
detail: [" add `--check` to the command (exit 0 healthy, 3 unsupported) to have it verified here"],
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
const why = signal !== null ? `terminated by ${signal}` : `exit ${code ?? "unknown"}`;
|
|
445
|
+
return [
|
|
446
|
+
{ mark: "bad", text: `${label}: self-check failed (${why})`, detail: quoted.length ? quoted : undefined },
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function runCheck(
|
|
451
|
+
cmd: string,
|
|
452
|
+
env: Record<string, string | undefined>,
|
|
453
|
+
cwd: string,
|
|
454
|
+
): Promise<{ code: number | null; signal: NodeJS.Signals | null; text: string }> {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
const child = spawn(cmd, ["--check"], { cwd, env: env as NodeJS.ProcessEnv, detached: true });
|
|
457
|
+
const chunks: Buffer[] = [];
|
|
458
|
+
let bytes = 0;
|
|
459
|
+
const collect = (chunk: Buffer): void => {
|
|
460
|
+
if (bytes >= PROBE_OUTPUT_CAP) return;
|
|
461
|
+
bytes += chunk.length;
|
|
462
|
+
chunks.push(chunk);
|
|
463
|
+
};
|
|
464
|
+
const timer = setTimeout(() => {
|
|
465
|
+
try {
|
|
466
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
467
|
+
} catch {
|
|
468
|
+
child.kill("SIGKILL");
|
|
469
|
+
}
|
|
470
|
+
}, PROBE_TIMEOUT_MS);
|
|
471
|
+
child.stdout.on("data", collect);
|
|
472
|
+
child.stderr.on("data", collect);
|
|
473
|
+
child.on("error", (error) => {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
resolve({ code: null, signal: null, text: error.message });
|
|
476
|
+
});
|
|
477
|
+
child.on("close", (code, signal) => {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
resolve({ code, signal, text: Buffer.concat(chunks).toString("utf8") });
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The first run, in order (ADR 0027): each line is one command, and the last
|
|
486
|
+
* one is the report that says every line is `✓`.
|
|
487
|
+
*/
|
|
488
|
+
function recipeFor(
|
|
489
|
+
sources: SourceRecord[],
|
|
490
|
+
listeners: ListenerRecord[],
|
|
491
|
+
deliverer: DelivererRecord | undefined,
|
|
492
|
+
notify: NotificationsConfig,
|
|
493
|
+
lastPass: number | undefined,
|
|
494
|
+
): string[] {
|
|
495
|
+
const steps: string[][] = [];
|
|
496
|
+
if (sources.length === 0) {
|
|
497
|
+
steps.push([
|
|
498
|
+
'write a Source command: one JSONL item per message, then {"cursor":"…"}',
|
|
499
|
+
' README "Writing a Source drain"; a worked Feishu pair: examples/feishu/',
|
|
500
|
+
" agents-pod source add <name> --cmd <path> --timeout <s>",
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
503
|
+
if (listeners.length === 0) {
|
|
504
|
+
steps.push(["agents-pod listener add --source <name> --template <file> [--project <dir>]"]);
|
|
505
|
+
}
|
|
506
|
+
if (deliverer === undefined) {
|
|
507
|
+
steps.push(["agents-pod deliver add <name> --cmd <path> --timeout <s>"]);
|
|
508
|
+
}
|
|
509
|
+
if (notify.channels.length === 0) {
|
|
510
|
+
steps.push([`add notification channels to ${notifyConfigFile()} (README "Notifications")`]);
|
|
511
|
+
}
|
|
512
|
+
if (lastPass === undefined) {
|
|
513
|
+
steps.push([
|
|
514
|
+
"crontab: * * * * * cd <project> && agents-pod reconcile >> ~/.agents/pods/cron.log 2>&1",
|
|
515
|
+
]);
|
|
516
|
+
}
|
|
517
|
+
steps.push(["agents-pod doctor --probe — until every line is ✓"]);
|
|
518
|
+
return steps.flatMap((lines, index) =>
|
|
519
|
+
lines.map((line, position) => (position === 0 ? `${index + 1}. ${line}` : ` ${line}`)),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function exists(path: string): Promise<boolean> {
|
|
524
|
+
if (!path) return false;
|
|
525
|
+
try {
|
|
526
|
+
await stat(path);
|
|
527
|
+
return true;
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function isExecutable(path: string): Promise<boolean> {
|
|
534
|
+
if (!path) return false;
|
|
535
|
+
try {
|
|
536
|
+
const info = await stat(path);
|
|
537
|
+
if (!info.isFile()) return false;
|
|
538
|
+
await access(path, constants.X_OK);
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Ages are read at a glance: seconds, minutes, hours, days, never an epoch. */
|
|
546
|
+
function age(ms: number): string {
|
|
547
|
+
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`;
|
|
548
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
549
|
+
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
|
550
|
+
return `${Math.round(ms / 86_400_000)}d`;
|
|
551
|
+
}
|
package/src/inbox.ts
CHANGED
|
@@ -1,18 +1,58 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
/**
|
|
4
|
-
* An Inbox Item (ADR 0013): one positional id, the
|
|
4
|
+
* An Inbox Item (ADR 0013): one positional id, the values a Pod Template
|
|
5
5
|
* binds, and the facts the two-pass stability guard compares. `{{path}}` is the
|
|
6
6
|
* item file's absolute path (directory inbox) or the JSONL path plus line.
|
|
7
|
+
*
|
|
8
|
+
* The optional routing fields are the platform's passengers (ADR 0026): they
|
|
9
|
+
* never join the id or the body, they only feed the Delivery Address.
|
|
7
10
|
*/
|
|
8
11
|
export interface InboxItem {
|
|
9
|
-
/** Directory:
|
|
12
|
+
/** Directory: filename. File: line number. Source: the platform's id (ADRs 0013, 0024). */
|
|
10
13
|
id: string;
|
|
11
14
|
title: string;
|
|
12
15
|
body: string;
|
|
16
|
+
/** Empty for a Source item, which has no filesystem attachment (ADR 0024). */
|
|
13
17
|
path: string;
|
|
18
|
+
/** Inbox stability facts only; placeholders are zero for a Source item. */
|
|
14
19
|
mtimeMs: number;
|
|
15
20
|
size: number;
|
|
21
|
+
/** The chat a reply has to land in; without it there is no address. */
|
|
22
|
+
chatId?: string;
|
|
23
|
+
/** The root message: the Conversation's key, propagated by the platform. */
|
|
24
|
+
rootId?: string;
|
|
25
|
+
/** The topic thread, when the platform gave the message one. */
|
|
26
|
+
threadId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Delivery Address (ADR 0026): the Conversation's key, the chat, and the
|
|
31
|
+
* optional thread as one opaque token — the string an outbox entry's `to`
|
|
32
|
+
* takes verbatim and a deliverer parses. Core never reads it back. An item with
|
|
33
|
+
* no chat has nowhere to be answered, so it renders the empty string rather
|
|
34
|
+
* than a token with a `chat=` hole; root and thread are passengers, never a
|
|
35
|
+
* substitute for the chat.
|
|
36
|
+
*
|
|
37
|
+
* The affinity is `root_id ?? item id`, exactly as ADR 0026 states it. For a
|
|
38
|
+
* directory or JSONL inbox the id is the item's position, not a platform
|
|
39
|
+
* message id; a Source (ADR 0024) is what makes the id the platform's, and
|
|
40
|
+
* therefore the fallback usable by a deliverer.
|
|
41
|
+
*/
|
|
42
|
+
export function deliveryAddress(
|
|
43
|
+
item: Pick<InboxItem, "id" | "chatId" | "rootId" | "threadId">,
|
|
44
|
+
): string {
|
|
45
|
+
if (item.chatId === undefined) return "";
|
|
46
|
+
const affinity = item.rootId ?? item.id;
|
|
47
|
+
const thread = item.threadId === undefined ? "" : `;thread=${item.threadId}`;
|
|
48
|
+
return `chat=${item.chatId};affinity=${affinity}${thread}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A routing field is a non-empty, trimmed string; anything else is as ignored as an unknown key. */
|
|
52
|
+
function routingField(value: unknown): string | undefined {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed === "" ? undefined : trimmed;
|
|
16
56
|
}
|
|
17
57
|
|
|
18
58
|
/** A malformed JSONL line: skipped, unclaimed, warned on every pass (ADR 0013). */
|
|
@@ -74,8 +114,10 @@ async function scanDirectory(dir: string): Promise<InboxScan> {
|
|
|
74
114
|
|
|
75
115
|
/**
|
|
76
116
|
* A file inbox is JSONL only (ADR 0013): `body` is required, `title` optional,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
117
|
+
* `chat_id`/`root_id`/`thread_id` are read as the item's routing fields
|
|
118
|
+
* (ADR 0026), and every other key is ignored. A line that does not parse, or
|
|
119
|
+
* carries no string body, is skipped and reported — never claimed, never fatal
|
|
120
|
+
* to the pass.
|
|
79
121
|
*/
|
|
80
122
|
async function scanJsonl(file: string): Promise<InboxScan> {
|
|
81
123
|
const info = await stat(file).catch(() => undefined);
|
|
@@ -101,7 +143,13 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
101
143
|
bad.push({ line, reason: "invalid JSON (not an object)" });
|
|
102
144
|
continue;
|
|
103
145
|
}
|
|
104
|
-
const record = parsed as {
|
|
146
|
+
const record = parsed as {
|
|
147
|
+
body?: unknown;
|
|
148
|
+
title?: unknown;
|
|
149
|
+
chat_id?: unknown;
|
|
150
|
+
root_id?: unknown;
|
|
151
|
+
thread_id?: unknown;
|
|
152
|
+
};
|
|
105
153
|
if (typeof record.body !== "string") {
|
|
106
154
|
bad.push({ line, reason: "no body" });
|
|
107
155
|
continue;
|
|
@@ -110,6 +158,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
110
158
|
typeof record.title === "string" && record.title.trim() !== ""
|
|
111
159
|
? record.title
|
|
112
160
|
: firstLine(record.body);
|
|
161
|
+
const chatId = routingField(record.chat_id);
|
|
162
|
+
const rootId = routingField(record.root_id);
|
|
163
|
+
const threadId = routingField(record.thread_id);
|
|
113
164
|
items.push({
|
|
114
165
|
id: String(line),
|
|
115
166
|
title,
|
|
@@ -117,6 +168,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
117
168
|
path: `${file}#${line}`,
|
|
118
169
|
mtimeMs: info.mtimeMs,
|
|
119
170
|
size: info.size,
|
|
171
|
+
...(chatId === undefined ? {} : { chatId }),
|
|
172
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
173
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
120
174
|
});
|
|
121
175
|
}
|
|
122
176
|
return { items, bad };
|
|
@@ -129,8 +183,19 @@ export async function scanInbox(inbox: string): Promise<InboxScan> {
|
|
|
129
183
|
return info.isDirectory() ? scanDirectory(inbox) : scanJsonl(inbox);
|
|
130
184
|
}
|
|
131
185
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
186
|
+
/**
|
|
187
|
+
* The five template placeholders (ADR 0013, amended by ADR 0026), each one a
|
|
188
|
+
* renderer: adding a sixth is one row, not a set plus two casts.
|
|
189
|
+
*/
|
|
190
|
+
const PLACEHOLDERS: Record<string, (item: InboxItem) => string> = {
|
|
191
|
+
id: (item) => item.id,
|
|
192
|
+
title: (item) => item.title,
|
|
193
|
+
body: (item) => item.body,
|
|
194
|
+
path: (item) => item.path,
|
|
195
|
+
// The address is derived, not a field: an item without routing fields
|
|
196
|
+
// renders empty, so a template stays valid for both kinds of item.
|
|
197
|
+
reply_to: deliveryAddress,
|
|
198
|
+
};
|
|
134
199
|
|
|
135
200
|
/**
|
|
136
201
|
* Render a Pod Template against one item. An unknown `{{...}}` is a
|
|
@@ -144,11 +209,12 @@ export function renderTemplate(
|
|
|
144
209
|
const unknown: string[] = [];
|
|
145
210
|
const goal = template.replace(/\{\{([^{}]*)\}\}/g, (whole: string, raw: string) => {
|
|
146
211
|
const name = raw.trim();
|
|
147
|
-
|
|
212
|
+
const render = PLACEHOLDERS[name];
|
|
213
|
+
if (render === undefined) {
|
|
148
214
|
unknown.push(name);
|
|
149
215
|
return whole;
|
|
150
216
|
}
|
|
151
|
-
return item
|
|
217
|
+
return render(item);
|
|
152
218
|
});
|
|
153
219
|
if (unknown.length > 0) return { error: `unknown placeholder {{${unknown[0]}}}` };
|
|
154
220
|
return { goal };
|