@ours.network/fleet 0.10.4 → 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 (42) hide show
  1. package/dist/application/fleet-query-service.d.ts +11 -0
  2. package/dist/application/fleet-query-service.js +9 -1
  3. package/dist/cli.js +200 -3
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +3 -1
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +22 -0
  8. package/dist/duration.d.ts +5 -0
  9. package/dist/duration.js +20 -0
  10. package/dist/ops.d.ts +16 -0
  11. package/dist/ops.js +112 -3
  12. package/dist/paths.d.ts +1 -0
  13. package/dist/paths.js +1 -0
  14. package/dist/resolved-plan.js +7 -0
  15. package/dist/watchdog/alerts.d.ts +34 -0
  16. package/dist/watchdog/alerts.js +78 -0
  17. package/dist/watchdog/briefing.d.ts +65 -0
  18. package/dist/watchdog/briefing.js +181 -0
  19. package/dist/watchdog/config.d.ts +49 -0
  20. package/dist/watchdog/config.js +114 -0
  21. package/dist/watchdog/query.d.ts +78 -0
  22. package/dist/watchdog/query.js +124 -0
  23. package/dist/watchdog/report.d.ts +53 -0
  24. package/dist/watchdog/report.js +126 -0
  25. package/dist/watchdog/run.d.ts +61 -0
  26. package/dist/watchdog/run.js +318 -0
  27. package/dist/watchdog/scheduler.d.ts +105 -0
  28. package/dist/watchdog/scheduler.js +244 -0
  29. package/dist/watchdog/service.d.ts +46 -0
  30. package/dist/watchdog/service.js +179 -0
  31. package/dist/watchdog/store.d.ts +85 -0
  32. package/dist/watchdog/store.js +226 -0
  33. package/dist/web/runtime.js +46 -2
  34. package/dist/web/server.d.ts +2 -0
  35. package/dist/web/server.js +18 -0
  36. package/dist/web-app/assets/{TerminalView-DcImdrI1.js → TerminalView-BvcIkuIF.js} +1 -1
  37. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  38. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  39. package/dist/web-app/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/web-app/assets/index-BokQN1Ao.js +0 -9
  42. package/dist/web-app/assets/index-lAXzaOZM.css +0 -1
