@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,244 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { errorReport } from './report.js';
|
|
5
|
+
import { executeNotifierRun, executeWatchdogRun } from './run.js';
|
|
6
|
+
import { acquireRunLock, formatRunId, readRunLockOwner, reclaimStaleRunLock, releaseRunLock, watchdogDir, writeReport, } from './store.js';
|
|
7
|
+
import { readLedger, writeLedger } from './alerts.js';
|
|
8
|
+
const STATE_FILE = 'state.json';
|
|
9
|
+
const cleanState = () => ({ version: 1, consecutiveFailures: 0, heldDown: false });
|
|
10
|
+
/** Read a watchdog's scheduler state; a missing or corrupt file starts clean (mirrors readRestartLedger). */
|
|
11
|
+
export function readSchedulerState(name) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = JSON.parse(readFileSync(join(watchdogDir(name), STATE_FILE), 'utf8'));
|
|
14
|
+
if (raw.version !== 1)
|
|
15
|
+
return cleanState();
|
|
16
|
+
return { ...cleanState(), ...raw, version: 1 };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return cleanState();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Write never throws: scheduler diagnostics must never take the loop down. */
|
|
23
|
+
export function writeSchedulerState(name, s) {
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(join(watchdogDir(name), STATE_FILE), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
catch { /* diagnostics must never take the loop down */ }
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Operator release (Task 15): clears failures and heldDown so a held-down
|
|
31
|
+
* loop's next held-down poll sees a clean state and resumes running. Also
|
|
32
|
+
* clears the ledger's `heldDownAlerted` flag — that flag is what makes "alert
|
|
33
|
+
* once per hold-down" durable across scheduler restarts (it lives in
|
|
34
|
+
* alerts.json, not state.json), so a release must reset it too or a
|
|
35
|
+
* subsequent hold-down would silently alert zero times.
|
|
36
|
+
*
|
|
37
|
+
* Also reclaims a STALE run lock (final review #2, tightened by finding #2):
|
|
38
|
+
* a watchdog SIGKILLed mid-run (e.g. systemd's TimeoutStopSec on a
|
|
39
|
+
* fleet-wide stop) leaves `.run-lock` behind forever — the loop's `finally`
|
|
40
|
+
* that would normally release it never runs. Without this, every future tick
|
|
41
|
+
* sees the lock held and reports `skipped_overlap` indefinitely, and skips
|
|
42
|
+
* never alert. `ours-fleet restart <watchdog>` is the documented recovery, so
|
|
43
|
+
* it must clear a stale lock too, not just the failure/hold-down bookkeeping.
|
|
44
|
+
* But only a DEMONSTRABLY stale lock (dead owner pid, or legacy lock with no
|
|
45
|
+
* owner metadata) — a lock genuinely held by a live run (foreground
|
|
46
|
+
* `watchdog-run`, another scheduler instance) must survive an operator's
|
|
47
|
+
* `restart` of a DIFFERENT problem (e.g. releasing hold-down) unrelated to
|
|
48
|
+
* that live run; two runs sharing the same temp dir would corrupt each
|
|
49
|
+
* other's output. Best-effort: a reclaim failure here must not turn an
|
|
50
|
+
* operator's recovery action into a crash.
|
|
51
|
+
*/
|
|
52
|
+
export function resetSchedulerState(name) {
|
|
53
|
+
writeSchedulerState(name, cleanState());
|
|
54
|
+
const ledger = readLedger(name);
|
|
55
|
+
if (ledger.heldDownAlerted)
|
|
56
|
+
writeLedger(name, { ...ledger, heldDownAlerted: false });
|
|
57
|
+
try {
|
|
58
|
+
reclaimStaleRunLock(name);
|
|
59
|
+
}
|
|
60
|
+
catch { /* best effort */ }
|
|
61
|
+
}
|
|
62
|
+
export const WATCHDOG_HOLD_THRESHOLD = 3;
|
|
63
|
+
export const WATCHDOG_BACKOFF_MAX_MS = 3_600_000;
|
|
64
|
+
/** Bounded exponential backoff: 1x, 2x, 4x, ... capped at WATCHDOG_BACKOFF_MAX_MS (spec §3). */
|
|
65
|
+
export function watchdogBackoffMs(intervalMs, failures) {
|
|
66
|
+
if (failures <= 0)
|
|
67
|
+
return intervalMs;
|
|
68
|
+
return Math.min(intervalMs * 2 ** (failures - 1), WATCHDOG_BACKOFF_MAX_MS);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* One watchdog's scheduling loop: run immediately, then repeatedly sleep the
|
|
72
|
+
* backed-off interval and run again, until `shouldStop()`. No overlap (a run
|
|
73
|
+
* lock guards each attempt), bounded exponential backoff on failure, and a
|
|
74
|
+
* hold-down circuit breaker after WATCHDOG_HOLD_THRESHOLD consecutive
|
|
75
|
+
* failures (released externally via resetSchedulerState).
|
|
76
|
+
*/
|
|
77
|
+
export async function runWatchdogLoop(wd, deps) {
|
|
78
|
+
const runOnceFor = deps.runOnceFor ?? executeWatchdogRun;
|
|
79
|
+
const shouldStop = deps.shouldStop ?? (() => false);
|
|
80
|
+
const notifierRun = deps.notifierRun ?? executeNotifierRun;
|
|
81
|
+
const onSchedulerAlert = deps.onSchedulerAlert ?? ((wd, text) => notifierRun(wd, text, {
|
|
82
|
+
binPath: deps.binPath, log: deps.log, now: deps.now, sleep: deps.sleep, cfg: deps.cfg,
|
|
83
|
+
}));
|
|
84
|
+
const heldPollMs = deps.heldPollMs ?? 5_000;
|
|
85
|
+
const locks = deps.locks ?? { acquire: acquireRunLock, release: releaseRunLock };
|
|
86
|
+
/**
|
|
87
|
+
* Apply one tick's outcome to state.json: update the failure streak,
|
|
88
|
+
* transition into hold-down on the Nth consecutive failure (firing the
|
|
89
|
+
* alert at most once per transition via the ledger's `heldDownAlerted`
|
|
90
|
+
* guard — durable across restarts, unlike an in-memory flag), then sleep
|
|
91
|
+
* before the next tick. On the tick that transitions into hold-down, sleep
|
|
92
|
+
* `heldPollMs` rather than the (possibly hour-long) backoff delay — an
|
|
93
|
+
* operator's resetSchedulerState must be noticed within one poll cycle,
|
|
94
|
+
* not after the last backoff finishes (review finding #2).
|
|
95
|
+
*/
|
|
96
|
+
const settle = async (isFailure, errorMessage, startedAt) => {
|
|
97
|
+
const s = readSchedulerState(wd.name);
|
|
98
|
+
if (isFailure) {
|
|
99
|
+
s.consecutiveFailures += 1;
|
|
100
|
+
s.lastError = errorMessage;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
s.consecutiveFailures = 0;
|
|
104
|
+
s.lastError = undefined;
|
|
105
|
+
}
|
|
106
|
+
s.lastRunAt = startedAt.toISOString();
|
|
107
|
+
let justHeld = false;
|
|
108
|
+
if (s.consecutiveFailures >= WATCHDOG_HOLD_THRESHOLD && !s.heldDown) {
|
|
109
|
+
s.heldDown = true;
|
|
110
|
+
s.heldSince = deps.now().toISOString();
|
|
111
|
+
justHeld = true;
|
|
112
|
+
}
|
|
113
|
+
const delay = justHeld ? heldPollMs : watchdogBackoffMs(wd.intervalMs, s.consecutiveFailures);
|
|
114
|
+
s.nextRunAt = new Date(deps.now().getTime() + delay).toISOString();
|
|
115
|
+
writeSchedulerState(wd.name, s);
|
|
116
|
+
if (justHeld) {
|
|
117
|
+
const msg = `watchdog ${wd.name} held down after ${WATCHDOG_HOLD_THRESHOLD} consecutive failed runs: ${s.lastError}`;
|
|
118
|
+
deps.log(msg);
|
|
119
|
+
// "Once per state change" (spec §5.5) must survive a scheduler restart, so the guard
|
|
120
|
+
// lives in the ledger (alerts.json), not in memory — resetSchedulerState clears it.
|
|
121
|
+
const ledger = readLedger(wd.name);
|
|
122
|
+
if (!ledger.heldDownAlerted) {
|
|
123
|
+
await onSchedulerAlert(wd, msg);
|
|
124
|
+
writeLedger(wd.name, { ...ledger, heldDownAlerted: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await deps.sleep(delay);
|
|
128
|
+
};
|
|
129
|
+
for (;;) {
|
|
130
|
+
if (shouldStop())
|
|
131
|
+
return;
|
|
132
|
+
if (readSchedulerState(wd.name).heldDown) {
|
|
133
|
+
await deps.sleep(heldPollMs);
|
|
134
|
+
if (shouldStop())
|
|
135
|
+
return;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const tickStart = deps.now();
|
|
139
|
+
// The run-lock is a filesystem mutex (mkdir/rmdir) and can throw
|
|
140
|
+
// (EACCES, EIO, ENOTEMPTY on release — store.ts's release deliberately
|
|
141
|
+
// rethrows non-ENOENT failures). Never let that throw escape this loop:
|
|
142
|
+
// runScheduler drives every watchdog's loop via Promise.all, so an
|
|
143
|
+
// uncaught throw here would kill every OTHER watchdog's loop too.
|
|
144
|
+
// Classify it as this tick's failure instead (review finding #1).
|
|
145
|
+
let acquired = false;
|
|
146
|
+
let acquireError;
|
|
147
|
+
try {
|
|
148
|
+
acquired = locks.acquire(wd.name);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
acquireError = e instanceof Error ? e.message : String(e);
|
|
152
|
+
}
|
|
153
|
+
if (acquireError !== undefined) {
|
|
154
|
+
await settle(true, acquireError, tickStart);
|
|
155
|
+
if (shouldStop())
|
|
156
|
+
return;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!acquired) {
|
|
160
|
+
writeReport(wd.name, errorReport({
|
|
161
|
+
watchdog: wd.name, run_id: formatRunId(tickStart),
|
|
162
|
+
started_at: tickStart.toISOString(), finished_at: tickStart.toISOString(),
|
|
163
|
+
error: 'skipped_overlap',
|
|
164
|
+
}));
|
|
165
|
+
deps.log(`watchdog ${wd.name}: skipped run (previous run still holds the lock)`);
|
|
166
|
+
// Failure count is untouched by a skip; only lastRunAt/nextRunAt move.
|
|
167
|
+
// The backoff cadence (not the raw interval) still governs during an
|
|
168
|
+
// active failure streak: a skip isn't a finished run, so it shouldn't
|
|
169
|
+
// reset the retry cadence to full speed either (spec §3).
|
|
170
|
+
const skipState = readSchedulerState(wd.name);
|
|
171
|
+
const delay = watchdogBackoffMs(wd.intervalMs, skipState.consecutiveFailures);
|
|
172
|
+
writeSchedulerState(wd.name, {
|
|
173
|
+
...skipState,
|
|
174
|
+
lastRunAt: tickStart.toISOString(),
|
|
175
|
+
nextRunAt: new Date(tickStart.getTime() + delay).toISOString(),
|
|
176
|
+
});
|
|
177
|
+
await deps.sleep(delay);
|
|
178
|
+
if (shouldStop())
|
|
179
|
+
return;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let outcome;
|
|
183
|
+
let tickError;
|
|
184
|
+
try {
|
|
185
|
+
outcome = await runOnceFor(wd, {
|
|
186
|
+
binPath: deps.binPath, log: deps.log, now: deps.now, sleep: deps.sleep, cfg: deps.cfg,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
tickError = e instanceof Error ? e.message : String(e);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
// Same "never escape" rule applies to release: fold a throw into this
|
|
194
|
+
// tick's failure rather than letting it propagate out of the loop.
|
|
195
|
+
try {
|
|
196
|
+
locks.release(wd.name);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
tickError = tickError ?? (e instanceof Error ? e.message : String(e));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let errorMessage;
|
|
203
|
+
if (outcome === undefined) {
|
|
204
|
+
errorMessage = tickError;
|
|
205
|
+
}
|
|
206
|
+
else if (outcome.report.status === 'error' && outcome.report.error !== 'skipped_overlap') {
|
|
207
|
+
errorMessage = outcome.report.error ?? tickError ?? 'unknown error';
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
errorMessage = tickError;
|
|
211
|
+
}
|
|
212
|
+
await settle(errorMessage !== undefined, errorMessage, tickStart);
|
|
213
|
+
if (shouldStop())
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Run every enabled watchdog's loop concurrently until deps.shouldStop().
|
|
219
|
+
* SIGTERM wiring into shouldStop is the CLI's job (Task 10), not this
|
|
220
|
+
* function's.
|
|
221
|
+
*/
|
|
222
|
+
export async function runScheduler(configPath, deps) {
|
|
223
|
+
const cfg = loadConfig(configPath);
|
|
224
|
+
const watchdogs = cfg.watchdogs.filter(w => w.enabled);
|
|
225
|
+
// Recover from a SIGKILLed prior run (final review #2a, tightened by
|
|
226
|
+
// finding #2): a scheduler restart is EVIDENCE, not proof, that no
|
|
227
|
+
// scheduler-owned run is in flight — a foreground `watchdog-run`, or
|
|
228
|
+
// another scheduler instance, may genuinely still hold a watchdog's lock.
|
|
229
|
+
// Only a DEMONSTRABLY stale lock (dead owner pid, or a legacy lock with no
|
|
230
|
+
// owner metadata) may be reclaimed; a live lock is left alone so the two
|
|
231
|
+
// runs never share the same temp dir — the loop then simply observes
|
|
232
|
+
// skipped_overlap on its first tick, same as any other overlap. Best-effort:
|
|
233
|
+
// a reclaim failure here must not prevent the scheduler from starting.
|
|
234
|
+
for (const wd of watchdogs) {
|
|
235
|
+
try {
|
|
236
|
+
if (!reclaimStaleRunLock(wd.name)) {
|
|
237
|
+
const owner = readRunLockOwner(wd.name);
|
|
238
|
+
deps.log(`watchdog '${wd.name}': run lock held by live pid ${owner?.pid ?? 'unknown'} — not reclaimed`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch { /* best effort */ }
|
|
242
|
+
}
|
|
243
|
+
await Promise.all(watchdogs.map(wd => runWatchdogLoop(wd, { ...deps, cfg })));
|
|
244
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
export declare const WATCHDOG_SYSTEMD_UNIT = "ours-fleet-watchdogs.service";
|
|
3
|
+
export declare const WATCHDOG_LAUNCHD_LABEL = "network.ours.fleet.watchdogs";
|
|
4
|
+
/**
|
|
5
|
+
* Supervises the single long-running watchdog-scheduler process (the hidden
|
|
6
|
+
* `_run-watchdogs` command, Task 9's `runScheduler`) the same way
|
|
7
|
+
* `WebServiceManager` (src/web/service.ts) supervises the web console: a
|
|
8
|
+
* private systemd --user unit on Linux, a launchd LaunchAgent on macOS.
|
|
9
|
+
*/
|
|
10
|
+
export declare class WatchdogServiceManager {
|
|
11
|
+
private readonly exec;
|
|
12
|
+
private readonly platform;
|
|
13
|
+
constructor(exec?: Exec, platform?: NodeJS.Platform);
|
|
14
|
+
get definitionPath(): string;
|
|
15
|
+
/** false when explicitly disabled (OURS_FLEET_SUPERVISOR=none) or on an unsupported platform. */
|
|
16
|
+
supervised(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Writes the unit/plist and returns whether its content actually changed
|
|
19
|
+
* (finding #4): `binPath`/`configPath` are the only inputs that ever
|
|
20
|
+
* change this content, and neither reflects a watchdog's `interval:` or
|
|
21
|
+
* any other config value inside `watchdogs:` — so `changed` here can never
|
|
22
|
+
* by itself justify a restart on every config edit. It's one of the two
|
|
23
|
+
* signals reconcileWatchdogScheduler (ops.ts) combines with a config
|
|
24
|
+
* fingerprint before deciding restart vs. the idempotent start.
|
|
25
|
+
*/
|
|
26
|
+
install(binPath: string, configPath?: string): Promise<{
|
|
27
|
+
changed: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
start(): Promise<void>;
|
|
30
|
+
stop(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* `start()` is a no-op on an already-active unit — systemctl start against
|
|
33
|
+
* a running service just returns 0 without reloading anything, and
|
|
34
|
+
* launchctl kickstart (without -k) behaves the same way. That means a
|
|
35
|
+
* config change (new/changed watchdogs) never reaches a live scheduler
|
|
36
|
+
* process via reconcileWatchdogScheduler's `install` + `start` pair (final
|
|
37
|
+
* review #4). `restart()` mirrors WebServiceManager.restart: an
|
|
38
|
+
* unconditional restart on Linux, and `kickstart -k` (force-restart) on
|
|
39
|
+
* macOS, falling back to stop+start if the kickstart itself fails.
|
|
40
|
+
*/
|
|
41
|
+
restart(): Promise<void>;
|
|
42
|
+
status(): Promise<string>;
|
|
43
|
+
uninstall(): Promise<void>;
|
|
44
|
+
private requireInstalled;
|
|
45
|
+
private must;
|
|
46
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { FleetError } from '../application/errors.js';
|
|
5
|
+
import { realExec } from '../exec.js';
|
|
6
|
+
import { home } from '../paths.js';
|
|
7
|
+
export const WATCHDOG_SYSTEMD_UNIT = 'ours-fleet-watchdogs.service';
|
|
8
|
+
export const WATCHDOG_LAUNCHD_LABEL = 'network.ours.fleet.watchdogs';
|
|
9
|
+
/**
|
|
10
|
+
* Supervises the single long-running watchdog-scheduler process (the hidden
|
|
11
|
+
* `_run-watchdogs` command, Task 9's `runScheduler`) the same way
|
|
12
|
+
* `WebServiceManager` (src/web/service.ts) supervises the web console: a
|
|
13
|
+
* private systemd --user unit on Linux, a launchd LaunchAgent on macOS.
|
|
14
|
+
*/
|
|
15
|
+
export class WatchdogServiceManager {
|
|
16
|
+
exec;
|
|
17
|
+
platform;
|
|
18
|
+
constructor(exec = realExec, platform = process.platform) {
|
|
19
|
+
this.exec = exec;
|
|
20
|
+
this.platform = platform;
|
|
21
|
+
}
|
|
22
|
+
get definitionPath() {
|
|
23
|
+
return this.platform === 'linux'
|
|
24
|
+
? join(home(), '.config', 'systemd', 'user', WATCHDOG_SYSTEMD_UNIT)
|
|
25
|
+
: join(home(), 'Library', 'LaunchAgents', `${WATCHDOG_LAUNCHD_LABEL}.plist`);
|
|
26
|
+
}
|
|
27
|
+
/** false when explicitly disabled (OURS_FLEET_SUPERVISOR=none) or on an unsupported platform. */
|
|
28
|
+
supervised() {
|
|
29
|
+
if (process.env.OURS_FLEET_SUPERVISOR === 'none')
|
|
30
|
+
return false;
|
|
31
|
+
return this.platform === 'linux' || this.platform === 'darwin';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Writes the unit/plist and returns whether its content actually changed
|
|
35
|
+
* (finding #4): `binPath`/`configPath` are the only inputs that ever
|
|
36
|
+
* change this content, and neither reflects a watchdog's `interval:` or
|
|
37
|
+
* any other config value inside `watchdogs:` — so `changed` here can never
|
|
38
|
+
* by itself justify a restart on every config edit. It's one of the two
|
|
39
|
+
* signals reconcileWatchdogScheduler (ops.ts) combines with a config
|
|
40
|
+
* fingerprint before deciding restart vs. the idempotent start.
|
|
41
|
+
*/
|
|
42
|
+
async install(binPath, configPath) {
|
|
43
|
+
const resolvedScript = resolveExecutable(binPath, 'ours-fleet CLI script');
|
|
44
|
+
const runtime = resolveExecutable(process.execPath, 'Node runtime');
|
|
45
|
+
const config = configPath ? resolve(configPath) : undefined;
|
|
46
|
+
const content = this.platform === 'linux'
|
|
47
|
+
? watchdogSystemdUnit(runtime, resolvedScript, config)
|
|
48
|
+
: watchdogLaunchdPlist(runtime, resolvedScript, config);
|
|
49
|
+
let previous;
|
|
50
|
+
try {
|
|
51
|
+
previous = readFileSync(this.definitionPath, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch { /* absent: definitely changed */ }
|
|
54
|
+
const changed = previous !== content;
|
|
55
|
+
mkdirSync(dirname(this.definitionPath), { recursive: true, mode: 0o700 });
|
|
56
|
+
replaceFileAtomically(this.definitionPath, content, 0o600);
|
|
57
|
+
if (this.platform === 'linux') {
|
|
58
|
+
await this.must('systemctl', ['--user', 'daemon-reload']);
|
|
59
|
+
await this.must('systemctl', ['--user', 'enable', WATCHDOG_SYSTEMD_UNIT]);
|
|
60
|
+
}
|
|
61
|
+
return { changed };
|
|
62
|
+
}
|
|
63
|
+
async start() {
|
|
64
|
+
this.requireInstalled();
|
|
65
|
+
if (this.platform === 'linux') {
|
|
66
|
+
await this.must('systemctl', ['--user', 'start', WATCHDOG_SYSTEMD_UNIT]);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const domain = `gui/${uid()}`;
|
|
70
|
+
const loaded = await this.exec('launchctl', ['print', `${domain}/${WATCHDOG_LAUNCHD_LABEL}`]);
|
|
71
|
+
if (loaded.code === 0)
|
|
72
|
+
await this.must('launchctl', ['kickstart', `${domain}/${WATCHDOG_LAUNCHD_LABEL}`]);
|
|
73
|
+
else
|
|
74
|
+
await this.must('launchctl', ['bootstrap', domain, this.definitionPath]);
|
|
75
|
+
}
|
|
76
|
+
async stop() {
|
|
77
|
+
if (this.platform === 'linux') {
|
|
78
|
+
// Tolerate "not loaded" (systemctl exit 5) the same way the launchd
|
|
79
|
+
// branch below tolerates "could not find service": a watchdog-less
|
|
80
|
+
// fleet's `up`/`down` calls stop() defensively even when the unit was
|
|
81
|
+
// never installed, and that must not surface as an error (final
|
|
82
|
+
// review #3).
|
|
83
|
+
const result = await this.exec('systemctl', ['--user', 'stop', WATCHDOG_SYSTEMD_UNIT]);
|
|
84
|
+
if (result.code !== 0 && result.code !== 5 && !/not loaded/i.test(`${result.stdout}\n${result.stderr}`))
|
|
85
|
+
throw new FleetError('control_unavailable', `systemctl --user stop ${WATCHDOG_SYSTEMD_UNIT} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const result = await this.exec('launchctl', ['bootout', `gui/${uid()}/${WATCHDOG_LAUNCHD_LABEL}`]);
|
|
89
|
+
if (result.code !== 0 && !/could not find service|no such process/i.test(`${result.stdout}\n${result.stderr}`))
|
|
90
|
+
throw new FleetError('control_unavailable', `launchctl bootout failed: ${result.stderr.trim()}`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `start()` is a no-op on an already-active unit — systemctl start against
|
|
94
|
+
* a running service just returns 0 without reloading anything, and
|
|
95
|
+
* launchctl kickstart (without -k) behaves the same way. That means a
|
|
96
|
+
* config change (new/changed watchdogs) never reaches a live scheduler
|
|
97
|
+
* process via reconcileWatchdogScheduler's `install` + `start` pair (final
|
|
98
|
+
* review #4). `restart()` mirrors WebServiceManager.restart: an
|
|
99
|
+
* unconditional restart on Linux, and `kickstart -k` (force-restart) on
|
|
100
|
+
* macOS, falling back to stop+start if the kickstart itself fails.
|
|
101
|
+
*/
|
|
102
|
+
async restart() {
|
|
103
|
+
this.requireInstalled();
|
|
104
|
+
if (this.platform === 'linux') {
|
|
105
|
+
await this.must('systemctl', ['--user', 'restart', WATCHDOG_SYSTEMD_UNIT]);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const result = await this.exec('launchctl', ['kickstart', '-k', `gui/${uid()}/${WATCHDOG_LAUNCHD_LABEL}`]);
|
|
109
|
+
if (result.code !== 0) {
|
|
110
|
+
await this.stop();
|
|
111
|
+
await this.start();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async status() {
|
|
115
|
+
if (this.platform === 'linux') {
|
|
116
|
+
const result = await this.exec('systemctl', [
|
|
117
|
+
'--user', 'show', WATCHDOG_SYSTEMD_UNIT, '-p', 'LoadState', '-p', 'ActiveState',
|
|
118
|
+
'-p', 'SubState', '-p', 'ExecMainPID', '--no-pager',
|
|
119
|
+
]);
|
|
120
|
+
return result.stdout.trim() || result.stderr.trim() || `exit ${result.code}`;
|
|
121
|
+
}
|
|
122
|
+
const result = await this.exec('launchctl', ['print', `gui/${uid()}/${WATCHDOG_LAUNCHD_LABEL}`]);
|
|
123
|
+
return result.code === 0 ? result.stdout.trim() : `not loaded (${WATCHDOG_LAUNCHD_LABEL})`;
|
|
124
|
+
}
|
|
125
|
+
async uninstall() {
|
|
126
|
+
if (this.platform === 'linux') {
|
|
127
|
+
await this.exec('systemctl', ['--user', 'disable', '--now', WATCHDOG_SYSTEMD_UNIT]);
|
|
128
|
+
rmSync(this.definitionPath, { force: true });
|
|
129
|
+
await this.exec('systemctl', ['--user', 'daemon-reload']);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
await this.stop();
|
|
133
|
+
rmSync(this.definitionPath, { force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
requireInstalled() {
|
|
137
|
+
if (!existsSync(this.definitionPath))
|
|
138
|
+
throw new FleetError('prerequisite_unavailable', 'watchdog scheduler service is not installed');
|
|
139
|
+
}
|
|
140
|
+
async must(command, args) {
|
|
141
|
+
const result = await this.exec(command, args);
|
|
142
|
+
if (result.code !== 0)
|
|
143
|
+
throw new FleetError('control_unavailable', `${command} ${args.join(' ')} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function uid() { return process.getuid?.() ?? 501; }
|
|
147
|
+
function resolveExecutable(executable, label) {
|
|
148
|
+
const absolute = isAbsolute(executable) ? executable : resolve(executable);
|
|
149
|
+
try {
|
|
150
|
+
return realpathSync(absolute);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new FleetError('prerequisite_unavailable', `${label} does not exist: ${absolute}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function systemdQuote(value) {
|
|
157
|
+
return `"${value.replace(/[%\\"]/g, char => char === '%' ? '%%' : `\\${char}`)}"`;
|
|
158
|
+
}
|
|
159
|
+
function watchdogSystemdUnit(runtime, script, configuration) {
|
|
160
|
+
const config = configuration ? ` -c ${systemdQuote(configuration)}` : '';
|
|
161
|
+
return `[Unit]\nDescription=ours-fleet watchdog scheduler\nAfter=default.target\n\n`
|
|
162
|
+
+ `[Service]\nType=simple\nExecStart=${systemdQuote(runtime)} ${systemdQuote(script)} _run-watchdogs${config}\n`
|
|
163
|
+
+ `Restart=on-failure\nRestartSec=5\nTimeoutStopSec=15\n\n`
|
|
164
|
+
+ `[Install]\nWantedBy=default.target\n`;
|
|
165
|
+
}
|
|
166
|
+
const xml = (value) => value.replace(/[&<>"']/g, char => ({
|
|
167
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
168
|
+
}[char]));
|
|
169
|
+
function watchdogLaunchdPlist(runtime, script, configuration) {
|
|
170
|
+
const config = configuration
|
|
171
|
+
? `<string>-c</string><string>${xml(configuration)}</string>` : '';
|
|
172
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
173
|
+
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n`
|
|
174
|
+
+ `<plist version="1.0"><dict>\n<key>Label</key><string>${WATCHDOG_LAUNCHD_LABEL}</string>\n`
|
|
175
|
+
+ `<key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(script)}</string>`
|
|
176
|
+
+ `<string>_run-watchdogs</string>${config}</array>\n`
|
|
177
|
+
+ `<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n`
|
|
178
|
+
+ `<key>ProcessType</key><string>Background</string>\n</dict></plist>\n`;
|
|
179
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { WatchdogReport, WatchdogReportStatus } from './report.js';
|
|
2
|
+
/**
|
|
3
|
+
* A lock directory briefly exists before acquireRunLock can rename its
|
|
4
|
+
* prewritten owner.json into it. Missing/corrupt owner metadata is therefore
|
|
5
|
+
* reclaimable only after this grace period, never while that acquisition
|
|
6
|
+
* window may still be in progress.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RUN_LOCK_OWNER_GRACE_MS = 10000;
|
|
9
|
+
/**
|
|
10
|
+
* Choke point every other helper in this module goes through to reach a
|
|
11
|
+
* watchdog's on-disk state. Validates `name` BEFORE any join/mkdir (defense
|
|
12
|
+
* in depth, finding #1): a caller that forgets its own ROLE_NAME_RE guard
|
|
13
|
+
* (as the CLI's `watchdogKnown` once did) must not be able to turn an
|
|
14
|
+
* unvalidated `name` into `join(watchdogsRoot(), '../../victim')` and mkdir
|
|
15
|
+
* an arbitrary path on disk.
|
|
16
|
+
*/
|
|
17
|
+
export declare function watchdogDir(name: string): string;
|
|
18
|
+
export declare function reportsDir(name: string): string;
|
|
19
|
+
export interface RunLockOwner {
|
|
20
|
+
pid: number;
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* mkdir-as-mutex: atomic across processes, unlike a lock file (open+O_EXCL
|
|
25
|
+
* would work too, but a directory needs no cleanup of file contents and
|
|
26
|
+
* can't be partially written). EEXIST means another run holds it.
|
|
27
|
+
*
|
|
28
|
+
* Stamps `owner.json` with our pid (finding #2): ownership metadata is what
|
|
29
|
+
* lets a later `reclaimStaleRunLock` tell a lock abandoned by a dead process
|
|
30
|
+
* apart from one genuinely held by a live run. A naive "mkdir, then
|
|
31
|
+
* writeFileSync(owner.json)" leaves an unnecessarily wide window —
|
|
32
|
+
* between the mkdir succeeding and the write landing — where the lock dir
|
|
33
|
+
* exists but owner.json doesn't yet. A `reclaimStaleRunLock` call from
|
|
34
|
+
* another process landing in exactly that window would see a lock with no
|
|
35
|
+
* (or corrupt) owner metadata and treat a genuinely live lock as a legacy
|
|
36
|
+
* one, reclaiming it out from under us. Do all slow work (building the JSON,
|
|
37
|
+
* writing it, chmod) to a per-pid temp file BEFORE mkdir, then publish it with
|
|
38
|
+
* one rename. There is still an unavoidable interval between those two
|
|
39
|
+
* syscalls; reclaimStaleRunLock protects it by treating a fresh ownerless lock
|
|
40
|
+
* as held for RUN_LOCK_OWNER_GRACE_MS. The temp file is unique per-pid (this
|
|
41
|
+
* function is synchronous, so there's no same-process concurrent-call hazard
|
|
42
|
+
* either) and is cleaned up if mkdir loses the race.
|
|
43
|
+
*/
|
|
44
|
+
export declare function acquireRunLock(name: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Release-tolerant of absence: a lock already gone (or never acquired) is not
|
|
47
|
+
* an error. Recursive because the lock dir now holds `owner.json` alongside
|
|
48
|
+
* the mkdir mutex itself (finding #2) — a plain rmdir would fail ENOTEMPTY.
|
|
49
|
+
*/
|
|
50
|
+
export declare function releaseRunLock(name: string): void;
|
|
51
|
+
/** Reads a run lock's owner metadata; missing or corrupt yields undefined. */
|
|
52
|
+
export declare function readRunLockOwner(name: string): RunLockOwner | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Owner-mandated (finding #2): only a DEMONSTRABLY stale run lock may be
|
|
55
|
+
* reclaimed — a lock is stale iff it isn't held at all, its owner metadata
|
|
56
|
+
* names a dead pid, or its owner metadata is missing/corrupt AND the lock dir
|
|
57
|
+
* is older than RUN_LOCK_OWNER_GRACE_MS. The age gate closes the interprocess
|
|
58
|
+
* interval between acquireRunLock's mkdir and owner.json rename: a concurrent
|
|
59
|
+
* scheduler sees a fresh ownerless lock as held, not stale. A live owner (a
|
|
60
|
+
* foreground `watchdog-run`, another scheduler instance, an in-progress run)
|
|
61
|
+
* is left strictly alone: reclaiming it would let two runs share the same temp
|
|
62
|
+
* dir.
|
|
63
|
+
* Returns true when the lock is (now) not held — whether because it was
|
|
64
|
+
* already absent or because a stale lock was just removed; false when a live
|
|
65
|
+
* lock was found and deliberately left in place.
|
|
66
|
+
*/
|
|
67
|
+
export declare function reclaimStaleRunLock(name: string): boolean;
|
|
68
|
+
/** Lexical-chronological UTC run id, e.g. '20260731T115000Z'. */
|
|
69
|
+
export declare function formatRunId(d: Date): string;
|
|
70
|
+
export interface RunListEntry {
|
|
71
|
+
runId: string;
|
|
72
|
+
status: WatchdogReportStatus;
|
|
73
|
+
startedAt: string;
|
|
74
|
+
finishedAt: string;
|
|
75
|
+
summary: WatchdogReport['summary'];
|
|
76
|
+
error: string | null;
|
|
77
|
+
}
|
|
78
|
+
export declare function writeReport(name: string, report: WatchdogReport): string;
|
|
79
|
+
/** Newest-first run listing; a corrupt report file yields a synthetic 'error' entry rather than throwing. */
|
|
80
|
+
export declare function listRuns(name: string): RunListEntry[];
|
|
81
|
+
/** Reads one run's full report. Rejects non-conforming runIds (path-traversal guard) and corrupt files by returning undefined. */
|
|
82
|
+
export declare function readReport(name: string, runId: string): WatchdogReport | undefined;
|
|
83
|
+
export declare function latestReport(name: string): WatchdogReport | undefined;
|
|
84
|
+
/** Deletes all but the `keep` newest reports (oldest first); returns the number pruned. */
|
|
85
|
+
export declare function pruneReports(name: string, keep: number): number;
|