agents-pod 0.2.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/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/paths.ts CHANGED
@@ -56,6 +56,11 @@ export function notifyConfigFile(): string {
56
56
  return join(storeRoot(), "config.json");
57
57
  }
58
58
 
59
+ /** When the last pass finished (ADR 0027): "no pass ever ran" is its own fact. */
60
+ export function lastPassFile(): string {
61
+ return join(storeRoot(), "last-pass.json");
62
+ }
63
+
59
64
  /** The short-lived registry mutex that makes state transitions CAS (ADR 0017, 0018). */
60
65
  export function registryLockPath(): string {
61
66
  return join(storeRoot(), "registry.lock");
package/src/sources.ts CHANGED
@@ -14,6 +14,14 @@ export interface SourceRecord {
14
14
  cursor?: string;
15
15
  /** The previous successful drain is the claim-retention horizon (ADR 0024). */
16
16
  lastDrainAt?: number;
17
+ /**
18
+ * That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
19
+ * and delivers nothing is the shape of a conversation that never reaches it.
20
+ */
21
+ lastDrainItems?: number;
22
+ /** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
23
+ lastFailureAt?: number;
24
+ lastError?: string;
17
25
  }
18
26
 
19
27
  export interface SourceDrain {
@@ -273,6 +281,32 @@ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void>
273
281
  source.lastDrainAt = drain.drainedAt;
274
282
  changed = true;
275
283
  }
284
+ // The yield is recorded even when it is zero (ADR 0027): doctor reports a
285
+ // silent pull only because the count exists.
286
+ if (source.lastDrainItems !== drain.items.length) {
287
+ source.lastDrainItems = drain.items.length;
288
+ changed = true;
289
+ }
290
+ // A success clears the failure it disproved (ADR 0027).
291
+ if (source.lastError !== undefined) {
292
+ delete source.lastError;
293
+ delete source.lastFailureAt;
294
+ changed = true;
295
+ }
276
296
  }
277
297
  if (changed) await writeSources(sources);
278
298
  }
299
+
300
+ /**
301
+ * A failed drain leaves the cursor and the yield alone, so its only trace is a
302
+ * `cron.log` line — invisible to anyone not tailing the log. The record keeps
303
+ * the fact for the report (ADR 0027), and a later success clears it.
304
+ */
305
+ export async function recordSourceFailure(name: string, error: string): Promise<void> {
306
+ const sources = await readSources();
307
+ const source = sources.find((candidate) => candidate.name === name);
308
+ if (source === undefined) return;
309
+ source.lastFailureAt = Date.now();
310
+ source.lastError = error;
311
+ await writeSources(sources);
312
+ }