@ours.network/fleet 0.10.3 → 0.11.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.
Files changed (94) hide show
  1. package/README.md +66 -0
  2. package/dist/application/capabilities.d.ts +6 -0
  3. package/dist/application/capabilities.js +37 -0
  4. package/dist/application/errors.d.ts +31 -0
  5. package/dist/application/errors.js +51 -0
  6. package/dist/application/fleet-query-service.d.ts +42 -0
  7. package/dist/application/fleet-query-service.js +188 -0
  8. package/dist/application/log-service.d.ts +28 -0
  9. package/dist/application/log-service.js +146 -0
  10. package/dist/application/role-command-service.d.ts +37 -0
  11. package/dist/application/role-command-service.js +82 -0
  12. package/dist/application/role-creation-service.d.ts +142 -0
  13. package/dist/application/role-creation-service.js +374 -0
  14. package/dist/application/role-repository.d.ts +20 -0
  15. package/dist/application/role-repository.js +168 -0
  16. package/dist/application/session-control.d.ts +55 -0
  17. package/dist/application/session-control.js +115 -0
  18. package/dist/application/types.d.ts +156 -0
  19. package/dist/application/types.js +1 -0
  20. package/dist/cli.js +341 -3
  21. package/dist/config.d.ts +9 -2
  22. package/dist/config.js +21 -5
  23. package/dist/creation.d.ts +11 -0
  24. package/dist/creation.js +22 -5
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +56 -0
  27. package/dist/duration.d.ts +5 -0
  28. package/dist/duration.js +20 -0
  29. package/dist/index.d.ts +10 -1
  30. package/dist/index.js +9 -1
  31. package/dist/ops.d.ts +16 -0
  32. package/dist/ops.js +112 -3
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.js +1 -0
  35. package/dist/resolved-plan.js +7 -0
  36. package/dist/runner.js +10 -2
  37. package/dist/session/control.d.ts +4 -2
  38. package/dist/session/control.js +45 -13
  39. package/dist/spawn.d.ts +20 -2
  40. package/dist/spawn.js +94 -24
  41. package/dist/supervisor/launchd.js +17 -0
  42. package/dist/supervisor/none.js +17 -0
  43. package/dist/supervisor/systemd.js +4 -0
  44. package/dist/supervisor/types.d.ts +6 -0
  45. package/dist/tmux.d.ts +2 -0
  46. package/dist/tmux.js +8 -0
  47. package/dist/watchdog/alerts.d.ts +34 -0
  48. package/dist/watchdog/alerts.js +78 -0
  49. package/dist/watchdog/briefing.d.ts +65 -0
  50. package/dist/watchdog/briefing.js +181 -0
  51. package/dist/watchdog/config.d.ts +49 -0
  52. package/dist/watchdog/config.js +114 -0
  53. package/dist/watchdog/query.d.ts +78 -0
  54. package/dist/watchdog/query.js +124 -0
  55. package/dist/watchdog/report.d.ts +53 -0
  56. package/dist/watchdog/report.js +126 -0
  57. package/dist/watchdog/run.d.ts +61 -0
  58. package/dist/watchdog/run.js +318 -0
  59. package/dist/watchdog/scheduler.d.ts +105 -0
  60. package/dist/watchdog/scheduler.js +244 -0
  61. package/dist/watchdog/service.d.ts +46 -0
  62. package/dist/watchdog/service.js +179 -0
  63. package/dist/watchdog/store.d.ts +85 -0
  64. package/dist/watchdog/store.js +226 -0
  65. package/dist/web/audit.d.ts +22 -0
  66. package/dist/web/audit.js +54 -0
  67. package/dist/web/auth.d.ts +61 -0
  68. package/dist/web/auth.js +186 -0
  69. package/dist/web/control.d.ts +14 -0
  70. package/dist/web/control.js +110 -0
  71. package/dist/web/device-store.d.ts +27 -0
  72. package/dist/web/device-store.js +155 -0
  73. package/dist/web/events.d.ts +15 -0
  74. package/dist/web/events.js +34 -0
  75. package/dist/web/lock.d.ts +5 -0
  76. package/dist/web/lock.js +69 -0
  77. package/dist/web/runtime.d.ts +12 -0
  78. package/dist/web/runtime.js +214 -0
  79. package/dist/web/server.d.ts +37 -0
  80. package/dist/web/server.js +279 -0
  81. package/dist/web/service.d.ts +42 -0
  82. package/dist/web/service.js +180 -0
  83. package/dist/web/terminal/bridge.d.ts +27 -0
  84. package/dist/web/terminal/bridge.js +317 -0
  85. package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
  86. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  87. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  88. package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
  89. package/dist/web-app/icons/ours-fleet.svg +4 -0
  90. package/dist/web-app/index.html +17 -0
  91. package/dist/web-app/manifest.webmanifest +15 -0
  92. package/dist/web-app/offline.html +18 -0
  93. package/dist/web-app/sw.js +51 -0
  94. package/package.json +26 -3