@@ -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
+ };
@@ -0,0 +1,126 @@
1
+ export const WATCHDOG_ROLE_STATUSES = ['healthy', 'idle', 'stale', 'blocked', 'off_briefing', 'unreachable', 'unknown'];
2
+ const SUMMARY_KEYS = ['checked', 'healthy', 'idle', 'anomalies'];
3
+ function isPlainObject(v) {
4
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ }
6
+ export function validateWatchdogReport(v) {
7
+ const errors = [];
8
+ if (!isPlainObject(v)) {
9
+ errors.push('report: expected an object');
10
+ return errors;
11
+ }
12
+ const r = v;
13
+ if (r.schema_version !== 1)
14
+ errors.push('schema_version: expected 1');
15
+ for (const key of ['watchdog', 'run_id', 'started_at', 'finished_at'])
16
+ if (typeof r[key] !== 'string')
17
+ errors.push(`${key}: expected a string`);
18
+ if (r.status !== 'ok' && r.status !== 'anomalies' && r.status !== 'error')
19
+ errors.push(`status: expected ok|anomalies|error, got '${String(r.status)}'`);
20
+ if (!isPlainObject(r.summary)) {
21
+ errors.push('summary: expected an object');
22
+ }
23
+ else {
24
+ for (const key of SUMMARY_KEYS)
25
+ if (typeof r.summary[key] !== 'number')
26
+ errors.push(`summary.${key}: expected a number`);
27
+ }
28
+ if (!Array.isArray(r.roles)) {
29
+ errors.push('roles: expected an array');
30
+ }
31
+ else {
32
+ r.roles.forEach((role, i) => {
33
+ if (!isPlainObject(role)) {
34
+ errors.push(`roles[${i}]: expected an object`);
35
+ return;
36
+ }
37
+ if (typeof role.role !== 'string')
38
+ errors.push(`roles[${i}].role: expected a string`);
39
+ if (!WATCHDOG_ROLE_STATUSES.includes(role.status))
40
+ errors.push(`roles[${i}].status: expected one of ${WATCHDOG_ROLE_STATUSES.join('|')}, got '${String(role.status)}'`);
41
+ if (role.status !== 'healthy' && role.status !== 'idle' && !role.reason)
42
+ errors.push(`roles[${i}]: non-healthy finding requires a reason`);
43
+ if (role.reason !== undefined && typeof role.reason !== 'string')
44
+ errors.push(`roles[${i}].reason: expected a string`);
45
+ if (role.evidence !== undefined) {
46
+ if (!Array.isArray(role.evidence)) {
47
+ errors.push(`roles[${i}].evidence: expected an array`);
48
+ }
49
+ else {
50
+ role.evidence.forEach((ev, j) => {
51
+ if (!isPlainObject(ev)) {
52
+ errors.push(`roles[${i}].evidence[${j}]: expected an object`);
53
+ return;
54
+ }
55
+ for (const key of ['source', 'detail', 'observed_at'])
56
+ if (typeof ev[key] !== 'string')
57
+ errors.push(`roles[${i}].evidence[${j}].${key}: expected a string`);
58
+ });
59
+ }
60
+ }
61
+ });
62
+ }
63
+ if (!Array.isArray(r.alerts)) {
64
+ errors.push('alerts: expected an array');
65
+ }
66
+ else {
67
+ r.alerts.forEach((alert, i) => {
68
+ if (!isPlainObject(alert)) {
69
+ errors.push(`alerts[${i}]: expected an object`);
70
+ return;
71
+ }
72
+ for (const key of ['role', 'code', 'coordinator', 'sent_at'])
73
+ if (typeof alert[key] !== 'string')
74
+ errors.push(`alerts[${i}].${key}: expected a string`);
75
+ });
76
+ }
77
+ if (r.error !== null && typeof r.error !== 'string')
78
+ errors.push('error: expected string or null');
79
+ return errors;
80
+ }
81
+ const cleanEvidence = (value, max = 280) => value.replace(/[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim().slice(0, max);
82
+ export function normalizeWatchdogReport(r, ctx) {
83
+ const copy = JSON.parse(JSON.stringify(r));
84
+ copy.watchdog = ctx.watchdog;
85
+ copy.run_id = ctx.run_id;
86
+ copy.roles = copy.roles.map(role => {
87
+ // Rebuilt from ONLY the known fields (final review #6a) — the old
88
+ // `{ ...role }` spread let any unknown per-finding key (e.g. an agent
89
+ // dumping a huge `pane_dump`) ride unbounded into the store. Report-level
90
+ // extras (tail/isolation) are untouched here and stay tolerated; this is
91
+ // a per-finding allowlist only.
92
+ const next = { role: role.role, status: role.status };
93
+ if (role.reason !== undefined)
94
+ next.reason = cleanEvidence(role.reason);
95
+ if (role.evidence !== undefined) {
96
+ next.evidence = role.evidence.slice(0, 3).map(ev => ({
97
+ source: cleanEvidence(ev.source),
98
+ detail: cleanEvidence(ev.detail),
99
+ observed_at: cleanEvidence(ev.observed_at),
100
+ }));
101
+ }
102
+ if (role.alerted !== undefined)
103
+ next.alerted = Boolean(role.alerted);
104
+ return next;
105
+ });
106
+ return copy;
107
+ }
108
+ export function errorReport(ctx) {
109
+ return {
110
+ schema_version: 1,
111
+ watchdog: ctx.watchdog,
112
+ run_id: ctx.run_id,
113
+ started_at: ctx.started_at,
114
+ finished_at: ctx.finished_at,
115
+ status: 'error',
116
+ summary: { checked: 0, healthy: 0, idle: 0, anomalies: 0 },
117
+ roles: [],
118
+ alerts: [],
119
+ // Scheduler-built error strings can embed raw agent-controlled bytes
120
+ // (JSON.parse messages, validator interpolations of agent-supplied
121
+ // values) and are stored+printed as-is elsewhere — clean them the same
122
+ // way finding-level text is cleaned (final review #6b).
123
+ error: cleanEvidence(ctx.error),
124
+ ...(ctx.tail !== undefined ? { tail: ctx.tail.slice(-4096) } : {}),
125
+ };
126
+ }
@@ -0,0 +1,61 @@
1
+ import type { ResolvedWatchdog } from './config.js';
2
+ import type { WatchdogReport } from './report.js';
3
+ import { type IdentityProvisioner } from '../creation.js';
4
+ import { type FleetConfig } from '../config.js';
5
+ /** A minimal handle over the launched child: kill it, or await its natural exit. */
6
+ export interface WatchdogChildHandle {
7
+ kill(): void;
8
+ exited: Promise<void>;
9
+ }
10
+ export interface WatchdogRunDeps {
11
+ binPath: string;
12
+ log(line: string): void;
13
+ now?(): Date;
14
+ sleep?(ms: number): Promise<void>;
15
+ identityProvisioner?: IdentityProvisioner;
16
+ /**
17
+ * Injectable child launcher for tests. Default: spawn
18
+ * `node <binPath> _run-watchdog <roleName>` detached:false, stdio to
19
+ * `<runDir>/run.log`. Returns kill() and an exited promise.
20
+ */
21
+ launchChild?(binPath: string, roleName: string, runDir: string): WatchdogChildHandle;
22
+ /** Pre-loaded config (defaults inheritance). Falls back to `loadConfig()`. */
23
+ cfg?: FleetConfig;
24
+ }
25
+ export interface WatchdogRunOutcome {
26
+ report: WatchdogReport;
27
+ storedPath: string;
28
+ }
29
+ /**
30
+ * Run one watchdog agent end-to-end in a clean-context temp state dir: provision
31
+ * identity, materialize the run's contract (briefing/manifest/role snapshot),
32
+ * launch the child, enforce a deadline against report.json as the completion
33
+ * sentinel, harvest whatever it wrote (or a synthetic error report if it
34
+ * didn't), store the result, and always clean up the temp dir.
35
+ *
36
+ * The scheduler's run-lock guarantees only one run per watchdog at a time;
37
+ * this function itself takes no lock (Task 8).
38
+ */
39
+ export declare function executeWatchdogRun(wd: ResolvedWatchdog, deps: WatchdogRunDeps): Promise<WatchdogRunOutcome>;
40
+ /**
41
+ * Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
42
+ * under the watchdog's own identity whose entire mission is: bind identity, send `text` to
43
+ * `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
44
+ * (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
45
+ * other way still gets a message out.
46
+ *
47
+ * Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
48
+ * launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
49
+ * watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
50
+ * deadline applies regardless of `wd.timeoutMs`.
51
+ *
52
+ * Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
53
+ * and releases the run lock itself — a notifier run shares the same temp dir name
54
+ * (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
55
+ * to acquire is logged and treated as a no-op: the held-down state already means no regular runs
56
+ * are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
57
+ * warning and swallowed — a notifier failure must never throw into the scheduler loop.
58
+ */
59
+ export declare function executeNotifierRun(wd: ResolvedWatchdog, text: string, deps: WatchdogRunDeps): Promise<void>;
60
+ /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
61
+ export declare function runWatchdogAgent(name: string): Promise<void>;