harnery 0.15.0 → 0.16.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 +1 -1
- package/dist/commands/supervisor.d.ts.map +1 -1
- package/dist/commands/supervisor.js +210 -2
- package/dist/core/supervisor/index.d.ts +1 -0
- package/dist/core/supervisor/index.d.ts.map +1 -1
- package/dist/core/supervisor/index.js +1 -0
- package/dist/core/supervisor/read.d.ts +2 -0
- package/dist/core/supervisor/read.d.ts.map +1 -1
- package/dist/core/supervisor/read.js +1 -0
- package/dist/core/supervisor/service-read.d.ts +6 -0
- package/dist/core/supervisor/service-read.d.ts.map +1 -0
- package/dist/core/supervisor/service-read.js +127 -0
- package/dist/core/supervisor/service.d.ts +114 -0
- package/dist/core/supervisor/service.d.ts.map +1 -0
- package/dist/core/supervisor/service.js +699 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +6 -1
- package/package.json +1 -1
- package/src/commands/supervisor.ts +261 -1
- package/src/core/supervisor/index.ts +27 -0
- package/src/core/supervisor/read.ts +15 -0
- package/src/core/supervisor/service-read.ts +144 -0
- package/src/core/supervisor/service.ts +920 -0
- package/src/lib/tunnel/gate.ts +7 -1
package/dist/lib/tunnel/gate.js
CHANGED
|
@@ -38,13 +38,18 @@ const server = Bun.serve({
|
|
|
38
38
|
// Bun's max, and beyond any realistic single-turn delay.
|
|
39
39
|
idleTimeout: 255,
|
|
40
40
|
async fetch(req, server) {
|
|
41
|
+
const url = new URL(req.url);
|
|
41
42
|
if (ACCESS === "cloudflare-allowlist") {
|
|
42
43
|
const ip = req.headers.get("cf-connecting-ip") ?? "";
|
|
43
44
|
if (!ALLOW.has(ip)) {
|
|
45
|
+
// Log denials so operators can whitelist a phone/laptop that just
|
|
46
|
+
// hit 403 without asking the human to dig up their public IP.
|
|
47
|
+
console.log(
|
|
48
|
+
// lint-ok-emission: detached worker, see file note above
|
|
49
|
+
`deny: ${ip || "(missing-cf-connecting-ip)"} ${req.method} ${url.pathname}`);
|
|
44
50
|
return new Response("403 Forbidden\n", { status: 403 });
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
|
-
const url = new URL(req.url);
|
|
48
53
|
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
49
54
|
if (server.upgrade(req, { data: { path: url.pathname + url.search } })) {
|
|
50
55
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { Command } from "commander";
|
|
4
4
|
import type { EmitContext } from "../commander.ts";
|
|
@@ -8,12 +8,21 @@ import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
|
|
|
8
8
|
import type { PolicyIsolation } from "../core/policy/index.ts";
|
|
9
9
|
import { loadPolicyFile } from "../core/policy/index.ts";
|
|
10
10
|
import {
|
|
11
|
+
configureSupervisorService,
|
|
11
12
|
createSupervisor,
|
|
12
13
|
listSupervisors,
|
|
13
14
|
readSupervisor,
|
|
15
|
+
readSupervisorServiceConfig,
|
|
16
|
+
readSupervisorServiceStatus,
|
|
17
|
+
requestSupervisorServiceStop,
|
|
14
18
|
runSupervisor,
|
|
19
|
+
runSupervisorServiceDaemon,
|
|
15
20
|
type SupervisorRecord,
|
|
16
21
|
type SupervisorRunReport,
|
|
22
|
+
type SupervisorServiceConfig,
|
|
23
|
+
type SupervisorServiceStatus,
|
|
24
|
+
spawnSupervisorService,
|
|
25
|
+
supervisorServiceLogPath,
|
|
17
26
|
} from "../core/supervisor/index.ts";
|
|
18
27
|
import type { WorkflowSpecialistProfile } from "../core/workflow/index.ts";
|
|
19
28
|
|
|
@@ -45,6 +54,13 @@ interface RunOpts {
|
|
|
45
54
|
json?: boolean;
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
interface ServiceOpts extends RunOpts {
|
|
58
|
+
wakeIntervalMs?: string;
|
|
59
|
+
heartbeatIntervalMs?: string;
|
|
60
|
+
errorBackoffBaseMs?: string;
|
|
61
|
+
errorBackoffMaxMs?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export function registerSupervisorCommand(program: Command, emit: EmitContext): void {
|
|
49
65
|
const registry = createBuiltinHarnessRegistry();
|
|
50
66
|
const supervisor = program
|
|
@@ -133,10 +149,250 @@ export function registerSupervisorCommand(program: Command, emit: EmitContext):
|
|
|
133
149
|
});
|
|
134
150
|
});
|
|
135
151
|
|
|
152
|
+
registerServiceCommand(supervisor, registry, emit);
|
|
136
153
|
registerRunCommand(supervisor, "tick", registry, emit);
|
|
137
154
|
registerRunCommand(supervisor, "run", registry, emit);
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
function registerServiceCommand(
|
|
158
|
+
supervisor: Command,
|
|
159
|
+
registry: ReturnType<typeof createBuiltinHarnessRegistry>,
|
|
160
|
+
emit: EmitContext,
|
|
161
|
+
): void {
|
|
162
|
+
const service = supervisor
|
|
163
|
+
.command("service")
|
|
164
|
+
.description("Run explicitly enrolled durable goals in a restartable background service.");
|
|
165
|
+
|
|
166
|
+
addServiceOptions(
|
|
167
|
+
service
|
|
168
|
+
.command("start [goal-ids...]")
|
|
169
|
+
.description("Configure and start the detached per-repository supervisor service."),
|
|
170
|
+
)
|
|
171
|
+
.option("--json", "Emit complete service status as JSON")
|
|
172
|
+
.action(async (goalIds: string[], opts: ServiceOpts) => {
|
|
173
|
+
await withSupervisorRootAsync(emit, async (coordRoot) => {
|
|
174
|
+
prepareServiceConfig(coordRoot, goalIds, opts, registry);
|
|
175
|
+
const status = await spawnSupervisorService(coordRoot);
|
|
176
|
+
emitServiceStatus(status, opts.json, emit);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
addServiceOptions(
|
|
181
|
+
service
|
|
182
|
+
.command("run [goal-ids...]")
|
|
183
|
+
.description("Run the persistent service loop in the foreground for a process manager."),
|
|
184
|
+
)
|
|
185
|
+
.option("--json", "Emit terminal service status as JSON")
|
|
186
|
+
.action(async (goalIds: string[], opts: ServiceOpts) => {
|
|
187
|
+
await withSupervisorRootAsync(emit, async (coordRoot) => {
|
|
188
|
+
const config = prepareServiceConfig(coordRoot, goalIds, opts, registry);
|
|
189
|
+
const status = await runSupervisorServiceDaemon({
|
|
190
|
+
coordRoot,
|
|
191
|
+
engine: serviceEngine(config, registry),
|
|
192
|
+
onLog: opts.json ? undefined : (line) => emit.text(`${line}\n`),
|
|
193
|
+
});
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
emit.config({ format: "json" });
|
|
196
|
+
emit.data(status);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
service
|
|
202
|
+
.command("status")
|
|
203
|
+
.description("Show service liveness, heartbeat, enrolled goals, and durable wake state.")
|
|
204
|
+
.option("--json", "Emit complete service status as JSON")
|
|
205
|
+
.action((opts: { json?: boolean }) => {
|
|
206
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
207
|
+
emitServiceStatus(readSupervisorServiceStatus(coordRoot), opts.json, emit);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
service
|
|
212
|
+
.command("stop")
|
|
213
|
+
.description("Request a graceful stop; an active goal tick is allowed to finish safely.")
|
|
214
|
+
.option("--json", "Emit complete service status as JSON")
|
|
215
|
+
.action(async (opts: { json?: boolean }) => {
|
|
216
|
+
await withSupervisorRootAsync(emit, async (coordRoot) => {
|
|
217
|
+
let status = requestSupervisorServiceStop(coordRoot);
|
|
218
|
+
const deadline = Date.now() + 5_000;
|
|
219
|
+
while (status.running && Date.now() < deadline) {
|
|
220
|
+
await delay(50);
|
|
221
|
+
status = readSupervisorServiceStatus(coordRoot);
|
|
222
|
+
}
|
|
223
|
+
emitServiceStatus(status, opts.json, emit);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
service
|
|
228
|
+
.command("logs")
|
|
229
|
+
.description("Show the tail of the private background-service log.")
|
|
230
|
+
.option("--lines <n>", "Number of lines to show (default 50)", "50")
|
|
231
|
+
.action((opts: { lines: string }) => {
|
|
232
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
233
|
+
const lines = integer(opts.lines) ?? 50;
|
|
234
|
+
if (lines > 10_000) throw new Error("service log lines must not exceed 10000");
|
|
235
|
+
const path = supervisorServiceLogPath(coordRoot);
|
|
236
|
+
if (!existsSync(path)) {
|
|
237
|
+
emit.text("no supervisor service log\n");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const selected = readFileSync(path, "utf8")
|
|
241
|
+
.split("\n")
|
|
242
|
+
.slice(-(lines + 1))
|
|
243
|
+
.join("\n");
|
|
244
|
+
emit.text(selected.endsWith("\n") ? selected : `${selected}\n`);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
service
|
|
249
|
+
.command("daemon", { hidden: true })
|
|
250
|
+
.description("Internal detached service entrypoint.")
|
|
251
|
+
.action(async () => {
|
|
252
|
+
await withSupervisorRootAsync(emit, async (coordRoot) => {
|
|
253
|
+
const config = readSupervisorServiceConfig(coordRoot);
|
|
254
|
+
await runSupervisorServiceDaemon({
|
|
255
|
+
coordRoot,
|
|
256
|
+
engine: serviceEngine(config, registry),
|
|
257
|
+
onLog: (line) => emit.text(`${line}\n`),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function addServiceOptions(command: Command): Command {
|
|
264
|
+
return command
|
|
265
|
+
.option("--wake-interval-ms <n>", "State-change poll interval (default 5000)")
|
|
266
|
+
.option("--heartbeat-interval-ms <n>", "Heartbeat interval (default 2000)")
|
|
267
|
+
.option("--error-backoff-base-ms <n>", "Initial service-error backoff (default 2000)")
|
|
268
|
+
.option("--error-backoff-max-ms <n>", "Maximum service-error backoff (default 300000)")
|
|
269
|
+
.option("--harness <name>", "Fallback harness for agent calls without a specialist")
|
|
270
|
+
.option("--cwd <dir>", "Working directory for child agents")
|
|
271
|
+
.option("--subscription-only", "Require stored harness-login billing")
|
|
272
|
+
.option("--allow-api-billing", "Permit API-key override billing")
|
|
273
|
+
.option("--policy <file>", "Host policy JSON/JSONC")
|
|
274
|
+
.option("--isolation <mode>", "shared | worktree | sandbox | remote")
|
|
275
|
+
.option("--approval-to <address>", "Address durable ASK requests");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function prepareServiceConfig(
|
|
279
|
+
coordRoot: string,
|
|
280
|
+
goalIds: string[],
|
|
281
|
+
opts: ServiceOpts,
|
|
282
|
+
registry: ReturnType<typeof createBuiltinHarnessRegistry>,
|
|
283
|
+
): SupervisorServiceConfig {
|
|
284
|
+
if (goalIds.length === 0) {
|
|
285
|
+
if (hasServiceConfigOverrides(opts)) {
|
|
286
|
+
throw new Error("goal ids are required when changing supervisor service options");
|
|
287
|
+
}
|
|
288
|
+
return readSupervisorServiceConfig(coordRoot);
|
|
289
|
+
}
|
|
290
|
+
if (opts.harness && !registry.get(opts.harness)) {
|
|
291
|
+
throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
|
|
292
|
+
}
|
|
293
|
+
const policy = opts.policy ? loadPolicyFile(opts.policy) : undefined;
|
|
294
|
+
return configureSupervisorService({
|
|
295
|
+
coordRoot,
|
|
296
|
+
goalIds,
|
|
297
|
+
wakeIntervalMs: integer(opts.wakeIntervalMs),
|
|
298
|
+
heartbeatIntervalMs: integer(opts.heartbeatIntervalMs),
|
|
299
|
+
errorBackoffBaseMs: integer(opts.errorBackoffBaseMs),
|
|
300
|
+
errorBackoffMaxMs: integer(opts.errorBackoffMaxMs),
|
|
301
|
+
engine: {
|
|
302
|
+
default_harness: opts.harness,
|
|
303
|
+
cwd: opts.cwd,
|
|
304
|
+
subscription_only:
|
|
305
|
+
opts.subscriptionOnly === true ? true : workflowSubscriptionOnly(coordRoot),
|
|
306
|
+
allow_api_billing: opts.allowApiBilling,
|
|
307
|
+
policy,
|
|
308
|
+
isolation: opts.isolation,
|
|
309
|
+
approval_addressee: opts.approvalTo,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasServiceConfigOverrides(opts: ServiceOpts): boolean {
|
|
315
|
+
return Boolean(
|
|
316
|
+
opts.wakeIntervalMs ||
|
|
317
|
+
opts.heartbeatIntervalMs ||
|
|
318
|
+
opts.errorBackoffBaseMs ||
|
|
319
|
+
opts.errorBackoffMaxMs ||
|
|
320
|
+
opts.harness ||
|
|
321
|
+
opts.cwd ||
|
|
322
|
+
opts.subscriptionOnly ||
|
|
323
|
+
opts.allowApiBilling ||
|
|
324
|
+
opts.policy ||
|
|
325
|
+
opts.isolation ||
|
|
326
|
+
opts.approvalTo,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function serviceEngine(
|
|
331
|
+
config: SupervisorServiceConfig,
|
|
332
|
+
registry: ReturnType<typeof createBuiltinHarnessRegistry>,
|
|
333
|
+
) {
|
|
334
|
+
return {
|
|
335
|
+
spawners: registry.spawners(),
|
|
336
|
+
defaultHarness: config.engine.default_harness,
|
|
337
|
+
cwd: config.engine.cwd,
|
|
338
|
+
subscriptionOnly: config.engine.subscription_only,
|
|
339
|
+
allowApiBilling: config.engine.allow_api_billing,
|
|
340
|
+
harnessEvidence: Object.fromEntries(
|
|
341
|
+
registry
|
|
342
|
+
.list()
|
|
343
|
+
.map((adapter) => [
|
|
344
|
+
adapter.profile.id,
|
|
345
|
+
{ toolEvidence: adapter.profile.capabilities.toolEvidence },
|
|
346
|
+
]),
|
|
347
|
+
),
|
|
348
|
+
policy: config.engine.policy,
|
|
349
|
+
approvalMode: "park" as const,
|
|
350
|
+
approvalAddressee: config.engine.approval_addressee,
|
|
351
|
+
isolation: config.engine.isolation,
|
|
352
|
+
networkAccess: "enabled" as const,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function emitServiceStatus(
|
|
357
|
+
status: SupervisorServiceStatus,
|
|
358
|
+
json: boolean | undefined,
|
|
359
|
+
emit: EmitContext,
|
|
360
|
+
): void {
|
|
361
|
+
if (json) {
|
|
362
|
+
emit.config({ format: "json" });
|
|
363
|
+
emit.data(status);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (!status.config && !status.record) {
|
|
367
|
+
emit.text("supervisor service: unconfigured\n");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const state = status.running
|
|
371
|
+
? (status.record?.state ?? "running")
|
|
372
|
+
: status.stale
|
|
373
|
+
? "stale"
|
|
374
|
+
: "stopped";
|
|
375
|
+
const lines = [
|
|
376
|
+
`supervisor service: ${state}`,
|
|
377
|
+
`goals: ${status.config?.goal_ids.join(", ") || "none"}`,
|
|
378
|
+
];
|
|
379
|
+
if (status.record) {
|
|
380
|
+
lines.push(`pid: ${status.record.pid} on ${status.record.host}`);
|
|
381
|
+
lines.push(`heartbeat: ${status.record.heartbeat_at}`);
|
|
382
|
+
lines.push(`sweeps: ${status.record.sweep_count}`);
|
|
383
|
+
if (status.record.active_goal_id) lines.push(`active: ${status.record.active_goal_id}`);
|
|
384
|
+
if (status.record.next_wake_at) lines.push(`next wake: ${status.record.next_wake_at}`);
|
|
385
|
+
if (status.record.last_error) lines.push(`error: ${status.record.last_error}`);
|
|
386
|
+
}
|
|
387
|
+
const backingOff = Object.entries(status.runtime?.goals ?? {}).filter(
|
|
388
|
+
([, runtime]) => runtime.state === "backoff",
|
|
389
|
+
);
|
|
390
|
+
if (backingOff.length) {
|
|
391
|
+
lines.push(`backoff: ${backingOff.map(([goalId]) => goalId).join(", ")}`);
|
|
392
|
+
}
|
|
393
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
394
|
+
}
|
|
395
|
+
|
|
140
396
|
function registerRunCommand(
|
|
141
397
|
supervisor: Command,
|
|
142
398
|
mode: "tick" | "run",
|
|
@@ -275,6 +531,10 @@ function integer(value: string | undefined): number | undefined {
|
|
|
275
531
|
return Number.parseInt(value, 10);
|
|
276
532
|
}
|
|
277
533
|
|
|
534
|
+
function delay(ms: number): Promise<void> {
|
|
535
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
536
|
+
}
|
|
537
|
+
|
|
278
538
|
function withSupervisorRoot(emit: EmitContext, fn: (coordRoot: string) => void): void {
|
|
279
539
|
const coordRoot = findCoordRoot();
|
|
280
540
|
if (!coordRoot) {
|
|
@@ -5,6 +5,33 @@ export {
|
|
|
5
5
|
type SupervisorRunReport,
|
|
6
6
|
type SupervisorStopReason,
|
|
7
7
|
} from "./runner.ts";
|
|
8
|
+
export {
|
|
9
|
+
type ConfigureSupervisorServiceInput,
|
|
10
|
+
configureSupervisorService,
|
|
11
|
+
type RunSupervisorServiceDaemonInput,
|
|
12
|
+
type RunSupervisorServiceSweepInput,
|
|
13
|
+
readSupervisorServiceConfig,
|
|
14
|
+
readSupervisorServiceRuntime,
|
|
15
|
+
readSupervisorServiceStatus,
|
|
16
|
+
requestSupervisorServiceStop,
|
|
17
|
+
runSupervisorServiceDaemon,
|
|
18
|
+
runSupervisorServiceSweep,
|
|
19
|
+
SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION,
|
|
20
|
+
SUPERVISOR_SERVICE_RUNTIME_SCHEMA_VERSION,
|
|
21
|
+
SUPERVISOR_SERVICE_STATUS_SCHEMA_VERSION,
|
|
22
|
+
type SupervisorServiceConfig,
|
|
23
|
+
type SupervisorServiceEngineConfig,
|
|
24
|
+
type SupervisorServiceGoalRuntime,
|
|
25
|
+
type SupervisorServiceGoalState,
|
|
26
|
+
type SupervisorServiceProcessState,
|
|
27
|
+
type SupervisorServiceRuntime,
|
|
28
|
+
type SupervisorServiceStatus,
|
|
29
|
+
type SupervisorServiceStatusRecord,
|
|
30
|
+
type SupervisorServiceSweepOutcome,
|
|
31
|
+
type SupervisorServiceSweepReport,
|
|
32
|
+
spawnSupervisorService,
|
|
33
|
+
supervisorServiceLogPath,
|
|
34
|
+
} from "./service.ts";
|
|
8
35
|
export {
|
|
9
36
|
assertSupervisorId,
|
|
10
37
|
type CreateSupervisorInput,
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SupervisorServiceConfig,
|
|
3
|
+
SupervisorServiceGoalRuntime,
|
|
4
|
+
SupervisorServiceGoalState,
|
|
5
|
+
SupervisorServiceProcessState,
|
|
6
|
+
SupervisorServiceRuntime,
|
|
7
|
+
SupervisorServiceStatus,
|
|
8
|
+
SupervisorServiceStatusRecord,
|
|
9
|
+
} from "./service.ts";
|
|
10
|
+
export {
|
|
11
|
+
readSupervisorServiceConfig,
|
|
12
|
+
readSupervisorServiceRuntime,
|
|
13
|
+
readSupervisorServiceStatus,
|
|
14
|
+
supervisorServiceLogPath,
|
|
15
|
+
} from "./service-read.ts";
|
|
1
16
|
export {
|
|
2
17
|
assertSupervisorId,
|
|
3
18
|
collectSupervisorWork,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
SupervisorServiceConfig,
|
|
6
|
+
SupervisorServiceRuntime,
|
|
7
|
+
SupervisorServiceStatus,
|
|
8
|
+
SupervisorServiceStatusRecord,
|
|
9
|
+
} from "./service.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
12
|
+
const MAX_GOALS = 100;
|
|
13
|
+
const FOREIGN_STATUS_STALE_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
export function readSupervisorServiceConfig(coordRootRaw: string): SupervisorServiceConfig {
|
|
16
|
+
const coordRoot = resolve(coordRootRaw);
|
|
17
|
+
const config = readBoundedJson<SupervisorServiceConfig>(
|
|
18
|
+
join(serviceDir(coordRoot), "config.json"),
|
|
19
|
+
"supervisor service configuration",
|
|
20
|
+
);
|
|
21
|
+
if (
|
|
22
|
+
config.schema_version !== 1 ||
|
|
23
|
+
!validTimestamp(config.created_at) ||
|
|
24
|
+
!Array.isArray(config.goal_ids) ||
|
|
25
|
+
config.goal_ids.length < 1 ||
|
|
26
|
+
config.goal_ids.length > MAX_GOALS ||
|
|
27
|
+
config.goal_ids.some((goalId) => typeof goalId !== "string" || !goalId.trim()) ||
|
|
28
|
+
!positive(config.wake_interval_ms) ||
|
|
29
|
+
!positive(config.heartbeat_interval_ms) ||
|
|
30
|
+
!positive(config.error_backoff_base_ms) ||
|
|
31
|
+
!positive(config.error_backoff_max_ms) ||
|
|
32
|
+
config.error_backoff_max_ms < config.error_backoff_base_ms ||
|
|
33
|
+
!config.engine ||
|
|
34
|
+
typeof config.engine !== "object" ||
|
|
35
|
+
typeof config.engine.subscription_only !== "boolean" ||
|
|
36
|
+
typeof config.engine.allow_api_billing !== "boolean"
|
|
37
|
+
) {
|
|
38
|
+
throw new Error("supervisor service configuration has an unsupported schema");
|
|
39
|
+
}
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readSupervisorServiceRuntime(
|
|
44
|
+
coordRootRaw: string,
|
|
45
|
+
): SupervisorServiceRuntime | undefined {
|
|
46
|
+
const coordRoot = resolve(coordRootRaw);
|
|
47
|
+
const path = join(serviceDir(coordRoot), "runtime.json");
|
|
48
|
+
if (!existsSync(path)) return undefined;
|
|
49
|
+
const runtime = readBoundedJson<SupervisorServiceRuntime>(path, "supervisor service runtime");
|
|
50
|
+
if (
|
|
51
|
+
runtime.schema_version !== 1 ||
|
|
52
|
+
!validTimestamp(runtime.config_created_at) ||
|
|
53
|
+
!validTimestamp(runtime.updated_at) ||
|
|
54
|
+
!runtime.goals ||
|
|
55
|
+
typeof runtime.goals !== "object" ||
|
|
56
|
+
Array.isArray(runtime.goals)
|
|
57
|
+
) {
|
|
58
|
+
throw new Error("supervisor service runtime has an unsupported schema");
|
|
59
|
+
}
|
|
60
|
+
return runtime;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readSupervisorServiceStatus(coordRootRaw: string): SupervisorServiceStatus {
|
|
64
|
+
const coordRoot = resolve(coordRootRaw);
|
|
65
|
+
const record = readStatusRecord(coordRoot);
|
|
66
|
+
let config: SupervisorServiceConfig | undefined;
|
|
67
|
+
let runtime: SupervisorServiceRuntime | undefined;
|
|
68
|
+
try {
|
|
69
|
+
config = readSupervisorServiceConfig(coordRoot);
|
|
70
|
+
} catch {
|
|
71
|
+
// Unconfigured or corrupt service state is represented without throwing.
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
runtime = readSupervisorServiceRuntime(coordRoot);
|
|
75
|
+
} catch {
|
|
76
|
+
// Runtime is recoverable and must not make the dashboard unreadable.
|
|
77
|
+
}
|
|
78
|
+
if (!record) return { running: false, stale: false, config, runtime };
|
|
79
|
+
const running = statusOwnerIsLive(record);
|
|
80
|
+
return { running, stale: !running && record.state !== "stopped", record, config, runtime };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function supervisorServiceLogPath(coordRoot: string): string {
|
|
84
|
+
return join(serviceDir(resolve(coordRoot)), "service.log");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readStatusRecord(coordRoot: string): SupervisorServiceStatusRecord | undefined {
|
|
88
|
+
const path = join(serviceDir(coordRoot), "status.json");
|
|
89
|
+
if (!existsSync(path)) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const value = readBoundedJson<SupervisorServiceStatusRecord>(path, "supervisor service status");
|
|
92
|
+
if (
|
|
93
|
+
value.schema_version !== 1 ||
|
|
94
|
+
!Number.isSafeInteger(value.pid) ||
|
|
95
|
+
value.pid < 1 ||
|
|
96
|
+
typeof value.host !== "string" ||
|
|
97
|
+
typeof value.nonce !== "string" ||
|
|
98
|
+
!validTimestamp(value.started_at) ||
|
|
99
|
+
!validTimestamp(value.heartbeat_at)
|
|
100
|
+
) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function statusOwnerIsLive(record: SupervisorServiceStatusRecord): boolean {
|
|
110
|
+
if (record.state === "stopped") return false;
|
|
111
|
+
if (record.host === hostname()) {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(record.pid, 0);
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const age = Date.now() - Date.parse(record.heartbeat_at);
|
|
120
|
+
return Number.isFinite(age) && age < FOREIGN_STATUS_STALE_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readBoundedJson<T>(path: string, label: string): T {
|
|
124
|
+
if (!existsSync(path)) throw new Error(`${label} does not exist`);
|
|
125
|
+
const size = statSync(path).size;
|
|
126
|
+
if (size <= 0 || size > MAX_FILE_BYTES) throw new Error(`${label} has invalid size ${size}`);
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`cannot parse ${label}: ${(error as Error).message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function serviceDir(coordRoot: string): string {
|
|
135
|
+
return join(coordRoot, ".harnery", "supervisor-service");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function positive(value: unknown): value is number {
|
|
139
|
+
return Number.isSafeInteger(value) && (value as number) > 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validTimestamp(value: unknown): value is string {
|
|
143
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
144
|
+
}
|