@@ -0,0 +1,181 @@
1
+ import { formatDuration } from '../duration.js';
2
+ /**
3
+ * The "bind or mint your identity" block shared by every watchdog-family briefing (the
4
+ * inspection contract and the notifier contract alike): call bindTool, and if the identity
5
+ * wasn't verified when configured, fall back to createTool on first run.
6
+ */
7
+ function bindSection(heading, wd, v, guarantee) {
8
+ const L = ['', heading];
9
+ L.push(`Call the **${v.bindTool}** tool with name "${wd.identity}" force=true (search the`);
10
+ L.push('deferred tool registry first if needed).');
11
+ if (guarantee === 'unverified') {
12
+ L.push(`- This identity was NOT verified when this watchdog was configured, so it may not`);
13
+ L.push(` exist yet. If binding reports no such identity, call **${v.createTool}** name`);
14
+ L.push(` "${wd.identity}" once to mint it on this first run, then you are bound. Re-binding`);
15
+ L.push(' your OWN identity is always allowed.');
16
+ }
17
+ else {
18
+ L.push(`- It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when this`);
19
+ L.push(' watchdog was configured, so binding should succeed. If it unexpectedly reports no');
20
+ L.push(` such identity, call **${v.createTool}** name "${wd.identity}" once and report the`);
21
+ L.push(' discrepancy — something removed it since.');
22
+ }
23
+ return L;
24
+ }
25
+ /**
26
+ * Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
27
+ * run): bind, observe-only rules, procedure, status vocabulary, evidence rules, alert rules,
28
+ * report schema, and — if the watchdog configures one — an appended prompt_file focus.
29
+ *
30
+ * The contract is fixed for every watchdog (spec §5: "The schema is fixed for every watchdog,
31
+ * including ones with a prompt_file; the override adds focus, never fields"), so every section
32
+ * below is unconditional except the identity-guarantee wording and the trailing focus append.
33
+ */
34
+ export function generateWatchdogBriefing(opts) {
35
+ const { wd, manifestPath, reportPath, vocabulary: v, identityGuarantee: guarantee } = opts;
36
+ const L = [];
37
+ // 1. Header + bind instructions (mirrors src/briefing.ts:43-57; mint on first run when the
38
+ // identity was not verified when this watchdog was created).
39
+ L.push(`# Watchdog run: ${wd.name} (ours identity: ${wd.identity})`, '');
40
+ L.push(`You are a fresh, one-shot inspection agent for the **${wd.name}** watchdog. You carry`);
41
+ L.push('no memory from any previous run — this run stands alone, and nothing you learn here');
42
+ L.push('persists past writing your report.');
43
+ L.push(...bindSection('## 1. Bind your identity', wd, v, guarantee));
44
+ // 2. Observe-only rules, verbatim (spec §1).
45
+ L.push('', '## 2. Observe-only — non-negotiable');
46
+ L.push('You observe and report. You never restart, stop, spawn or remove a role, never answer a ' +
47
+ 'pending permission, never edit a workspace or fleet.yaml, and never approve anything on the ' +
48
+ "owner's behalf. You do exactly three things: run read-only inspections, send at most one " +
49
+ 'message to your coordinator, and write one report.json.');
50
+ // 3. Procedure.
51
+ L.push('', '## 3. Procedure');
52
+ L.push(`1. Read \`${manifestPath}\` (watch.json). It is JSON with this shape:`);
53
+ L.push(' ```json');
54
+ L.push(' {');
55
+ L.push(` "watchdog": "${wd.name}", "run_id": "...", "coordinator": "${wd.coordinator}",`);
56
+ L.push(' "started_at": "...",');
57
+ L.push(' "roles": [{ "name": "Alice", "stateDir": "/home/.../agents/Alice" }],');
58
+ L.push(' "digest": { "cooldown_ms": 3600000, "open": [');
59
+ L.push(' { "role": "Alice", "status": "blocked", "since": "...", "realert_after": "..." }');
60
+ L.push(' ] }');
61
+ L.push(' }');
62
+ L.push(' ```');
63
+ L.push(' Use its `watchdog`, `run_id` and `started_at` values verbatim in your report — do not');
64
+ L.push(' invent your own.');
65
+ L.push('2. For each entry in `roles`, gather evidence — read-only, nothing written outside your');
66
+ L.push(' own report — from two sources:');
67
+ L.push(" - Files under that role's `stateDir`: `briefing.md`, `WORKLOG.md` (size/mtime),");
68
+ L.push(' `.worklog-rotation.json`, `.monitor-status`, `.monitor-owner`, `.restart-ledger.json`,');
69
+ L.push(' `.exit-status`, `.model-status`, `.isolation-degraded`.');
70
+ L.push(' - The sanctioned commands: `ours-fleet status <Role>`, `ours-fleet peek <Role>`,');
71
+ L.push(' `ours-fleet logs <Role>`, `ours-fleet config`. No other `ours-fleet` subcommand is');
72
+ L.push(' available to you, and none would be honoured even if invoked.');
73
+ L.push('3. If a command fails, times out, or the control plane is otherwise unanswerable for a');
74
+ L.push(' role, that role degrades to `unknown` — never guess `healthy`. `unknown` means the');
75
+ L.push(' evidence is insufficient, not that the role is fine.');
76
+ // 4. Status vocabulary — spec §4 definitions, verbatim where quoted.
77
+ L.push('', '## 4. Status vocabulary (fixed)');
78
+ L.push(`interval = ${formatDuration(wd.intervalMs)} — thresholds below are multiples of this.`);
79
+ L.push('');
80
+ L.push('- `healthy` — alive, on-briefing, recent progress.');
81
+ L.push('- `idle` — alive, nothing assigned or nothing to do. Not an anomaly.');
82
+ L.push('- `stale` = no worklog append and no console progress for ≥ 3 intervals.');
83
+ L.push('- `blocked` = waiting on a permission/prompt/modal longer than one interval.');
84
+ L.push('- `off_briefing` — activity contradicts the briefing (wrong repo, out-of-scope work,');
85
+ L.push(' ignored routine).');
86
+ L.push('- `unreachable` = configured and enabled but not running.');
87
+ L.push('- `unknown` — evidence insufficient (e.g. control plane unanswered); unknown is never ' +
88
+ 'reported as healthy.');
89
+ // 5. Evidence rules.
90
+ L.push('', '## 5. Evidence rules');
91
+ L.push('Every non-`healthy` entry carries a one-sentence `reason` and at most 3 `evidence`');
92
+ L.push('items (`{source, detail, observed_at}`), each ≤280 characters. Never include env');
93
+ L.push('values, tokens, file contents beyond quoted evidence, or a full pane dump — quote only');
94
+ L.push('the specific line(s) that support the finding.');
95
+ // 6. Alert rules — spec §5 rules 1-4, plus the digest-authoritative sentence, verbatim.
96
+ L.push('', '## 6. Alert rules');
97
+ L.push('1. Alert on a finding that is **new** (role+status not open) or **escalated** (e.g.');
98
+ L.push(' `stale` → `blocked`).');
99
+ L.push('2. Re-alert an unchanged open finding only after `alert_cooldown`.');
100
+ L.push('3. Send one **resolved** notice when an open finding returns to `healthy`.');
101
+ L.push('4. One message per run, listing all qualifying findings — never one message per role.');
102
+ L.push('');
103
+ L.push('Severity ordering, lowest to highest: healthy = idle (0) < unknown (1) < stale (2) < ' +
104
+ "blocked = unreachable (3) < off_briefing (4); a finding is escalated when its status ranks " +
105
+ "strictly higher than the digest entry's status.");
106
+ L.push('');
107
+ L.push('the suppression digest in watch.json is authoritative: alert only on findings that are new ' +
108
+ '(role not in digest.open), escalated (higher severity than the digest entry), or past ' +
109
+ 'realert_after; send one resolved notice when a digest-open role is now healthy; ONE message ' +
110
+ `per run listing all qualifying findings, sent with **${v.sendTool}** to "${wd.coordinator}"; ` +
111
+ 'set alerted:true and add an alerts[] entry for each. If nothing qualifies, send nothing.');
112
+ // 7. Report schema — spec §5 example, plus the write-order sentence, verbatim.
113
+ L.push('', '## 7. Write your report');
114
+ L.push('`report.json` has this fixed schema (`schema_version` is always the literal `1`;');
115
+ L.push('`status` is `ok` when every role is `healthy`/`idle`, `anomalies` when any role is not,');
116
+ L.push('and you never set `error` yourself — that field is for the scheduler):');
117
+ L.push('```json');
118
+ L.push('{');
119
+ L.push(' "schema_version": 1,');
120
+ L.push(` "watchdog": "${wd.name}",`);
121
+ L.push(' "run_id": "<run_id from watch.json>",');
122
+ L.push(' "started_at": "<started_at from watch.json>",');
123
+ L.push(' "finished_at": "2026-07-31T11:51:12Z",');
124
+ L.push(' "status": "anomalies",');
125
+ L.push(' "summary": { "checked": 4, "healthy": 2, "idle": 1, "anomalies": 1 },');
126
+ L.push(' "roles": [');
127
+ L.push(' { "role": "Bob", "status": "healthy" },');
128
+ L.push(' {');
129
+ L.push(' "role": "Alice",');
130
+ L.push(' "status": "blocked",');
131
+ L.push(' "reason": "Waiting on a trust dialog for 34 minutes with no progress.",');
132
+ L.push(' "evidence": [');
133
+ L.push(' { "source": "status", "detail": "session.readiness=awaiting_permission",');
134
+ L.push(' "observed_at": "2026-07-31T11:50:31Z" },');
135
+ L.push(' { "source": "worklog", "detail": "WORKLOG.md unchanged since 11:16Z",');
136
+ L.push(' "observed_at": "2026-07-31T11:50:33Z" }');
137
+ L.push(' ],');
138
+ L.push(' "alerted": true');
139
+ L.push(' }');
140
+ L.push(' ],');
141
+ L.push(' "alerts": [');
142
+ L.push(' { "role": "Alice", "code": "blocked", "coordinator": "FleetCoordinator",');
143
+ L.push(' "sent_at": "2026-07-31T11:51:10Z" }');
144
+ L.push(' ],');
145
+ L.push(' "error": null');
146
+ L.push('}');
147
+ L.push('```');
148
+ L.push(`Write the report to \`${reportPath}\` (your cwd) as your LAST action — writing it ends ` +
149
+ 'the run. Send your coordinator message BEFORE writing report.json.');
150
+ // 8. Appended prompt_file focus — extra emphasis only, contract stays non-negotiable.
151
+ if (opts.promptFocus) {
152
+ L.push('', '## Extra focus (from prompt_file)', '');
153
+ L.push(opts.promptFocus);
154
+ L.push('', 'The extra focus adds emphasis only. The report schema, the observe-only rules and the ' +
155
+ 'alert rules above are unchanged and non-negotiable.');
156
+ }
157
+ return L.join('\n') + '\n';
158
+ }
159
+ /**
160
+ * Render a scheduler-alert notifier run's entire contract: a minimal one-shot agent whose sole
161
+ * job is to bind its identity, send one exact message to the coordinator, and write `sent.json`
162
+ * as its completion sentinel. Used only for scheduler-level alerts (e.g. held-down) — the fleet
163
+ * process itself cannot send ours messages (deviation 4), so this is how it delegates the send.
164
+ * Unlike `generateWatchdogBriefing`, there is no report.json, no manifest, and no inspection.
165
+ */
166
+ export function generateNotifierBriefing(opts) {
167
+ const { wd, vocabulary: v, identityGuarantee: guarantee, text } = opts;
168
+ const L = [];
169
+ L.push(`# Watchdog alert notifier: ${wd.name} (ours identity: ${wd.identity})`, '');
170
+ L.push('You are a minimal one-shot notifier agent. You carry no memory from any previous run —');
171
+ L.push('this run stands alone, and it has exactly one job: relay one message, then stop.');
172
+ L.push(...bindSection('## Bind your identity', wd, v, guarantee));
173
+ L.push('', '## Send the message');
174
+ L.push(`Send exactly this message to ${wd.coordinator} using **${v.sendTool}**:`);
175
+ L.push('');
176
+ L.push(text);
177
+ L.push('', '## Finish');
178
+ L.push(`Then write a file \`sent.json\` containing \`{"sent": true}\` in your cwd as your LAST ` +
179
+ 'action — it ends the run. Do nothing else: no inspection, no report.json, no other messages.');
180
+ return L.join('\n') + '\n';
181
+ }
@@ -0,0 +1,49 @@
1
+ import type { FleetConfig, ResolvedRole, SessionBackendId } from '../config.js';
2
+ export interface WatchdogConfig {
3
+ coordinator?: string;
4
+ enabled?: boolean;
5
+ interval?: string;
6
+ watch?: string[];
7
+ harness?: string;
8
+ model?: string | null;
9
+ session?: string;
10
+ identity?: string;
11
+ timeout?: string;
12
+ keep_reports?: number;
13
+ alert_cooldown?: string;
14
+ prompt_file?: string;
15
+ }
16
+ export interface ResolvedWatchdog {
17
+ name: string;
18
+ coordinator: string;
19
+ enabled: boolean;
20
+ intervalMs: number;
21
+ watch: string[];
22
+ harness: string;
23
+ session: SessionBackendId;
24
+ model?: string;
25
+ identity: string;
26
+ timeoutMs: number;
27
+ keepReports: number;
28
+ alertCooldownMs: number;
29
+ promptFile?: string;
30
+ sourceFile: string;
31
+ }
32
+ export declare const WATCHDOG_DEFAULT_INTERVAL_MS = 600000;
33
+ export declare const WATCHDOG_MIN_INTERVAL_MS = 60000;
34
+ export declare const WATCHDOG_DEFAULT_TIMEOUT_MS = 300000;
35
+ export declare const WATCHDOG_DEFAULT_KEEP_REPORTS = 50;
36
+ export declare const WATCHDOG_DEFAULT_COOLDOWN_MS = 3600000;
37
+ export declare function resolveWatchdogs(baseDoc: Record<string, unknown>, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>, defaults: Record<string, unknown>): ResolvedWatchdog[];
38
+ /**
39
+ * Split `restart`'s argument names into watchdogs (a release, handled
40
+ * directly) and roles (handed to restartRoles). Watchdog names can't collide
41
+ * with role names (config validation guarantees dispatch is unambiguous), so
42
+ * a name matching a configured watchdog is always a release, never a role
43
+ * restart. Order is preserved within each bucket; unknown names fall through
44
+ * to roleNames — findRole errors later, same as today.
45
+ */
46
+ export declare function partitionRestartNames(cfg: FleetConfig, names: string[]): {
47
+ watchdogNames: string[];
48
+ roleNames: string[];
49
+ };
@@ -0,0 +1,114 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ import { parseDuration } from '../duration.js';
4
+ import { ConfigError, ROLE_NAME_RE, resolveRoleModel } from '../config.js';
5
+ const WATCHDOG_KEYS = [
6
+ 'coordinator', 'enabled', 'interval', 'watch', 'harness', 'model', 'session',
7
+ 'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file',
8
+ ];
9
+ export const WATCHDOG_DEFAULT_INTERVAL_MS = 600_000;
10
+ export const WATCHDOG_MIN_INTERVAL_MS = 60_000;
11
+ export const WATCHDOG_DEFAULT_TIMEOUT_MS = 300_000;
12
+ export const WATCHDOG_DEFAULT_KEEP_REPORTS = 50;
13
+ export const WATCHDOG_DEFAULT_COOLDOWN_MS = 3_600_000;
14
+ function deepSub(v, vars) {
15
+ if (typeof v === 'string')
16
+ return v.replace(/\$\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
17
+ if (Array.isArray(v))
18
+ return v.map(x => deepSub(x, vars));
19
+ if (v && typeof v === 'object')
20
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepSub(x, vars)]));
21
+ return v;
22
+ }
23
+ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
24
+ const block = baseDoc.watchdogs;
25
+ if (block === undefined || block === null)
26
+ return [];
27
+ if (typeof block !== 'object' || Array.isArray(block))
28
+ throw new ConfigError(`${baseFile}: watchdogs: must be a map`);
29
+ const roleNames = new Set(roles.map(r => r.name));
30
+ const roleIdentities = new Set(roles.map(r => r.identity));
31
+ // Tracks identity -> owning watchdog name across entries (final review #5):
32
+ // two watchdogs declaring the same identity would otherwise share a temp
33
+ // dir, tmux session, and run lock with no error until they collide at
34
+ // runtime.
35
+ const watchdogIdentities = new Map();
36
+ const out = [];
37
+ for (const [name, rawEntry] of Object.entries(block)) {
38
+ const where = `${baseFile}: watchdog '${name}'`;
39
+ if (!ROLE_NAME_RE.test(name))
40
+ throw new ConfigError(`${baseFile}: invalid watchdog name '${name}' (allowed: [A-Za-z0-9_-])`);
41
+ if (roleNames.has(name))
42
+ throw new ConfigError(`${baseFile}: watchdog '${name}' collides with a role name`);
43
+ const w = deepSub(rawEntry ?? {}, vars);
44
+ const bad = Object.keys(w).filter(k => !WATCHDOG_KEYS.includes(k));
45
+ if (bad.length)
46
+ throw new ConfigError(`${where} has unknown key(s) ${bad.join(', ')}; allowed: ${WATCHDOG_KEYS.join(', ')}`);
47
+ if (typeof w.coordinator !== 'string' || !w.coordinator.trim())
48
+ throw new ConfigError(`${where}: coordinator is required`);
49
+ const watch = w.watch === undefined ? roles.map(r => r.name) : w.watch;
50
+ if (!Array.isArray(watch) || watch.some(x => typeof x !== 'string'))
51
+ throw new ConfigError(`${where}: watch must be a list of role names`);
52
+ for (const r of watch)
53
+ if (!roleNames.has(r))
54
+ throw new ConfigError(`${where}: watch names missing role '${r}'`);
55
+ const identity = w.identity ?? `Watchdog-${name}`;
56
+ if (roleNames.has(identity) || roleIdentities.has(identity))
57
+ throw new ConfigError(`${where}: identity '${identity}' collides with a role name or identity`);
58
+ const collidingWatchdog = watchdogIdentities.get(identity);
59
+ if (collidingWatchdog !== undefined)
60
+ throw new ConfigError(`${where}: identity '${identity}' collides with watchdog '${collidingWatchdog}'`);
61
+ watchdogIdentities.set(identity, name);
62
+ const harness = w.harness ?? defaults.harness ?? 'claude-code';
63
+ const sessionRaw = w.session ?? defaults.session ?? 'tmux';
64
+ if (sessionRaw !== 'tmux' && sessionRaw !== 'acp')
65
+ throw new ConfigError(`${where}: session must be 'tmux' or 'acp'`);
66
+ if (w.prompt_file !== undefined) {
67
+ if (typeof w.prompt_file !== 'string' || !isAbsolute(w.prompt_file))
68
+ throw new ConfigError(`${where}: prompt_file must be an absolute path`);
69
+ if (!existsSync(w.prompt_file))
70
+ throw new ConfigError(`${where}: prompt_file not found: ${w.prompt_file}`);
71
+ }
72
+ const dur = (v, key, fallback, minMs) => {
73
+ if (v === undefined)
74
+ return fallback;
75
+ try {
76
+ return parseDuration(v, { name: key, minMs });
77
+ }
78
+ catch (e) {
79
+ throw new ConfigError(`${where}: ${e.message}`);
80
+ }
81
+ };
82
+ const keepReports = w.keep_reports ?? WATCHDOG_DEFAULT_KEEP_REPORTS;
83
+ if (!Number.isInteger(keepReports) || keepReports < 1)
84
+ throw new ConfigError(`${where}: keep_reports must be a positive integer`);
85
+ if (w.enabled !== undefined && typeof w.enabled !== 'boolean')
86
+ throw new ConfigError(`${where}: enabled must be true or false`);
87
+ out.push({
88
+ name, coordinator: w.coordinator.trim(),
89
+ enabled: w.enabled ?? true,
90
+ intervalMs: dur(w.interval, 'interval', WATCHDOG_DEFAULT_INTERVAL_MS, WATCHDOG_MIN_INTERVAL_MS),
91
+ watch, harness, session: sessionRaw,
92
+ model: resolveRoleModel(w.model, w.harness, defaults),
93
+ identity,
94
+ timeoutMs: dur(w.timeout, 'timeout', WATCHDOG_DEFAULT_TIMEOUT_MS),
95
+ keepReports,
96
+ alertCooldownMs: dur(w.alert_cooldown, 'alert_cooldown', WATCHDOG_DEFAULT_COOLDOWN_MS),
97
+ promptFile: w.prompt_file, sourceFile: baseFile,
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * Split `restart`'s argument names into watchdogs (a release, handled
104
+ * directly) and roles (handed to restartRoles). Watchdog names can't collide
105
+ * with role names (config validation guarantees dispatch is unambiguous), so
106
+ * a name matching a configured watchdog is always a release, never a role
107
+ * restart. Order is preserved within each bucket; unknown names fall through
108
+ * to roleNames — findRole errors later, same as today.
109
+ */
110
+ export function partitionRestartNames(cfg, names) {
111
+ const watchdogNames = names.filter(n => cfg.watchdogs.some(w => w.name === n));
112
+ const roleNames = names.filter(n => !watchdogNames.includes(n));
113
+ return { watchdogNames, roleNames };
114
+ }
@@ -0,0 +1,78 @@
1
+ import { type FleetConfig } from '../config.js';
2
+ import type { WatchdogReport, WatchdogRoleStatus } from './report.js';
3
+ import { type RunListEntry } from './store.js';
4
+ export interface WatchdogRoleFinding {
5
+ watchdog: string;
6
+ status: WatchdogRoleStatus;
7
+ reason: string;
8
+ }
9
+ /**
10
+ * Needs-attention integration (Task 19): worst current finding per role across
11
+ * every configured watchdog, for FleetQueryService.status() to fold into a
12
+ * role's problems. An `error`-status report carries no role evidence (the run
13
+ * itself failed) so it's skipped outright, matching alerts.ts's
14
+ * reconcileLedger rule. Healthy/idle findings (rank 0) never surface here —
15
+ * only actionable anomalies do. "Worst" is decided by WATCHDOG_STATUS_RANK;
16
+ * ties keep whichever watchdog was seen first. Takes `latestReport` as a
17
+ * parameter (rather than importing store.ts directly) so it stays a pure,
18
+ * disk-free function for unit testing; runtime.ts wires the real store.
19
+ *
20
+ * Skips disabled watchdogs (finding #5): a watchdog turned off in config
21
+ * still has its last stored report sitting on disk, and without this check
22
+ * that stale report's findings would pin roles in "Needs attention" forever
23
+ * — an operator disabling a noisy/broken watchdog has no way to make the
24
+ * findings it already produced go away.
25
+ */
26
+ export declare function buildWatchdogFindings(cfg: {
27
+ watchdogs: Array<{
28
+ name: string;
29
+ enabled: boolean;
30
+ }>;
31
+ }, latestReport: (name: string) => WatchdogReport | undefined): Map<string, WatchdogRoleFinding>;
32
+ /**
33
+ * Memoizes a findings-map builder with a short TTL (mirrors runtime.ts's
34
+ * cachedConfigProvider pattern). status() calls its injected watchdogFindings
35
+ * provider once per role, so an unmemoized thunk turns a single list() sweep
36
+ * into O(roles x watchdogs) disk reads, repeated every 1-3s of console
37
+ * polling. `now` is injectable (defaults to Date.now) so the TTL boundary is
38
+ * unit-testable without real timers.
39
+ */
40
+ export declare function cachedWatchdogFindingsProvider(build: () => Map<string, WatchdogRoleFinding>, ttlMs: number, now?: () => number): () => Map<string, WatchdogRoleFinding>;
41
+ export interface WatchdogSummary {
42
+ name: string;
43
+ enabled: boolean;
44
+ heldDown: boolean;
45
+ heldSince: string | null;
46
+ intervalMs: number;
47
+ coordinator: string;
48
+ watch: string[];
49
+ lastRunAt: string | null;
50
+ nextRunAt: string | null;
51
+ latest: RunListEntry | null;
52
+ }
53
+ /**
54
+ * Read-only view over watchdog config + on-disk scheduler state + stored
55
+ * reports, for the authenticated web console. Never mutates: `list()` reads
56
+ * config plus `readSchedulerState`/`listRuns` (both already tolerant of a
57
+ * corrupt/missing state.json or an empty/nonexistent reports dir, per
58
+ * store.ts and scheduler.ts), and `reports()`/`report()` reject unknown
59
+ * watchdog names before touching disk.
60
+ */
61
+ export declare class WatchdogQueryService {
62
+ private readonly cfgProvider;
63
+ constructor(cfgProvider: () => FleetConfig);
64
+ list(): {
65
+ watchdogs: WatchdogSummary[];
66
+ };
67
+ reports(name: string, limit?: number): {
68
+ runs: RunListEntry[];
69
+ };
70
+ report(name: string, runId: string): WatchdogReport;
71
+ /**
72
+ * Known means: configured today, OR a store directory already exists for a
73
+ * watchdog that used to be configured (its history should stay readable).
74
+ * The regex check runs before any filesystem lookup so a hostile `name`
75
+ * (path separators, '..', etc.) can never reach `join(watchdogsRoot(), name)`.
76
+ */
77
+ private requireKnown;
78
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { FleetError } from '../application/errors.js';
4
+ import { ROLE_NAME_RE } from '../config.js';
5
+ import { watchdogsRoot } from '../paths.js';
6
+ import { WATCHDOG_STATUS_RANK } from './alerts.js';
7
+ import { readSchedulerState } from './scheduler.js';
8
+ import { listRuns, readReport } from './store.js';
9
+ /**
10
+ * Needs-attention integration (Task 19): worst current finding per role across
11
+ * every configured watchdog, for FleetQueryService.status() to fold into a
12
+ * role's problems. An `error`-status report carries no role evidence (the run
13
+ * itself failed) so it's skipped outright, matching alerts.ts's
14
+ * reconcileLedger rule. Healthy/idle findings (rank 0) never surface here —
15
+ * only actionable anomalies do. "Worst" is decided by WATCHDOG_STATUS_RANK;
16
+ * ties keep whichever watchdog was seen first. Takes `latestReport` as a
17
+ * parameter (rather than importing store.ts directly) so it stays a pure,
18
+ * disk-free function for unit testing; runtime.ts wires the real store.
19
+ *
20
+ * Skips disabled watchdogs (finding #5): a watchdog turned off in config
21
+ * still has its last stored report sitting on disk, and without this check
22
+ * that stale report's findings would pin roles in "Needs attention" forever
23
+ * — an operator disabling a noisy/broken watchdog has no way to make the
24
+ * findings it already produced go away.
25
+ */
26
+ export function buildWatchdogFindings(cfg, latestReport) {
27
+ const findings = new Map();
28
+ for (const wd of cfg.watchdogs) {
29
+ if (!wd.enabled)
30
+ continue;
31
+ const report = latestReport(wd.name);
32
+ if (!report || report.status === 'error')
33
+ continue;
34
+ for (const finding of report.roles) {
35
+ const rank = WATCHDOG_STATUS_RANK[finding.status];
36
+ if (rank <= 0)
37
+ continue;
38
+ const existing = findings.get(finding.role);
39
+ if (existing && WATCHDOG_STATUS_RANK[existing.status] >= rank)
40
+ continue;
41
+ findings.set(finding.role, { watchdog: wd.name, status: finding.status, reason: finding.reason ?? '' });
42
+ }
43
+ }
44
+ return findings;
45
+ }
46
+ /**
47
+ * Memoizes a findings-map builder with a short TTL (mirrors runtime.ts's
48
+ * cachedConfigProvider pattern). status() calls its injected watchdogFindings
49
+ * provider once per role, so an unmemoized thunk turns a single list() sweep
50
+ * into O(roles x watchdogs) disk reads, repeated every 1-3s of console
51
+ * polling. `now` is injectable (defaults to Date.now) so the TTL boundary is
52
+ * unit-testable without real timers.
53
+ */
54
+ export function cachedWatchdogFindingsProvider(build, ttlMs, now = Date.now) {
55
+ let cached;
56
+ return () => {
57
+ const at = now();
58
+ if (!cached || at - cached.at >= ttlMs)
59
+ cached = { at, value: build() };
60
+ return cached.value;
61
+ };
62
+ }
63
+ const DEFAULT_REPORTS_LIMIT = 50;
64
+ const MAX_REPORTS_LIMIT = 500;
65
+ /**
66
+ * Read-only view over watchdog config + on-disk scheduler state + stored
67
+ * reports, for the authenticated web console. Never mutates: `list()` reads
68
+ * config plus `readSchedulerState`/`listRuns` (both already tolerant of a
69
+ * corrupt/missing state.json or an empty/nonexistent reports dir, per
70
+ * store.ts and scheduler.ts), and `reports()`/`report()` reject unknown
71
+ * watchdog names before touching disk.
72
+ */
73
+ export class WatchdogQueryService {
74
+ cfgProvider;
75
+ constructor(cfgProvider) {
76
+ this.cfgProvider = cfgProvider;
77
+ }
78
+ list() {
79
+ const cfg = this.cfgProvider();
80
+ const watchdogs = cfg.watchdogs.map((wd) => {
81
+ const state = readSchedulerState(wd.name);
82
+ const [latest = null] = listRuns(wd.name);
83
+ return {
84
+ name: wd.name, enabled: wd.enabled, heldDown: state.heldDown, heldSince: state.heldSince ?? null,
85
+ intervalMs: wd.intervalMs,
86
+ coordinator: wd.coordinator, watch: wd.watch,
87
+ lastRunAt: state.lastRunAt ?? null, nextRunAt: state.nextRunAt ?? null,
88
+ latest,
89
+ };
90
+ });
91
+ return { watchdogs };
92
+ }
93
+ reports(name, limit = DEFAULT_REPORTS_LIMIT) {
94
+ this.requireKnown(name);
95
+ const n = Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_REPORTS_LIMIT;
96
+ const clamped = Math.min(Math.max(n, 1), MAX_REPORTS_LIMIT);
97
+ return { runs: listRuns(name).slice(0, clamped) };
98
+ }
99
+ report(name, runId) {
100
+ this.requireKnown(name);
101
+ // readReport parses the stored file with JSON.parse and returns the parsed object;
102
+ // fastify re-serializes it for the HTTP response, so what's actually verifiable (and
103
+ // what the tests assert) is deep equality with the stored JSON, not byte-for-byte
104
+ // identity of the response bytes against the file on disk.
105
+ const found = readReport(name, runId);
106
+ if (!found)
107
+ throw new FleetError('role_not_found', `no such report '${runId}' for watchdog '${name}'`);
108
+ return found;
109
+ }
110
+ /**
111
+ * Known means: configured today, OR a store directory already exists for a
112
+ * watchdog that used to be configured (its history should stay readable).
113
+ * The regex check runs before any filesystem lookup so a hostile `name`
114
+ * (path separators, '..', etc.) can never reach `join(watchdogsRoot(), name)`.
115
+ */
116
+ requireKnown(name) {
117
+ const cfg = this.cfgProvider();
118
+ if (cfg.watchdogs.some(wd => wd.name === name))
119
+ return;
120
+ if (ROLE_NAME_RE.test(name) && existsSync(join(watchdogsRoot(), name)))
121
+ return;
122
+ throw new FleetError('role_not_found', `no such watchdog '${name}'`);
123
+ }
124
+ }
@@ -0,0 +1,53 @@
1
+ export declare const WATCHDOG_ROLE_STATUSES: readonly ["healthy", "idle", "stale", "blocked", "off_briefing", "unreachable", "unknown"];
2
+ export type WatchdogRoleStatus = typeof WATCHDOG_ROLE_STATUSES[number];
3
+ export interface WatchdogEvidence {
4
+ source: string;
5
+ detail: string;
6
+ observed_at: string;
7
+ }
8
+ export interface WatchdogFinding {
9
+ role: string;
10
+ status: WatchdogRoleStatus;
11
+ reason?: string;
12
+ evidence?: WatchdogEvidence[];
13
+ alerted?: boolean;
14
+ }
15
+ export interface WatchdogAlert {
16
+ role: string;
17
+ code: string;
18
+ coordinator: string;
19
+ sent_at: string;
20
+ }
21
+ export type WatchdogReportStatus = 'ok' | 'anomalies' | 'error';
22
+ export interface WatchdogReport {
23
+ schema_version: 1;
24
+ watchdog: string;
25
+ run_id: string;
26
+ started_at: string;
27
+ finished_at: string;
28
+ status: WatchdogReportStatus;
29
+ summary: {
30
+ checked: number;
31
+ healthy: number;
32
+ idle: number;
33
+ anomalies: number;
34
+ };
35
+ roles: WatchdogFinding[];
36
+ alerts: WatchdogAlert[];
37
+ error: string | null;
38
+ }
39
+ export declare function validateWatchdogReport(v: unknown): string[];
40
+ export declare function normalizeWatchdogReport(r: WatchdogReport, ctx: {
41
+ watchdog: string;
42
+ run_id: string;
43
+ }): WatchdogReport;
44
+ export declare function errorReport(ctx: {
45
+ watchdog: string;
46
+ run_id: string;
47
+ started_at: string;
48
+ finished_at: string;
49
+ error: string;
50
+ tail?: string;
51
+ }): WatchdogReport & {
52
+ tail?: string;
53
+ };