@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
package/dist/spawn.js
CHANGED
|
@@ -4,18 +4,33 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
|
-
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveWorklogPolicy, } from './config.js';
|
|
7
|
+
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
|
|
8
8
|
import { applyRole, up } from './ops.js';
|
|
9
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
10
|
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
11
|
import { VERSION } from './version.js';
|
|
12
|
+
import './harness/claude-code.js';
|
|
13
|
+
import './harness/codex.js';
|
|
12
14
|
import { getAdapter } from './harness/registry.js';
|
|
13
15
|
/**
|
|
14
16
|
* The provenance record written by the most recent spawn in this process, so
|
|
15
17
|
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
16
18
|
*/
|
|
17
19
|
export let lastProvenance;
|
|
18
|
-
function
|
|
20
|
+
export function profileValues(o) {
|
|
21
|
+
if (o.bio !== undefined && o.bioFile)
|
|
22
|
+
throw new Error('bio and bioFile are mutually exclusive');
|
|
23
|
+
if (o.persona !== undefined && o.personaFile)
|
|
24
|
+
throw new Error('persona and personaFile are mutually exclusive');
|
|
25
|
+
return {
|
|
26
|
+
bio: o.bio !== undefined ? o.bio.trim()
|
|
27
|
+
: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
28
|
+
persona: o.persona !== undefined ? o.persona.trim()
|
|
29
|
+
: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Pure option-to-role mapping shared by CLI and application services. */
|
|
33
|
+
export function buildRoleConfig(o, defaultHarness) {
|
|
19
34
|
const r = {};
|
|
20
35
|
if (o.harness)
|
|
21
36
|
r.harness = o.harness;
|
|
@@ -31,7 +46,9 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
31
46
|
r.mission = readMissionFile(o.missionFile);
|
|
32
47
|
else if (o.mission !== undefined)
|
|
33
48
|
r.mission = o.mission;
|
|
34
|
-
if (o.model
|
|
49
|
+
if (o.model === null)
|
|
50
|
+
r.model = null;
|
|
51
|
+
else if (o.model?.trim())
|
|
35
52
|
r.model = o.model.trim();
|
|
36
53
|
const harness = o.harness ?? defaultHarness;
|
|
37
54
|
const harnessOptions = {};
|
|
@@ -60,12 +77,15 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
60
77
|
...(o.unattended ? { unattended: o.unattended } : {}),
|
|
61
78
|
};
|
|
62
79
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const profile = profileValues(o);
|
|
81
|
+
if (profile.bio)
|
|
82
|
+
r.bio = profile.bio;
|
|
83
|
+
if (profile.persona)
|
|
84
|
+
r.persona = profile.persona;
|
|
67
85
|
if (o.isolationFile)
|
|
68
86
|
r.isolation = readIsolationFile(o.isolationFile);
|
|
87
|
+
if (o.monitorConfig)
|
|
88
|
+
r.monitor = { ...o.monitorConfig };
|
|
69
89
|
return r;
|
|
70
90
|
}
|
|
71
91
|
/**
|
|
@@ -93,7 +113,7 @@ export function readIsolationFile(path) {
|
|
|
93
113
|
throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
|
|
94
114
|
return cfg;
|
|
95
115
|
}
|
|
96
|
-
function validateSpawnOpts(o) {
|
|
116
|
+
export function validateSpawnOpts(o) {
|
|
97
117
|
if (o.mission !== undefined && o.missionFile)
|
|
98
118
|
throw new Error('--mission and --mission-file are mutually exclusive');
|
|
99
119
|
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
@@ -104,6 +124,17 @@ function validateSpawnOpts(o) {
|
|
|
104
124
|
throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
|
|
105
125
|
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
106
126
|
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
127
|
+
if (!/^[A-Za-z0-9_-]+$/.test(o.name))
|
|
128
|
+
throw new Error(`invalid role name '${o.name}'`);
|
|
129
|
+
if (o.identity && !/^[A-Za-z0-9_-]+$/.test(o.identity))
|
|
130
|
+
throw new Error(`invalid identity name '${o.identity}'`);
|
|
131
|
+
if (o.model && (o.model.length > 128 || /[\0-\x1f\x7f]/.test(o.model)))
|
|
132
|
+
throw new Error('model must be printable and at most 128 characters');
|
|
133
|
+
if (o.monitorConfig) {
|
|
134
|
+
const problems = validateMonitorConfig(o.monitorConfig);
|
|
135
|
+
if (problems.length)
|
|
136
|
+
throw new Error(problems.join('; '));
|
|
137
|
+
}
|
|
107
138
|
}
|
|
108
139
|
/** Read mission text without trimming or newline rewriting. */
|
|
109
140
|
export function readMissionFile(path) {
|
|
@@ -146,23 +177,29 @@ export function spawnDryRun(o) {
|
|
|
146
177
|
readMissionFile(o.missionFile);
|
|
147
178
|
assertNameFree(o);
|
|
148
179
|
const cfg = loadConfig(o.configPath);
|
|
149
|
-
const raw =
|
|
180
|
+
const raw = buildRoleConfig(o, cfg.defaults.harness);
|
|
150
181
|
const harnessOptions = {
|
|
151
182
|
...(cfg.defaults.harness_options ?? {}),
|
|
152
183
|
...(raw.harness_options ?? {}),
|
|
153
184
|
};
|
|
185
|
+
const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
|
|
186
|
+
const defaultHarness = cfg.defaults.harness ?? 'claude-code';
|
|
187
|
+
const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
|
|
188
|
+
const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
|
|
154
189
|
const resolvedRole = {
|
|
155
190
|
...raw,
|
|
156
191
|
name: o.name,
|
|
157
192
|
sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
|
|
158
|
-
harness
|
|
193
|
+
harness,
|
|
159
194
|
session: raw.session ?? cfg.defaults.session ?? 'tmux',
|
|
160
195
|
session_options: raw.session_options,
|
|
161
196
|
permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
|
|
162
197
|
permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
163
198
|
identity: effectiveIdentity(o),
|
|
164
|
-
model
|
|
165
|
-
model_chain: resolveModelChain(
|
|
199
|
+
model,
|
|
200
|
+
model_chain: resolveModelChain(model, raw.model_chain ?? (inheritsModelDefaults
|
|
201
|
+
? cfg.defaults.model_chain
|
|
202
|
+
: undefined)),
|
|
166
203
|
harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
|
|
167
204
|
isolation: raw.isolation ?? cfg.defaults.isolation,
|
|
168
205
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
|
|
@@ -199,6 +236,8 @@ export function spawnDryRun(o) {
|
|
|
199
236
|
*/
|
|
200
237
|
function provenanceSettings(o, defaults) {
|
|
201
238
|
const perms = (defaults.permissions ?? {});
|
|
239
|
+
const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
|
|
240
|
+
const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
|
|
202
241
|
return {
|
|
203
242
|
harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
|
|
204
243
|
session: provenanceOf(o.session, defaults.session, 'tmux'),
|
|
@@ -206,7 +245,11 @@ function provenanceSettings(o, defaults) {
|
|
|
206
245
|
? { value: o.identity, source: 'cli' }
|
|
207
246
|
: { value: o.name, source: 'built-in' }, // defaults to the role name
|
|
208
247
|
cwd: provenanceOf(o.cwd, undefined, undefined),
|
|
209
|
-
model:
|
|
248
|
+
model: o.model === null
|
|
249
|
+
? { value: undefined, source: 'cli' }
|
|
250
|
+
: explicitModel
|
|
251
|
+
? { value: explicitModel, source: 'cli' }
|
|
252
|
+
: { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
|
|
210
253
|
coordinator: provenanceOf(o.coordinator, undefined, undefined),
|
|
211
254
|
approval: provenanceOf(o.approval, perms.approval, 'ask'),
|
|
212
255
|
filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
|
|
@@ -214,6 +257,7 @@ function provenanceSettings(o, defaults) {
|
|
|
214
257
|
isolation: o.isolationFile
|
|
215
258
|
? { value: 'declared via --isolation-file', source: 'cli' }
|
|
216
259
|
: { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
|
|
260
|
+
monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
|
|
217
261
|
};
|
|
218
262
|
}
|
|
219
263
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
@@ -225,13 +269,17 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
225
269
|
readMissionFile(o.missionFile); // fail before reserving
|
|
226
270
|
// Name AND identity reserved together, before anything is written or started
|
|
227
271
|
// (6.4). A loser of the race creates no config, no state, no service.
|
|
272
|
+
creation.onStage?.('reserving');
|
|
228
273
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
229
274
|
assertNameFree(o);
|
|
230
275
|
const cfg = loadConfig(o.configPath);
|
|
231
276
|
// Establish the identity BEFORE the service is enabled (7.3), and record
|
|
232
277
|
// what was actually guaranteed so the briefing can say something true.
|
|
233
|
-
|
|
234
|
-
|
|
278
|
+
creation.onStage?.('checking_identity');
|
|
279
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
280
|
+
creation.onStage?.('checking_identity', {
|
|
281
|
+
result: guarantee.evidence, guarantee: guarantee.state,
|
|
282
|
+
});
|
|
235
283
|
if (guarantee.state === 'created')
|
|
236
284
|
// We minted it; a failed creation must not leave an orphan identity
|
|
237
285
|
// behind. Only ever removes an identity THIS transaction created.
|
|
@@ -242,9 +290,10 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
242
290
|
},
|
|
243
291
|
});
|
|
244
292
|
mkdirSync(fleetDDir(), { recursive: true });
|
|
293
|
+
creation.onStage?.('writing_role');
|
|
245
294
|
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
246
295
|
writeRoleFile(tx, file, stringify({
|
|
247
|
-
roles: { [o.name]:
|
|
296
|
+
roles: { [o.name]: buildRoleConfig(o, cfg.defaults.harness) },
|
|
248
297
|
}));
|
|
249
298
|
// `up` materialises the state dir and registers the service. Journal the
|
|
250
299
|
// dir before it exists so a failure leaves the name genuinely reusable
|
|
@@ -272,9 +321,11 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
272
321
|
const provenance = buildProvenance({
|
|
273
322
|
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
274
323
|
settings: provenanceSettings(o, cfg.defaults),
|
|
324
|
+
surface: o.surface, creationActionId: o.creationActionId,
|
|
275
325
|
});
|
|
276
326
|
mkdirSync(agentDir(o.name), { recursive: true });
|
|
277
327
|
writeProvenance(agentDir(o.name), provenance);
|
|
328
|
+
creation.onStage?.('registering_supervisor');
|
|
278
329
|
await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
|
|
279
330
|
lastProvenance = provenance;
|
|
280
331
|
return file;
|
|
@@ -298,29 +349,45 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor, creatio
|
|
|
298
349
|
readMissionFile(o.missionFile); // fail before reserving
|
|
299
350
|
// Temporary roles go through the SAME reservation boundary as permanent ones
|
|
300
351
|
// (6.4): a temp agent competes for the same names.
|
|
352
|
+
creation.onStage?.('reserving');
|
|
301
353
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
354
|
+
creation.onStage?.('checking_identity');
|
|
302
355
|
assertNameFree(o);
|
|
303
|
-
const guarantee = await ensureIdentity(effectiveIdentity(o),
|
|
304
|
-
|
|
305
|
-
|
|
356
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
|
|
357
|
+
creation.onStage?.('checking_identity', {
|
|
358
|
+
result: guarantee.evidence, guarantee: guarantee.state,
|
|
359
|
+
});
|
|
360
|
+
if (guarantee.state === 'created')
|
|
361
|
+
tx.record({
|
|
362
|
+
stage: `ours identity ${effectiveIdentity(o)}`,
|
|
363
|
+
undo: async () => {
|
|
364
|
+
await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
return spawnTempInner(o, binPath, launch, tx, guarantee, creation.onStage);
|
|
306
368
|
}, creation);
|
|
307
369
|
}
|
|
308
|
-
async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
370
|
+
async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
309
371
|
const cfg = loadConfig(o.configPath);
|
|
310
372
|
const defaultHarness = cfg.defaults.harness;
|
|
311
|
-
const fromOpts =
|
|
373
|
+
const fromOpts = buildRoleConfig(o, defaultHarness);
|
|
312
374
|
const mergedHarnessOptions = {
|
|
313
375
|
...(cfg.defaults.harness_options ?? {}),
|
|
314
376
|
...(fromOpts.harness_options ?? {}),
|
|
315
377
|
};
|
|
378
|
+
const harness = o.harness ?? defaultHarness ?? 'claude-code';
|
|
379
|
+
const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
|
|
380
|
+
const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
|
|
316
381
|
const role = {
|
|
317
382
|
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
318
383
|
name: o.name,
|
|
319
|
-
harness
|
|
384
|
+
harness,
|
|
320
385
|
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
321
386
|
identity: o.identity ?? o.name,
|
|
322
|
-
model
|
|
323
|
-
model_chain: resolveModelChain(
|
|
387
|
+
model,
|
|
388
|
+
model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
|
|
389
|
+
? cfg.defaults.model_chain
|
|
390
|
+
: undefined)),
|
|
324
391
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
325
392
|
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
326
393
|
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
@@ -337,10 +404,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
|
337
404
|
};
|
|
338
405
|
if (role.auth_proxy && role.harness !== 'claude-code')
|
|
339
406
|
throw new Error('auth_proxy is supported only by claude-code');
|
|
407
|
+
onStage?.('writing_role');
|
|
340
408
|
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
341
409
|
const provenance = buildProvenance({
|
|
342
410
|
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
343
411
|
settings: provenanceSettings(o, cfg.defaults),
|
|
412
|
+
surface: o.surface, creationActionId: o.creationActionId,
|
|
344
413
|
});
|
|
345
414
|
writeProvenance(dir, provenance);
|
|
346
415
|
lastProvenance = provenance;
|
|
@@ -356,6 +425,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
|
356
425
|
// agent itself; a supervisor sharing that session name would SIGHUP its own
|
|
357
426
|
// process before the agent ever launches. Detaching mirrors how systemd hosts
|
|
358
427
|
// the supervisor for permanent roles, leaving runOnce to own the <name> session.
|
|
428
|
+
onStage?.('starting_temp');
|
|
359
429
|
launch(binPath, ['_run-temp', o.name], dir);
|
|
360
430
|
return dir;
|
|
361
431
|
}
|
|
@@ -177,6 +177,23 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
177
177
|
return { state: 'stopped', detail: `not loaded (${labelFor(name)})` };
|
|
178
178
|
return { state: 'unknown', detail: job.failure ?? `launchctl print ${labelFor(name)} failed` };
|
|
179
179
|
},
|
|
180
|
+
async inspect(name) {
|
|
181
|
+
const job = await printJob(name);
|
|
182
|
+
if (job.loaded)
|
|
183
|
+
return {
|
|
184
|
+
backend: 'launchd', state: 'running',
|
|
185
|
+
nativeState: job.state, detail: job.state ? `loaded (state = ${job.state})` : 'loaded',
|
|
186
|
+
};
|
|
187
|
+
if (job.notFound)
|
|
188
|
+
return {
|
|
189
|
+
backend: 'launchd', state: 'stopped',
|
|
190
|
+
nativeState: 'not-loaded', detail: `not loaded (${labelFor(name)})`,
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
backend: 'launchd', state: 'unknown',
|
|
194
|
+
detail: job.failure ?? `launchctl print ${labelFor(name)} failed`,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
180
197
|
async uninstall(name) {
|
|
181
198
|
const existed = existsSync(plistPath(name));
|
|
182
199
|
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // idempotent
|
package/dist/supervisor/none.js
CHANGED
|
@@ -31,6 +31,23 @@ export function makeNoneBackend(exec = realExec) {
|
|
|
31
31
|
return { state: 'stopped', detail: `no tmux session '${name}'` };
|
|
32
32
|
return { state: 'unknown', detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}` };
|
|
33
33
|
},
|
|
34
|
+
async inspect(name) {
|
|
35
|
+
const r = await exec('tmux', tmuxArgs(name, ['has-session', '-t', name]));
|
|
36
|
+
if (r.code === 0)
|
|
37
|
+
return {
|
|
38
|
+
backend: 'none', state: 'running',
|
|
39
|
+
nativeState: 'tmux-present', detail: `tmux session '${name}' exists`,
|
|
40
|
+
};
|
|
41
|
+
if (r.code === 1)
|
|
42
|
+
return {
|
|
43
|
+
backend: 'none', state: 'stopped',
|
|
44
|
+
nativeState: 'tmux-absent', detail: `no tmux session '${name}'`,
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
backend: 'none', state: 'unknown',
|
|
48
|
+
detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}`,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
34
51
|
async uninstall(name) {
|
|
35
52
|
const killed = await tmux.kill(name); // idempotent
|
|
36
53
|
return killed
|
|
@@ -148,6 +148,10 @@ WantedBy=default.target
|
|
|
148
148
|
return r.stdout || r.stderr;
|
|
149
149
|
},
|
|
150
150
|
liveness(name) { return probeLiveness(ctl, name); },
|
|
151
|
+
async inspect(name) {
|
|
152
|
+
const live = await probeLiveness(ctl, name);
|
|
153
|
+
return { backend: 'systemd', ...live, nativeState: live.detail.split(/\s/)[0] };
|
|
154
|
+
},
|
|
151
155
|
async uninstall(name) {
|
|
152
156
|
const before = await ctl('is-enabled', unitFor(name));
|
|
153
157
|
const wasEnabled = before.stdout.trim() === 'enabled';
|
|
@@ -19,6 +19,10 @@ export interface UninstallOutcome {
|
|
|
19
19
|
removed: boolean;
|
|
20
20
|
detail: string;
|
|
21
21
|
}
|
|
22
|
+
export interface SupervisorInspection extends Liveness {
|
|
23
|
+
backend: 'systemd' | 'launchd' | 'none';
|
|
24
|
+
nativeState?: string;
|
|
25
|
+
}
|
|
22
26
|
export interface SupervisorBackend {
|
|
23
27
|
id: 'systemd' | 'launchd' | 'none';
|
|
24
28
|
/** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
|
|
@@ -40,6 +44,8 @@ export interface SupervisorBackend {
|
|
|
40
44
|
* probe is `unknown` with the failure in `detail`.
|
|
41
45
|
*/
|
|
42
46
|
liveness(name: string): Promise<Liveness>;
|
|
47
|
+
/** Structured machine-derived status for application-service consumers. */
|
|
48
|
+
inspect?(name: string): Promise<SupervisorInspection>;
|
|
43
49
|
/** Remove the registration. Idempotent; reports whether anything was there. */
|
|
44
50
|
uninstall(name: string): Promise<UninstallOutcome>;
|
|
45
51
|
/** Command the CLI execs (stdio inherited) to show logs. */
|
package/dist/tmux.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export declare class Tmux {
|
|
|
33
33
|
*/
|
|
34
34
|
kill(name: string): Promise<boolean>;
|
|
35
35
|
capture(name: string, lines?: number): Promise<string>;
|
|
36
|
+
/** Bounded ANSI/history seed for the browser terminal projection. */
|
|
37
|
+
captureHistory(name: string, lines?: number): Promise<string>;
|
|
36
38
|
panePid(name: string): Promise<number | null>;
|
|
37
39
|
/**
|
|
38
40
|
* List the live sessions among `names`, asking each server in turn.
|
package/dist/tmux.js
CHANGED
|
@@ -49,6 +49,14 @@ export class Tmux {
|
|
|
49
49
|
const all = r.stdout.replace(/\n+$/, '').split('\n');
|
|
50
50
|
return all.slice(-lines).join('\n');
|
|
51
51
|
}
|
|
52
|
+
/** Bounded ANSI/history seed for the browser terminal projection. */
|
|
53
|
+
async captureHistory(name, lines = 5_000) {
|
|
54
|
+
const bounded = Math.min(Math.max(Math.trunc(lines), 1), 20_000);
|
|
55
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p', '-e', '-J', '-S', `-${bounded}`]));
|
|
56
|
+
if (r.code !== 0)
|
|
57
|
+
throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
|
|
58
|
+
return Buffer.from(r.stdout).subarray(0, 4 * 1024 * 1024).toString();
|
|
59
|
+
}
|
|
52
60
|
async panePid(name) {
|
|
53
61
|
const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
|
|
54
62
|
if (r.code !== 0)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WatchdogReport, WatchdogRoleStatus } from './report.js';
|
|
2
|
+
import type { WatchManifest } from './briefing.js';
|
|
3
|
+
/**
|
|
4
|
+
* Severity ordering, lowest to highest (must match the briefing contract's alert-rules
|
|
5
|
+
* sentence verbatim, src/watchdog/briefing.ts): healthy = idle (0) < unknown (1) < stale (2)
|
|
6
|
+
* < blocked = unreachable (3) < off_briefing (4).
|
|
7
|
+
*/
|
|
8
|
+
export declare const WATCHDOG_STATUS_RANK: Record<WatchdogRoleStatus, number>;
|
|
9
|
+
export interface OpenFinding {
|
|
10
|
+
role: string;
|
|
11
|
+
status: WatchdogRoleStatus;
|
|
12
|
+
since: string;
|
|
13
|
+
lastAlertedAt: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface AlertLedger {
|
|
16
|
+
version: 1;
|
|
17
|
+
open: Record<string, OpenFinding>;
|
|
18
|
+
heldDownAlerted: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Missing or corrupt ledger file yields a clean empty ledger rather than throwing. */
|
|
21
|
+
export declare function readLedger(name: string): AlertLedger;
|
|
22
|
+
export declare function writeLedger(name: string, l: AlertLedger): void;
|
|
23
|
+
/**
|
|
24
|
+
* Reconcile rules (spec §5): an `error`-status report carries no role evidence, so the ledger
|
|
25
|
+
* is returned unchanged. For each finding with rank > 0: an existing open entry keeps its
|
|
26
|
+
* `since` and updates `status` on escalation/de-escalation; a new one opens with `since = now`.
|
|
27
|
+
* Any role reported `healthy`/`idle` closes (deletes) its open entry. Every role named in
|
|
28
|
+
* `report.alerts` gets `lastAlertedAt = now`. Roles absent from the report keep their entries —
|
|
29
|
+
* a watchdog with a narrowed `watch:` set doesn't silently resolve findings it never inspected.
|
|
30
|
+
* Never mutates `l` — always returns a new ledger object.
|
|
31
|
+
*/
|
|
32
|
+
export declare function reconcileLedger(l: AlertLedger, report: WatchdogReport, now: Date): AlertLedger;
|
|
33
|
+
/** Ledger's open findings + cooldown, in the shape watch.json's `digest` field carries (Task 7 writes it, run reads it back). */
|
|
34
|
+
export declare function computeDigest(l: AlertLedger, cooldownMs: number, now: Date): WatchManifest['digest'];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { chmodSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { watchdogDir } from './store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Severity ordering, lowest to highest (must match the briefing contract's alert-rules
|
|
6
|
+
* sentence verbatim, src/watchdog/briefing.ts): healthy = idle (0) < unknown (1) < stale (2)
|
|
7
|
+
* < blocked = unreachable (3) < off_briefing (4).
|
|
8
|
+
*/
|
|
9
|
+
export const WATCHDOG_STATUS_RANK = {
|
|
10
|
+
healthy: 0, idle: 0, unknown: 1, stale: 2, blocked: 3, unreachable: 3, off_briefing: 4,
|
|
11
|
+
};
|
|
12
|
+
function emptyLedger() {
|
|
13
|
+
return { version: 1, open: {}, heldDownAlerted: false };
|
|
14
|
+
}
|
|
15
|
+
function ledgerPath(name) {
|
|
16
|
+
return join(watchdogDir(name), 'alerts.json');
|
|
17
|
+
}
|
|
18
|
+
/** Missing or corrupt ledger file yields a clean empty ledger rather than throwing. */
|
|
19
|
+
export function readLedger(name) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(ledgerPath(name), 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return emptyLedger();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function writeLedger(name, l) {
|
|
28
|
+
const path = ledgerPath(name);
|
|
29
|
+
writeFileSync(path, JSON.stringify(l, null, 2) + '\n', { mode: 0o600 });
|
|
30
|
+
chmodSync(path, 0o600);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile rules (spec §5): an `error`-status report carries no role evidence, so the ledger
|
|
34
|
+
* is returned unchanged. For each finding with rank > 0: an existing open entry keeps its
|
|
35
|
+
* `since` and updates `status` on escalation/de-escalation; a new one opens with `since = now`.
|
|
36
|
+
* Any role reported `healthy`/`idle` closes (deletes) its open entry. Every role named in
|
|
37
|
+
* `report.alerts` gets `lastAlertedAt = now`. Roles absent from the report keep their entries —
|
|
38
|
+
* a watchdog with a narrowed `watch:` set doesn't silently resolve findings it never inspected.
|
|
39
|
+
* Never mutates `l` — always returns a new ledger object.
|
|
40
|
+
*/
|
|
41
|
+
export function reconcileLedger(l, report, now) {
|
|
42
|
+
if (report.status === 'error')
|
|
43
|
+
return l;
|
|
44
|
+
const open = { ...l.open };
|
|
45
|
+
const nowIso = now.toISOString();
|
|
46
|
+
for (const finding of report.roles) {
|
|
47
|
+
const rank = WATCHDOG_STATUS_RANK[finding.status];
|
|
48
|
+
if (rank > 0) {
|
|
49
|
+
const existing = open[finding.role];
|
|
50
|
+
open[finding.role] = {
|
|
51
|
+
role: finding.role,
|
|
52
|
+
status: finding.status,
|
|
53
|
+
since: existing ? existing.since : nowIso,
|
|
54
|
+
lastAlertedAt: existing ? existing.lastAlertedAt : null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
delete open[finding.role];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const alert of report.alerts) {
|
|
62
|
+
if (open[alert.role])
|
|
63
|
+
open[alert.role] = { ...open[alert.role], lastAlertedAt: nowIso };
|
|
64
|
+
}
|
|
65
|
+
return { ...l, open };
|
|
66
|
+
}
|
|
67
|
+
/** Ledger's open findings + cooldown, in the shape watch.json's `digest` field carries (Task 7 writes it, run reads it back). */
|
|
68
|
+
export function computeDigest(l, cooldownMs, now) {
|
|
69
|
+
return {
|
|
70
|
+
cooldown_ms: cooldownMs,
|
|
71
|
+
open: Object.values(l.open).map(f => ({
|
|
72
|
+
role: f.role,
|
|
73
|
+
status: f.status,
|
|
74
|
+
since: f.since,
|
|
75
|
+
realert_after: f.lastAlertedAt ? new Date(new Date(f.lastAlertedAt).getTime() + cooldownMs).toISOString() : null,
|
|
76
|
+
})),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ResolvedWatchdog } from './config.js';
|
|
2
|
+
import type { BriefingVocab } from '../harness/types.js';
|
|
3
|
+
/** One entry in `watch.json`'s `roles` array — where to find a watched role's state. */
|
|
4
|
+
export interface WatchManifestRole {
|
|
5
|
+
name: string;
|
|
6
|
+
stateDir: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The fixed run manifest a watchdog run reads at `manifestPath` (7.3 §3, Task 7 writes it,
|
|
10
|
+
* Task 12+ digest reads it back). It carries everything the run needs to identify itself in
|
|
11
|
+
* report.json (`watchdog`, `run_id`, `started_at`) plus the suppression digest that makes
|
|
12
|
+
* alerting idempotent across runs.
|
|
13
|
+
*/
|
|
14
|
+
export interface WatchManifest {
|
|
15
|
+
watchdog: string;
|
|
16
|
+
run_id: string;
|
|
17
|
+
coordinator: string;
|
|
18
|
+
started_at: string;
|
|
19
|
+
roles: WatchManifestRole[];
|
|
20
|
+
digest: {
|
|
21
|
+
cooldown_ms: number;
|
|
22
|
+
open: Array<{
|
|
23
|
+
role: string;
|
|
24
|
+
status: string;
|
|
25
|
+
since: string;
|
|
26
|
+
realert_after: string | null;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface WatchdogBriefingOpts {
|
|
31
|
+
wd: ResolvedWatchdog;
|
|
32
|
+
manifestPath: string;
|
|
33
|
+
reportPath: string;
|
|
34
|
+
vocabulary: BriefingVocab;
|
|
35
|
+
/** What spawn actually established about the watchdog's ours identity (7.3, decision 3). */
|
|
36
|
+
identityGuarantee: 'verified' | 'created' | 'unverified';
|
|
37
|
+
/** Raw prompt_file content, appended as extra focus — never a replacement (owner decision 1). */
|
|
38
|
+
promptFocus?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
|
|
42
|
+
* run): bind, observe-only rules, procedure, status vocabulary, evidence rules, alert rules,
|
|
43
|
+
* report schema, and — if the watchdog configures one — an appended prompt_file focus.
|
|
44
|
+
*
|
|
45
|
+
* The contract is fixed for every watchdog (spec §5: "The schema is fixed for every watchdog,
|
|
46
|
+
* including ones with a prompt_file; the override adds focus, never fields"), so every section
|
|
47
|
+
* below is unconditional except the identity-guarantee wording and the trailing focus append.
|
|
48
|
+
*/
|
|
49
|
+
export declare function generateWatchdogBriefing(opts: WatchdogBriefingOpts): string;
|
|
50
|
+
export interface NotifierBriefingOpts {
|
|
51
|
+
wd: ResolvedWatchdog;
|
|
52
|
+
vocabulary: BriefingVocab;
|
|
53
|
+
/** What spawn actually established about the watchdog's ours identity (7.3, decision 3). */
|
|
54
|
+
identityGuarantee: 'verified' | 'created' | 'unverified';
|
|
55
|
+
/** The exact message to relay — the scheduler composed this, the notifier only delivers it. */
|
|
56
|
+
text: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Render a scheduler-alert notifier run's entire contract: a minimal one-shot agent whose sole
|
|
60
|
+
* job is to bind its identity, send one exact message to the coordinator, and write `sent.json`
|
|
61
|
+
* as its completion sentinel. Used only for scheduler-level alerts (e.g. held-down) — the fleet
|
|
62
|
+
* process itself cannot send ours messages (deviation 4), so this is how it delegates the send.
|
|
63
|
+
* Unlike `generateWatchdogBriefing`, there is no report.json, no manifest, and no inspection.
|
|
64
|
+
*/
|
|
65
|
+
export declare function generateNotifierBriefing(opts: NotifierBriefingOpts): string;
|