@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.
- package/README.md +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +42 -0
- package/dist/application/fleet-query-service.js +188 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +341 -3
- package/dist/config.d.ts +9 -2
- package/dist/config.js +21 -5
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +56 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/ops.d.ts +16 -0
- package/dist/ops.js +112 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/resolved-plan.js +7 -0
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/watchdog/alerts.d.ts +34 -0
- package/dist/watchdog/alerts.js +78 -0
- package/dist/watchdog/briefing.d.ts +65 -0
- package/dist/watchdog/briefing.js +181 -0
- package/dist/watchdog/config.d.ts +49 -0
- package/dist/watchdog/config.js +114 -0
- package/dist/watchdog/query.d.ts +78 -0
- package/dist/watchdog/query.js +124 -0
- package/dist/watchdog/report.d.ts +53 -0
- package/dist/watchdog/report.js +126 -0
- package/dist/watchdog/run.d.ts +61 -0
- package/dist/watchdog/run.js +318 -0
- package/dist/watchdog/scheduler.d.ts +105 -0
- package/dist/watchdog/scheduler.js +244 -0
- package/dist/watchdog/service.d.ts +46 -0
- package/dist/watchdog/service.js +179 -0
- package/dist/watchdog/store.d.ts +85 -0
- package/dist/watchdog/store.js +226 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +214 -0
- package/dist/web/server.d.ts +37 -0
- package/dist/web/server.js +279 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
- package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
- package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- package/package.json +26 -3
|
@@ -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>;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { stringify } from 'yaml';
|
|
6
|
+
import { errorReport, normalizeWatchdogReport, validateWatchdogReport } from './report.js';
|
|
7
|
+
import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport } from './store.js';
|
|
8
|
+
import { generateNotifierBriefing, generateWatchdogBriefing } from './briefing.js';
|
|
9
|
+
import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alerts.js';
|
|
10
|
+
import { applyRole } from '../ops.js';
|
|
11
|
+
import { daemonIdentityProvisioner, ensureIdentity, } from '../creation.js';
|
|
12
|
+
import { runOnce, START_STAGGER_FILE } from '../runner.js';
|
|
13
|
+
import { agentDir } from '../paths.js';
|
|
14
|
+
import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, } from '../config.js';
|
|
15
|
+
import { getAdapter } from '../harness/registry.js';
|
|
16
|
+
import { redactLogLine } from '../application/log-service.js';
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
/** How often the deadline loop polls for a completed report or a dead child. */
|
|
19
|
+
const POLL_MS = 1000;
|
|
20
|
+
/** A `report.json` this old, with no further writes, is treated as finished. */
|
|
21
|
+
const WRITE_STABLE_MS = 2000;
|
|
22
|
+
/** Grace given to the agent after a stable report before the session is killed. */
|
|
23
|
+
const HARVEST_GRACE_MS = 5000;
|
|
24
|
+
/** Fixed deadline for a one-shot notifier run — always 2 minutes, never wd.timeoutMs (Task 14). */
|
|
25
|
+
const NOTIFIER_TIMEOUT_MS = 120_000;
|
|
26
|
+
/**
|
|
27
|
+
* Poll for a completion sentinel file (report.json for an inspection run, sent.json for a
|
|
28
|
+
* notifier run) going write-stable, or the child exiting first, or the deadline passing —
|
|
29
|
+
* whichever comes first. Shared by executeWatchdogRun and executeNotifierRun so the two run
|
|
30
|
+
* flavors' wait loops can never drift apart.
|
|
31
|
+
*/
|
|
32
|
+
async function waitForSentinel(sentinelPath, child, deadlineMs, start, now, sleep) {
|
|
33
|
+
let exited = false;
|
|
34
|
+
// Attached now so a natural exit is observed even though the loop below never
|
|
35
|
+
// awaits the promise itself (its resolution races real time in the real
|
|
36
|
+
// launcher, and is driven purely by test fixtures in tests).
|
|
37
|
+
child.exited.then(() => { exited = true; }).catch(() => { exited = true; });
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (exited)
|
|
40
|
+
return 'exited';
|
|
41
|
+
if (existsSync(sentinelPath)) {
|
|
42
|
+
const age = now().getTime() - statSync(sentinelPath).mtimeMs;
|
|
43
|
+
if (age >= WRITE_STABLE_MS)
|
|
44
|
+
return 'stable';
|
|
45
|
+
}
|
|
46
|
+
if (now().getTime() - start.getTime() > deadlineMs)
|
|
47
|
+
return 'timeout';
|
|
48
|
+
await sleep(POLL_MS);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** After waitForSentinel settles, harvest the grace period (if stable) then kill the child+session. */
|
|
52
|
+
async function killIfNeeded(reason, child, roleName, sleep) {
|
|
53
|
+
if (reason === 'stable' || reason === 'timeout') {
|
|
54
|
+
if (reason === 'stable')
|
|
55
|
+
await sleep(HARVEST_GRACE_MS);
|
|
56
|
+
child.kill();
|
|
57
|
+
await killTmuxSession(roleName);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function defaultLaunchChild(binPath, roleName, runDir) {
|
|
61
|
+
const out = openSync(join(runDir, 'run.log'), 'a');
|
|
62
|
+
const child = spawnChild(process.execPath, [binPath, '_run-watchdog', roleName], {
|
|
63
|
+
detached: false, stdio: ['ignore', out, out],
|
|
64
|
+
});
|
|
65
|
+
// spawn() dup's the fd into the child; Node never closes our copy on its own,
|
|
66
|
+
// so a long-running scheduler launching many watchdog runs would leak one fd
|
|
67
|
+
// per run and eventually hit EMFILE. Safe to close immediately — the child
|
|
68
|
+
// keeps writing to its own dup'd descriptor.
|
|
69
|
+
closeSync(out);
|
|
70
|
+
return {
|
|
71
|
+
kill: () => { child.kill(); },
|
|
72
|
+
exited: new Promise(resolve => {
|
|
73
|
+
child.once('exit', () => resolve());
|
|
74
|
+
child.once('error', () => resolve());
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Best-effort: the tmux session outlives the supervisor child (`runOnce` created it). */
|
|
79
|
+
async function killTmuxSession(roleName) {
|
|
80
|
+
try {
|
|
81
|
+
await execFileAsync('tmux', ['kill-session', '-t', roleName]);
|
|
82
|
+
}
|
|
83
|
+
catch { /* best effort */ }
|
|
84
|
+
}
|
|
85
|
+
/** Last 4096 chars of run.log, redacted — attached to error reports as diagnostic tail. */
|
|
86
|
+
function readTail(runDir) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(join(runDir, 'run.log'), 'utf8');
|
|
89
|
+
return redactLogLine(raw.slice(-4096)).text;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The temp role every watchdog-family run (inspection and notifier alike) launches under.
|
|
97
|
+
* `network: 'broker'` keeps ours messaging available; no write binds beyond stateDir/cwd, which
|
|
98
|
+
* resolveIsolation adds itself. ~/fleet.yaml and fleet.d are deliberately NOT bound — they're on
|
|
99
|
+
* the isolation blocklist, and everything either run flavor needs is written into its own dir.
|
|
100
|
+
*
|
|
101
|
+
* Read access is scoped to exactly `wd.watch` (finding #3): a watchdog configured to watch one
|
|
102
|
+
* role must not be able to read every other role's state dir just because they all live under
|
|
103
|
+
* the same agents root. `wd.watch` defaults to every role (watchdog/config.ts's
|
|
104
|
+
* resolveWatchdogs), so a watchdog that watches everything still sees everything — this only
|
|
105
|
+
* narrows visibility for a watchdog scoped to fewer roles. A watched role whose state dir doesn't
|
|
106
|
+
* exist (e.g. a role removed after the watchdog was configured) is simply absent from the
|
|
107
|
+
* bwrap ro-bind-try set — the agent reports it unreachable from status evidence, same as any
|
|
108
|
+
* other missing state dir.
|
|
109
|
+
*/
|
|
110
|
+
function buildWatchdogRole(wd, cfg) {
|
|
111
|
+
return {
|
|
112
|
+
name: wd.identity, sourceFile: '(watchdog)',
|
|
113
|
+
harness: wd.harness, session: wd.session,
|
|
114
|
+
identity: wd.identity, model: wd.model,
|
|
115
|
+
permissions: { approval: 'allow', filesystem: 'workspace', unattended: 'deny' },
|
|
116
|
+
permissionsDeclared: true,
|
|
117
|
+
monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
|
|
118
|
+
worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
|
|
119
|
+
isolation: { fs: { read: wd.watch.map(r => agentDir(r)) }, network: 'broker' },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Run one watchdog agent end-to-end in a clean-context temp state dir: provision
|
|
124
|
+
* identity, materialize the run's contract (briefing/manifest/role snapshot),
|
|
125
|
+
* launch the child, enforce a deadline against report.json as the completion
|
|
126
|
+
* sentinel, harvest whatever it wrote (or a synthetic error report if it
|
|
127
|
+
* didn't), store the result, and always clean up the temp dir.
|
|
128
|
+
*
|
|
129
|
+
* The scheduler's run-lock guarantees only one run per watchdog at a time;
|
|
130
|
+
* this function itself takes no lock (Task 8).
|
|
131
|
+
*/
|
|
132
|
+
export async function executeWatchdogRun(wd, deps) {
|
|
133
|
+
const now = deps.now ?? (() => new Date());
|
|
134
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
135
|
+
const start = now();
|
|
136
|
+
const runId = formatRunId(start);
|
|
137
|
+
const startedAt = start.toISOString();
|
|
138
|
+
const roleName = wd.identity;
|
|
139
|
+
const runDir = agentDir(roleName, true);
|
|
140
|
+
// Read once, before the manifest is written: the same snapshot both seeds the
|
|
141
|
+
// digest the agent sees and is the base the post-run reconcile folds into.
|
|
142
|
+
const ledger = readLedger(wd.name);
|
|
143
|
+
// A crashed previous run can leave the temp dir behind; start clean.
|
|
144
|
+
if (existsSync(runDir))
|
|
145
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
146
|
+
let report;
|
|
147
|
+
try {
|
|
148
|
+
const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
149
|
+
const cfg = deps.cfg ?? loadConfig();
|
|
150
|
+
const role = buildWatchdogRole(wd, cfg);
|
|
151
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
152
|
+
const reportPath = join(dir, 'report.json');
|
|
153
|
+
const manifestPath = join(dir, 'watch.json');
|
|
154
|
+
const promptFocus = wd.promptFile ? readFileSync(wd.promptFile, 'utf8') : undefined;
|
|
155
|
+
// applyRole wrote the generic role briefing; the watchdog contract replaces it.
|
|
156
|
+
writeFileSync(join(dir, 'briefing.md'), generateWatchdogBriefing({
|
|
157
|
+
wd, manifestPath, reportPath,
|
|
158
|
+
vocabulary: getAdapter(wd.harness).vocabulary,
|
|
159
|
+
identityGuarantee: guarantee.state,
|
|
160
|
+
promptFocus,
|
|
161
|
+
}));
|
|
162
|
+
// loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
|
|
163
|
+
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
164
|
+
const manifest = {
|
|
165
|
+
watchdog: wd.name, run_id: runId, coordinator: wd.coordinator, started_at: startedAt,
|
|
166
|
+
roles: wd.watch.map(r => ({ name: r, stateDir: agentDir(r) })),
|
|
167
|
+
digest: computeDigest(ledger, wd.alertCooldownMs, now()),
|
|
168
|
+
};
|
|
169
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
170
|
+
// Snapshot the fleet start-stagger so the detached run (no config path
|
|
171
|
+
// threaded through `_run-watchdog`) honors the same host-wide launch gate
|
|
172
|
+
// as every other role (spec §3) — mirrors src/spawn.ts:503-504.
|
|
173
|
+
if (cfg.startStaggerMs > 0)
|
|
174
|
+
writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
|
|
175
|
+
const launch = deps.launchChild ?? defaultLaunchChild;
|
|
176
|
+
const child = launch(deps.binPath, roleName, dir);
|
|
177
|
+
const reason = await waitForSentinel(reportPath, child, wd.timeoutMs, start, now, sleep);
|
|
178
|
+
await killIfNeeded(reason, child, roleName, sleep);
|
|
179
|
+
const timedOut = reason === 'timeout';
|
|
180
|
+
if (timedOut) {
|
|
181
|
+
report = errorReport({
|
|
182
|
+
watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
|
|
183
|
+
error: 'timeout', tail: readTail(dir),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
let parsed;
|
|
188
|
+
let parseError;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
194
|
+
}
|
|
195
|
+
const validationErrors = parseError === undefined ? validateWatchdogReport(parsed) : [];
|
|
196
|
+
if (parseError !== undefined || validationErrors.length) {
|
|
197
|
+
const detail = parseError ?? validationErrors.slice(0, 3).join('; ');
|
|
198
|
+
report = errorReport({
|
|
199
|
+
watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
|
|
200
|
+
error: `invalid report: ${detail}`, tail: readTail(dir),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
report = normalizeWatchdogReport(parsed, { watchdog: wd.name, run_id: runId });
|
|
205
|
+
// Scheduler clock truth overrides whatever the agent wrote.
|
|
206
|
+
report.started_at = startedAt;
|
|
207
|
+
report.finished_at = now().toISOString();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Isolation degradation (spec §7): a weaker guarantee must never look like
|
|
211
|
+
// the strong one, so stamp it on every outcome, error reports included.
|
|
212
|
+
if (existsSync(join(dir, '.isolation-degraded')))
|
|
213
|
+
report.isolation = 'degraded';
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
const storedPath = writeReport(wd.name, report);
|
|
219
|
+
// Reconciles against the FINAL stored report (normalized or error) — reconcileLedger
|
|
220
|
+
// itself no-ops on error-status reports, so a timeout/invalid run leaves the ledger
|
|
221
|
+
// byte-for-byte unchanged.
|
|
222
|
+
writeLedger(wd.name, reconcileLedger(ledger, report, now()));
|
|
223
|
+
pruneReports(wd.name, wd.keepReports);
|
|
224
|
+
return { report, storedPath };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
|
|
228
|
+
* under the watchdog's own identity whose entire mission is: bind identity, send `text` to
|
|
229
|
+
* `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
|
|
230
|
+
* (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
|
|
231
|
+
* other way still gets a message out.
|
|
232
|
+
*
|
|
233
|
+
* Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
|
|
234
|
+
* launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
|
|
235
|
+
* watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
|
|
236
|
+
* deadline applies regardless of `wd.timeoutMs`.
|
|
237
|
+
*
|
|
238
|
+
* Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
|
|
239
|
+
* and releases the run lock itself — a notifier run shares the same temp dir name
|
|
240
|
+
* (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
|
|
241
|
+
* to acquire is logged and treated as a no-op: the held-down state already means no regular runs
|
|
242
|
+
* are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
|
|
243
|
+
* warning and swallowed — a notifier failure must never throw into the scheduler loop.
|
|
244
|
+
*/
|
|
245
|
+
export async function executeNotifierRun(wd, text, deps) {
|
|
246
|
+
const now = deps.now ?? (() => new Date());
|
|
247
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
248
|
+
const fail = (e) => {
|
|
249
|
+
deps.log(`notifier run for '${wd.name}' failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
250
|
+
};
|
|
251
|
+
// acquireRunLock can itself throw (EACCES/EIO, not just EEXIST — store.ts) — guarded
|
|
252
|
+
// separately from the try/finally below so a throw here never reaches the caller either;
|
|
253
|
+
// nothing was acquired, so there is nothing to release.
|
|
254
|
+
let acquired = false;
|
|
255
|
+
try {
|
|
256
|
+
acquired = acquireRunLock(wd.name);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
fail(e);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!acquired) {
|
|
263
|
+
deps.log(`notifier run for '${wd.name}' skipped: run lock held`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const roleName = wd.identity;
|
|
267
|
+
const runDir = agentDir(roleName, true);
|
|
268
|
+
try {
|
|
269
|
+
// A crashed previous run can leave the temp dir behind; start clean.
|
|
270
|
+
if (existsSync(runDir))
|
|
271
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
272
|
+
const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
273
|
+
const cfg = deps.cfg ?? loadConfig();
|
|
274
|
+
const role = buildWatchdogRole(wd, cfg);
|
|
275
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
276
|
+
const sentinelPath = join(dir, 'sent.json');
|
|
277
|
+
// applyRole wrote the generic role briefing; the notifier contract replaces it.
|
|
278
|
+
writeFileSync(join(dir, 'briefing.md'), generateNotifierBriefing({
|
|
279
|
+
wd, vocabulary: getAdapter(wd.harness).vocabulary,
|
|
280
|
+
identityGuarantee: guarantee.state, text,
|
|
281
|
+
}));
|
|
282
|
+
// loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
|
|
283
|
+
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
284
|
+
// Same host-wide launch-gate snapshot executeWatchdogRun writes (spec §3).
|
|
285
|
+
if (cfg.startStaggerMs > 0)
|
|
286
|
+
writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
|
|
287
|
+
const launch = deps.launchChild ?? defaultLaunchChild;
|
|
288
|
+
const start = now();
|
|
289
|
+
const child = launch(deps.binPath, roleName, dir);
|
|
290
|
+
const reason = await waitForSentinel(sentinelPath, child, NOTIFIER_TIMEOUT_MS, start, now, sleep);
|
|
291
|
+
await killIfNeeded(reason, child, roleName, sleep);
|
|
292
|
+
if (reason === 'timeout')
|
|
293
|
+
deps.log(`notifier run for '${wd.name}' timed out`);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
fail(e);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
// Same "never escape" rule as the lock acquire above: cleanup and release are each
|
|
300
|
+
// guarded individually so neither can turn a handled failure into an unhandled one.
|
|
301
|
+
try {
|
|
302
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
fail(e);
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
releaseRunLock(wd.name);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
fail(e);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
|
|
316
|
+
export async function runWatchdogAgent(name) {
|
|
317
|
+
await runOnce(name, { temp: true });
|
|
318
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { FleetConfig } from '../config.js';
|
|
2
|
+
import type { ResolvedWatchdog } from './config.js';
|
|
3
|
+
import { executeNotifierRun, executeWatchdogRun } from './run.js';
|
|
4
|
+
export interface WatchdogSchedulerState {
|
|
5
|
+
version: 1;
|
|
6
|
+
consecutiveFailures: number;
|
|
7
|
+
heldDown: boolean;
|
|
8
|
+
heldSince?: string;
|
|
9
|
+
lastRunAt?: string;
|
|
10
|
+
nextRunAt?: string;
|
|
11
|
+
lastError?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Read a watchdog's scheduler state; a missing or corrupt file starts clean (mirrors readRestartLedger). */
|
|
14
|
+
export declare function readSchedulerState(name: string): WatchdogSchedulerState;
|
|
15
|
+
/** Write never throws: scheduler diagnostics must never take the loop down. */
|
|
16
|
+
export declare function writeSchedulerState(name: string, s: WatchdogSchedulerState): void;
|
|
17
|
+
/**
|
|
18
|
+
* Operator release (Task 15): clears failures and heldDown so a held-down
|
|
19
|
+
* loop's next held-down poll sees a clean state and resumes running. Also
|
|
20
|
+
* clears the ledger's `heldDownAlerted` flag — that flag is what makes "alert
|
|
21
|
+
* once per hold-down" durable across scheduler restarts (it lives in
|
|
22
|
+
* alerts.json, not state.json), so a release must reset it too or a
|
|
23
|
+
* subsequent hold-down would silently alert zero times.
|
|
24
|
+
*
|
|
25
|
+
* Also reclaims a STALE run lock (final review #2, tightened by finding #2):
|
|
26
|
+
* a watchdog SIGKILLed mid-run (e.g. systemd's TimeoutStopSec on a
|
|
27
|
+
* fleet-wide stop) leaves `.run-lock` behind forever — the loop's `finally`
|
|
28
|
+
* that would normally release it never runs. Without this, every future tick
|
|
29
|
+
* sees the lock held and reports `skipped_overlap` indefinitely, and skips
|
|
30
|
+
* never alert. `ours-fleet restart <watchdog>` is the documented recovery, so
|
|
31
|
+
* it must clear a stale lock too, not just the failure/hold-down bookkeeping.
|
|
32
|
+
* But only a DEMONSTRABLY stale lock (dead owner pid, or legacy lock with no
|
|
33
|
+
* owner metadata) — a lock genuinely held by a live run (foreground
|
|
34
|
+
* `watchdog-run`, another scheduler instance) must survive an operator's
|
|
35
|
+
* `restart` of a DIFFERENT problem (e.g. releasing hold-down) unrelated to
|
|
36
|
+
* that live run; two runs sharing the same temp dir would corrupt each
|
|
37
|
+
* other's output. Best-effort: a reclaim failure here must not turn an
|
|
38
|
+
* operator's recovery action into a crash.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resetSchedulerState(name: string): void;
|
|
41
|
+
export declare const WATCHDOG_HOLD_THRESHOLD = 3;
|
|
42
|
+
export declare const WATCHDOG_BACKOFF_MAX_MS = 3600000;
|
|
43
|
+
/** Bounded exponential backoff: 1x, 2x, 4x, ... capped at WATCHDOG_BACKOFF_MAX_MS (spec §3). */
|
|
44
|
+
export declare function watchdogBackoffMs(intervalMs: number, failures: number): number;
|
|
45
|
+
export interface SchedulerDeps {
|
|
46
|
+
now(): Date;
|
|
47
|
+
sleep(ms: number): Promise<void>;
|
|
48
|
+
log(line: string): void;
|
|
49
|
+
binPath: string;
|
|
50
|
+
/**
|
|
51
|
+
* The fleet config `runScheduler` loaded from the `-c FILE`/default path
|
|
52
|
+
* (final review #1). Threaded into both `runOnceFor`'s and the notifier's
|
|
53
|
+
* deps so a run under a non-default config doesn't silently fall back to
|
|
54
|
+
* `loadConfig()`'s default `~/fleet.yaml`. `runWatchdogLoop` callers that
|
|
55
|
+
* bypass `runScheduler` (tests) may omit it — the run/notifier machinery
|
|
56
|
+
* falls back to `loadConfig()` itself when `cfg` is undefined.
|
|
57
|
+
*/
|
|
58
|
+
cfg?: FleetConfig;
|
|
59
|
+
/** Injectable for tests. */
|
|
60
|
+
runOnceFor?: typeof executeWatchdogRun;
|
|
61
|
+
/** Loop exit for tests + SIGTERM (wired by the CLI, not here). */
|
|
62
|
+
shouldStop?(): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Scheduler-level alert hook (Task 14, spec §5.5): fired once per hold-down
|
|
65
|
+
* transition (guarded in `settle` by `ledger.heldDownAlerted`, cleared by
|
|
66
|
+
* `resetSchedulerState`). Default: `executeNotifierRun` — the fleet
|
|
67
|
+
* process can't message on its own (deviation 4), so the default delivers
|
|
68
|
+
* the alert via a one-shot notifier agent under the watchdog's identity.
|
|
69
|
+
*/
|
|
70
|
+
onSchedulerAlert?(wd: ResolvedWatchdog, text: string): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Injectable notifier launcher backing the default `onSchedulerAlert` —
|
|
73
|
+
* consulted only when `onSchedulerAlert` itself is not supplied. Default:
|
|
74
|
+
* `executeNotifierRun`. Lets tests observe/short-circuit the default alert
|
|
75
|
+
* path (binPath/log/now/sleep wiring) without replacing onSchedulerAlert
|
|
76
|
+
* wholesale.
|
|
77
|
+
*/
|
|
78
|
+
notifierRun?: typeof executeNotifierRun;
|
|
79
|
+
/** Poll cadence while held down. Default 5000. */
|
|
80
|
+
heldPollMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Injectable run-lock primitives. Default: store's acquireRunLock /
|
|
83
|
+
* releaseRunLock. Both are mkdir/rmdir-backed and can throw (EACCES, EIO,
|
|
84
|
+
* ENOTEMPTY on release) — the loop always classifies such a throw as a
|
|
85
|
+
* failed tick rather than letting it escape (review finding #1).
|
|
86
|
+
*/
|
|
87
|
+
locks?: {
|
|
88
|
+
acquire(name: string): boolean;
|
|
89
|
+
release(name: string): void;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One watchdog's scheduling loop: run immediately, then repeatedly sleep the
|
|
94
|
+
* backed-off interval and run again, until `shouldStop()`. No overlap (a run
|
|
95
|
+
* lock guards each attempt), bounded exponential backoff on failure, and a
|
|
96
|
+
* hold-down circuit breaker after WATCHDOG_HOLD_THRESHOLD consecutive
|
|
97
|
+
* failures (released externally via resetSchedulerState).
|
|
98
|
+
*/
|
|
99
|
+
export declare function runWatchdogLoop(wd: ResolvedWatchdog, deps: SchedulerDeps): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Run every enabled watchdog's loop concurrently until deps.shouldStop().
|
|
102
|
+
* SIGTERM wiring into shouldStop is the CLI's job (Task 10), not this
|
|
103
|
+
* function's.
|
|
104
|
+
*/
|
|
105
|
+
export declare function runScheduler(configPath: string | undefined, deps: SchedulerDeps): Promise<void>;
|