@aywengo/mercury-fleet 0.0.1-bootstrap

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/dist/server.js ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The Fleet service: an HTTP server over the registry and the prober.
3
+ *
4
+ * The route table is enumerated rather than grown ad hoc (docs/fleet-design.md section 15.4). The rule that
5
+ * matters is that nothing here accepts a URL to fetch: a caller names a host by its registry id and Fleet
6
+ * resolves it. That is what keeps section 9's "do not proxy arbitrary paths to children" true as the surface
7
+ * grows, instead of eroding one convenience at a time.
8
+ */
9
+ import { createServer } from 'node:http';
10
+ import { readFileSync } from 'node:fs';
11
+ import * as https from 'node:https';
12
+ import { HostRegistry, RegistryError } from "./registry.js";
13
+ import { BindingStore, UNKNOWN } from "./bindings.js";
14
+ import { createChildClient } from "./child.js";
15
+ import { DispatchError, recoverPending, refreshStates, submitRun } from "./dispatch.js";
16
+ import { startSweeper } from "./sweep.js";
17
+ import { listMirroredEvents } from "./events.js";
18
+ import { startEventStream } from "./stream.js";
19
+ import { loadRepoUrlMap, routeRun, RoutingError } from "./routing.js";
20
+ import { cancelRun, retryRun, sendInput } from "./interact.js";
21
+ import { mergeRollup, scrapeAll } from "./metrics.js";
22
+ import { createProber } from "./prober.js";
23
+ import { CredentialError } from "./credentials.js";
24
+ import { parseCallerTokens, hostAllowed } from "./auth.js";
25
+ import { authenticate, HttpError, matchRoute, readJsonBody, sendJson } from "./http.js";
26
+ import { FLEET_PRODUCT, FLEET_VERSION } from "./version.js";
27
+ function bodyObject(body) {
28
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
29
+ throw new HttpError(400, 'request body must be a JSON object');
30
+ }
31
+ return body;
32
+ }
33
+ /** The standard Idempotency-Key header, accepted alongside an in-body equivalent. */
34
+ function headerIdempotency(ctx) {
35
+ const v = ctx.headers?.['idempotency-key'];
36
+ return typeof v === 'string' && v.length > 0 ? v : null;
37
+ }
38
+ function scopedHosts(caller, registry) {
39
+ if (caller.isAdmin || caller.allowedHosts === '*')
40
+ return '*';
41
+ return caller.allowedHosts;
42
+ }
43
+ /** Parse a query parameter as a non-negative integer, falling back when it is anything else. */
44
+ function nonNegativeInt(raw, fallback) {
45
+ if (raw === null || raw === undefined || raw.trim() === '')
46
+ return fallback;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0)
49
+ return fallback;
50
+ return n;
51
+ }
52
+ /**
53
+ * Unwrap the optional { input: ... } wrapper without losing a genuine null, and without rejecting a body that
54
+ * is a bare string, number, array or null -- the child accepts any JSON value.
55
+ */
56
+ function unwrapInput(body) {
57
+ if (body !== null && typeof body === 'object' && !Array.isArray(body) && 'input' in body) {
58
+ return body.input;
59
+ }
60
+ return body;
61
+ }
62
+ /** Narrow to a plain object without accepting arrays or null, both of which `typeof` would let through. */
63
+ function isRecord(v) {
64
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
65
+ }
66
+ function str(body, key) {
67
+ const v = body[key];
68
+ if (typeof v !== 'string' || !v)
69
+ throw new HttpError(400, `${key} is required and must be a non-empty string`);
70
+ return v;
71
+ }
72
+ /** Hosts this caller may see. Admins and `*` callers see everything. */
73
+ function visibleHosts(registry, caller) {
74
+ const all = registry.listWithProbe();
75
+ if (caller.isAdmin || caller.allowedHosts === '*')
76
+ return all;
77
+ return all.filter((h) => caller.allowedHosts.includes(h.id));
78
+ }
79
+ export function buildRoutes(deps) {
80
+ const registry = new HostRegistry(deps.db);
81
+ // Loaded once at startup. A malformed map must fail the process loudly rather than degrade into "no
82
+ // mapping", which would present as routing refusing work it should have accepted.
83
+ const resolveCloneUrl = loadRepoUrlMap(deps.config.repoUrlsFile);
84
+ const dispatch = {
85
+ registry,
86
+ bindings: new BindingStore(deps.db),
87
+ child: createChildClient({ timeoutMs: deps.config.probeTimeoutMs }),
88
+ resolveToken: (ref) => deps.credentials.secret(ref),
89
+ };
90
+ const interaction = {
91
+ registry: dispatch.registry,
92
+ bindings: dispatch.bindings,
93
+ child: dispatch.child,
94
+ resolveToken: dispatch.resolveToken,
95
+ };
96
+ const metricsDeps = {
97
+ registry: dispatch.registry,
98
+ child: dispatch.child,
99
+ resolveToken: dispatch.resolveToken,
100
+ timeoutMs: deps.config.probeTimeoutMs,
101
+ };
102
+ const prober = createProber({
103
+ registry,
104
+ resolveToken: (ref) => deps.credentials.secret(ref),
105
+ intervalMs: deps.config.probeIntervalMs,
106
+ timeoutMs: deps.config.probeTimeoutMs,
107
+ });
108
+ const routes = [
109
+ {
110
+ method: 'GET', pattern: ['healthz'], public: true,
111
+ handle: (_ctx, res) => sendJson(res, 200, {
112
+ ok: true,
113
+ ts: new Date().toISOString(),
114
+ product: FLEET_PRODUCT,
115
+ version: FLEET_VERSION,
116
+ }),
117
+ },
118
+ {
119
+ // GET /metrics -- the fleet rollup. Mounted at the root like a Mercury's own, so a Prometheus job can
120
+ // point at either with the same path.
121
+ //
122
+ // Scoped to the caller's visible hosts for the same reason reads are: a caller limited to one host must
123
+ // not learn another host's queue depth, run counts and worker topology from one aggregate endpoint.
124
+ method: 'GET', pattern: ['metrics'],
125
+ handle: async (ctx, res) => {
126
+ const hosts = visibleHosts(registry, ctx.caller);
127
+ const scrapes = await scrapeAll(metricsDeps, hosts.map((h) => h.id));
128
+ const { text, dropped } = mergeRollup(scrapes);
129
+ if (dropped.length > 0) {
130
+ // Reported rather than swallowed: a rollup that quietly loses a family under-reports, and the whole
131
+ // point of the endpoint is to be trusted.
132
+ deps.logger.warn('metrics rollup dropped data', { dropped: dropped.length, examples: dropped.slice(0, 20) });
133
+ }
134
+ res.writeHead(200, {
135
+ 'content-type': 'text/plain; version=0.0.4; charset=utf-8',
136
+ 'cache-control': 'no-cache',
137
+ });
138
+ res.end(text);
139
+ },
140
+ },
141
+ {
142
+ // Registry reads. A caller sees only the hosts it may act on; naming a host it may not touch is a
143
+ // 403 elsewhere, never a silent substitution.
144
+ method: 'GET', pattern: ['fleet', 'hosts'],
145
+ handle: (ctx, res) => sendJson(res, 200, { hosts: visibleHosts(registry, ctx.caller) }),
146
+ },
147
+ {
148
+ method: 'POST', pattern: ['fleet', 'hosts'], admin: true,
149
+ handle: (ctx, res) => {
150
+ const b = bodyObject(ctx.body);
151
+ // Reject an unknown ref at registration. Accepting a typo defers the failure to a probe that reports
152
+ // auth-fail, which points at the host when the mistake is in this request.
153
+ const ref = str(b, 'credentialRef');
154
+ deps.credentials.secret(ref);
155
+ const host = registry.add({
156
+ id: str(b, 'id'),
157
+ baseUrl: str(b, 'baseUrl'),
158
+ credentialRef: ref,
159
+ labels: (b.labels ?? {}),
160
+ localPaths: (b.localPaths ?? []),
161
+ enabled: b.enabled !== false,
162
+ });
163
+ sendJson(res, 201, { host });
164
+ },
165
+ },
166
+ {
167
+ method: 'POST', pattern: ['fleet', 'hosts', ':id', 'enable'], admin: true,
168
+ handle: (ctx, res) => {
169
+ const b = bodyObject(ctx.body);
170
+ const host = registry.setEnabled(ctx.params[0], b.enabled !== false);
171
+ sendJson(res, 200, { host });
172
+ },
173
+ },
174
+ {
175
+ method: 'DELETE', pattern: ['fleet', 'hosts', ':id'], admin: true,
176
+ handle: (ctx, res) => {
177
+ // ?force=1 discards the bindings to this host's Runs. Offered because refusing outright would give
178
+ // no way forward, but never implied: the default protects the one table that cannot be rebuilt.
179
+ const force = ctx.query.get('force') === '1';
180
+ let removed = false;
181
+ try {
182
+ removed = registry.remove(ctx.params[0], { force });
183
+ }
184
+ catch (err) {
185
+ if (err instanceof RegistryError && /still owns/.test(err.message)) {
186
+ throw new HttpError(409, err.message);
187
+ }
188
+ throw err;
189
+ }
190
+ if (!removed)
191
+ throw new HttpError(404, 'no such host');
192
+ sendJson(res, 200, { removed: ctx.params[0], forced: force });
193
+ },
194
+ },
195
+ {
196
+ method: 'POST', pattern: ['fleet', 'hosts', ':id', 'probe'], admin: true,
197
+ handle: async (ctx, res) => {
198
+ const id = ctx.params[0];
199
+ const host = registry.get(id);
200
+ if (!host)
201
+ throw new HttpError(404, 'no such host');
202
+ const { probeAndRecord } = await import("./probe.js");
203
+ const rec = await probeAndRecord({
204
+ hostId: id, baseUrl: host.baseUrl,
205
+ token: deps.credentials.secret(host.credentialRef),
206
+ timeoutMs: deps.config.probeTimeoutMs,
207
+ });
208
+ registry.recordProbe(rec);
209
+ sendJson(res, 200, { probe: rec });
210
+ },
211
+ },
212
+ {
213
+ // Dispatch. The caller names a host; the allowlist is checked against THAT host and a mismatch is 403,
214
+ // never a silent substitution to a host the caller may use.
215
+ method: 'POST', pattern: ['fleet', 'runs'],
216
+ handle: async (ctx, res) => {
217
+ const b = bodyObject(ctx.body);
218
+ const namedHost = typeof b.host === 'string' && b.host ? b.host : undefined;
219
+ // Routing runs over the hosts THIS caller may see, not the whole fleet. A router that could place work
220
+ // on a hidden host would turn an allowlist into a suggestion.
221
+ let decision;
222
+ try {
223
+ decision = routeRun(visibleHosts(registry, ctx.caller), {
224
+ host: namedHost,
225
+ agent: typeof b.agent === 'string' && b.agent ? b.agent : undefined,
226
+ // Passed through rather than quietly dropped when the shape is wrong: an array or a string here
227
+ // must be a 400 about the caller's body, not a request that silently loses its filter.
228
+ labels: b.labels,
229
+ repository: b.repository,
230
+ }, { resolveCloneUrl });
231
+ }
232
+ catch (err) {
233
+ // A routing failure names every host considered and why each was excluded. Swallowing that into a
234
+ // generic 400 is exactly how a five-second mistake becomes an hour of confusion.
235
+ if (err instanceof RoutingError)
236
+ throw new HttpError(err.status, err.message, undefined, err.exclusions);
237
+ throw err;
238
+ }
239
+ if (!hostAllowed(ctx.caller, decision.hostId)) {
240
+ // Unreachable given the scoping above, kept because a router change must not silently widen access.
241
+ throw new HttpError(403, `caller ${ctx.caller.ownerId} may not submit work to host ${decision.hostId}`);
242
+ }
243
+ const requested = { ...b, ...(decision.repository ? { repository: decision.repository } : {}) };
244
+ if (!decision.repository)
245
+ delete requested.repository;
246
+ delete requested.host;
247
+ delete requested.idempotency;
248
+ const idem = typeof b.idempotency === 'string' && b.idempotency
249
+ ? b.idempotency
250
+ : headerIdempotency(ctx);
251
+ // ownerId comes from the authenticated caller, never the body: idempotency scoping is a security
252
+ // boundary, and a caller that could choose it could claim another caller's binding.
253
+ const outcome = await submitRun(dispatch, {
254
+ hostId: decision.hostId, ownerId: ctx.caller.ownerId, requested, clientToken: idem ?? null,
255
+ });
256
+ sendJson(res, outcome.reused ? 200 : 201, {
257
+ fleetRunId: outcome.binding.fleetRunId,
258
+ hostId: outcome.binding.hostId,
259
+ // Say so when the localPath was replaced: a caller who asked for a path and got a clone should be
260
+ // able to see that the constraint was lifted, not discover it from a missing working tree.
261
+ ...(decision.rewroteLocalPath ? { rewroteLocalPath: true } : {}),
262
+ childRunId: outcome.binding.childRunId,
263
+ pending: outcome.pending,
264
+ reused: outcome.reused,
265
+ status: dispatch.bindings.state(outcome.binding.fleetRunId)?.status ?? UNKNOWN,
266
+ ...(outcome.note ? { note: outcome.note } : {}),
267
+ });
268
+ },
269
+ },
270
+ {
271
+ method: 'GET', pattern: ['fleet', 'runs'],
272
+ handle: (ctx, res) => {
273
+ const runs = dispatch.bindings.list(scopedHosts(ctx.caller, registry));
274
+ sendJson(res, 200, { runs });
275
+ },
276
+ },
277
+ {
278
+ method: 'GET', pattern: ['fleet', 'runs', ':id'],
279
+ handle: async (ctx, res) => {
280
+ const id = ctx.params[0];
281
+ const binding = dispatch.bindings.get(id);
282
+ if (!binding)
283
+ throw new HttpError(404, 'no such fleet run');
284
+ if (!hostAllowed(ctx.caller, binding.hostId)) {
285
+ // Same status as "does not exist" would be for a different reason; 403 is honest here because the
286
+ // caller demonstrably knows an id that it may not read.
287
+ throw new HttpError(403, `caller ${ctx.caller.ownerId} may not read runs on host ${binding.hostId}`);
288
+ }
289
+ await refreshStates(dispatch, [binding.hostId]);
290
+ sendJson(res, 200, {
291
+ ...binding,
292
+ state: dispatch.bindings.state(id),
293
+ });
294
+ },
295
+ },
296
+ // Interaction verbs. Each is scoped by the same host allowlist as reads: a caller who may not see a host
297
+ // must not be able to cancel or feed work to a Run on it.
298
+ ...['input', 'cancel', 'retry'].map((verb) => ({
299
+ method: 'POST',
300
+ pattern: ['fleet', 'runs', ':id', verb],
301
+ handle: async (ctx, res) => {
302
+ const id = ctx.params[0];
303
+ const binding = dispatch.bindings.get(id);
304
+ if (!binding)
305
+ throw new HttpError(404, 'no such fleet run');
306
+ if (!hostAllowed(ctx.caller, binding.hostId)) {
307
+ throw new HttpError(403, `caller ${ctx.caller.ownerId} may not act on runs on host ${binding.hostId}`);
308
+ }
309
+ const outcome = verb === 'input'
310
+ // The child takes an arbitrary JSON value; Fleet forwards it rather than inventing a shape.
311
+ // The child takes any JSON value, so Fleet must not demand an object. A body of {"input": ...} is
312
+ // unwrapped -- but by key presence, not truthiness, because {input: null} is a caller genuinely
313
+ // sending null rather than one who omitted it.
314
+ ? await sendInput(interaction, id, unwrapInput(ctx.body))
315
+ : verb === 'cancel'
316
+ ? await cancelRun(interaction, id)
317
+ : await retryRun(interaction, id);
318
+ // 200 even when unknown: the request was accepted and understood, and the uncertainty is about the
319
+ // child's answer rather than about the caller's request. The body says which it was.
320
+ sendJson(res, 200, outcome);
321
+ },
322
+ })),
323
+ {
324
+ // GET /fleet/runs/:id/stream -- Fleet-side SSE, a view onto the mirror rather than a second path to the
325
+ // child. Losing this stream costs latency only: the client reconnects with ?after=<cursor> and resumes
326
+ // exactly where it was, because the cursor is the correctness mechanism and SSE is the optimisation.
327
+ method: 'GET', pattern: ['fleet', 'runs', ':id', 'stream'],
328
+ handle: async (ctx, res) => {
329
+ const id = ctx.params[0];
330
+ const binding = dispatch.bindings.get(id);
331
+ if (!binding)
332
+ throw new HttpError(404, 'no such fleet run');
333
+ if (!hostAllowed(ctx.caller, binding.hostId)) {
334
+ throw new HttpError(403, `caller ${ctx.caller.ownerId} may not read runs on host ${binding.hostId}`);
335
+ }
336
+ res.writeHead(200, {
337
+ 'content-type': 'text/event-stream',
338
+ 'cache-control': 'no-cache',
339
+ connection: 'keep-alive',
340
+ // Without this a buffering proxy turns a live stream into one long download.
341
+ 'x-accel-buffering': 'no',
342
+ });
343
+ // Flush the headers now. Otherwise a client that connected but receives nothing waits on the socket
344
+ // and cannot tell a working stream from a hung request.
345
+ res.flushHeaders?.();
346
+ startEventStream(res, {
347
+ db: deps.db, bindings: dispatch.bindings, fleetRunId: id,
348
+ pollIntervalMs: deps.config.streamPollMs,
349
+ // Passed in rather than applied afterwards: the backlog is written synchronously, so a handle method
350
+ // would run after the client had already been sent events it asked to skip.
351
+ after: nonNegativeInt(ctx.query?.get('after'), 0),
352
+ });
353
+ },
354
+ },
355
+ {
356
+ // GET /fleet/runs/:id/events?after=<cursor>&limit=<n>
357
+ // Reads Fleet's mirror, so it answers at the freshness of the last sweep rather than this instant, and
358
+ // costs the child nothing. That is deliberate: section 8 makes the cursor the correctness mechanism and
359
+ // polling the baseline, so a client that pages from nextCursor sees every event whether or not a child
360
+ // connection is open.
361
+ method: 'GET', pattern: ['fleet', 'runs', ':id', 'events'],
362
+ handle: async (ctx, res) => {
363
+ const id = ctx.params[0];
364
+ const binding = dispatch.bindings.get(id);
365
+ if (!binding)
366
+ throw new HttpError(404, 'no such fleet run');
367
+ if (!hostAllowed(ctx.caller, binding.hostId)) {
368
+ throw new HttpError(403, `caller ${ctx.caller.ownerId} may not read runs on host ${binding.hostId}`);
369
+ }
370
+ // Parsed as integers before anything reaches SQLite. `after=Infinity` and `limit=2.5` are both
371
+ // accepted by Number() and both produce pagination nobody can reason about -- and Infinity is a
372
+ // perfectly truthy value, so the usual `|| 0` fallback would not have caught it.
373
+ const after = nonNegativeInt(ctx.query?.get('after'), 0);
374
+ // Bounded rather than trusted: an unbounded limit would let one caller pull an entire mirrored
375
+ // transcript into memory in one request.
376
+ const limit = Math.min(nonNegativeInt(ctx.query?.get('limit'), 200) || 200, 1000);
377
+ sendJson(res, 200, listMirroredEvents(deps.db, id, after, limit));
378
+ },
379
+ },
380
+ {
381
+ method: 'POST', pattern: ['fleet', 'probe'], admin: true,
382
+ handle: async (_ctx, res) => {
383
+ const results = await prober.sweepOnce();
384
+ sendJson(res, 200, { probed: results.length, hosts: registry.listWithProbe() });
385
+ },
386
+ },
387
+ ];
388
+ return { routes, prober, dispatch };
389
+ }
390
+ export function createFleetServer(deps) {
391
+ const { routes, prober, dispatch } = buildRoutes(deps);
392
+ const registry = new HostRegistry(deps.db);
393
+ // Built from the same dispatch the routes use, so mirroring resolves hosts and tokens exactly the way
394
+ // dispatch and reconciliation do rather than through a second, subtly different wiring.
395
+ const eventMirror = {
396
+ db: deps.db,
397
+ bindings: dispatch.bindings,
398
+ registry: dispatch.registry,
399
+ child: dispatch.child,
400
+ resolveToken: dispatch.resolveToken,
401
+ };
402
+ // Parsed once, not per request: the token set is fixed at startup, and re-splitting it on every call
403
+ // would let a caller's request rate scale the cost of an operation that never changes.
404
+ const authDeps = { callers: parseCallerTokens(deps.config.apiTokens), adminToken: deps.config.adminToken };
405
+ const handler = async (req, res) => {
406
+ const url = new URL(req.url ?? '/', 'http://internal');
407
+ const path = url.pathname.replace(/^\/+|\/+$/g, '');
408
+ const matched = matchRoute(routes, req.method ?? 'GET', path);
409
+ if (!matched) {
410
+ sendJson(res, 404, { error: 'not found' });
411
+ return;
412
+ }
413
+ const { route, params } = matched;
414
+ try {
415
+ let caller = { ownerId: 'anonymous', isAdmin: false, allowedHosts: [] };
416
+ if (!route.public) {
417
+ const resolved = authenticate(req, authDeps);
418
+ if (!resolved) {
419
+ sendJson(res, 401, { error: 'authentication required' });
420
+ return;
421
+ }
422
+ caller = resolved;
423
+ if (route.admin && !caller.isAdmin) {
424
+ sendJson(res, 403, { error: 'administrator token required' });
425
+ return;
426
+ }
427
+ }
428
+ const body = route.method === 'POST' ? await readJsonBody(req) : {};
429
+ await route.handle({ caller, params, query: url.searchParams, headers: req.headers, body, log: deps.logger }, res);
430
+ }
431
+ catch (err) {
432
+ // Each of these is a caller mistake rather than a server fault, and each carries or maps to a 4xx:
433
+ // HttpError carries its own status, DispatchError carries 404 or 409, and RegistryError plus
434
+ // CredentialError are always 400. CredentialError in particular names a credential_ref that is not in the
435
+ // file -- answering 500 there sends the operator to the service logs when the answer is in their own
436
+ // request body. Anything NOT listed here falls through to the 500 below, which is the point: an
437
+ // unrecognised exception is a server fault and must not be dressed up as a client error.
438
+ if (err instanceof HttpError || err instanceof RegistryError || err instanceof CredentialError
439
+ || err instanceof DispatchError) {
440
+ // DispatchError's status is chosen where the failure is understood, so it must survive to the caller:
441
+ // 404 for an unknown Fleet Run, 409 for a disabled host or an idempotency conflict.
442
+ const status = err instanceof HttpError ? err.status
443
+ : err instanceof DispatchError ? err.status
444
+ : 400;
445
+ sendJson(res, status, {
446
+ error: err.message,
447
+ ...(err instanceof HttpError && err.details !== undefined ? { details: err.details } : {}),
448
+ });
449
+ return;
450
+ }
451
+ // The logger redacts, so an exception message carrying a header cannot reach the log intact.
452
+ deps.logger.error('request failed', { method: req.method, path, err: err });
453
+ sendJson(res, 500, { error: 'internal error' });
454
+ }
455
+ };
456
+ const tls = Boolean(deps.config.tlsCert && deps.config.tlsKey);
457
+ const server = tls
458
+ ? https.createServer({ cert: readFileSync(deps.config.tlsCert), key: readFileSync(deps.config.tlsKey) }, handler)
459
+ : createServer(handler);
460
+ // A dashboard holding an open stream must not stall `systemctl stop` until SIGKILL.
461
+ server.keepAliveTimeout = 5000;
462
+ server.headersTimeout = 10000;
463
+ const sweeperRef = { handle: null };
464
+ return {
465
+ server,
466
+ prober,
467
+ routes,
468
+ get sweeper() {
469
+ return sweeperRef.handle;
470
+ },
471
+ async listen() {
472
+ // Startup order from section 15.6: credentials were loaded by the caller, the database is open, and
473
+ // reconciliation happens here. Binding LAST means a client can never get a response from an endpoint
474
+ // that has not finished loading the registry.
475
+ const hosts = registry.list();
476
+ // Recovery BEFORE binding, per section 15.6: a client must not be able to ask about a Run while the
477
+ // set of Runs is still being worked out. Pending bindings are those where Fleet asked a child for a Run
478
+ // and never recorded the answer -- typically because Fleet died in between.
479
+ const pending = dispatch.bindings.pending();
480
+ if (pending.length > 0) {
481
+ const rec = await recoverPending(dispatch);
482
+ deps.logger.info('recovered pending bindings', {
483
+ found: pending.length, resolved: rec.resolved, stillPending: rec.stillPending,
484
+ });
485
+ }
486
+ deps.logger.info('fleet starting', {
487
+ hosts: hosts.length,
488
+ bindHost: deps.config.bindHost,
489
+ port: deps.config.port,
490
+ tls,
491
+ });
492
+ await new Promise((resolve, reject) => {
493
+ server.once('error', reject);
494
+ server.listen(deps.config.port, deps.config.bindHost, () => resolve());
495
+ });
496
+ const addr = server.address();
497
+ prober.start();
498
+ // Sweep once immediately so the first GET /fleet/hosts is not all "never-probed".
499
+ void prober.sweepOnce().catch((err) => deps.logger.error('initial sweep failed', { err }));
500
+ // Reconciliation on its own timer, from startup, whether or not anyone is asking (section 7). A sweep
501
+ // driven by reads would report exactly the staleness it exists to prevent: a Run that finishes while the
502
+ // dashboard is closed would stay RUNNING until somebody opens the page again.
503
+ sweeperRef.handle = startSweeper(dispatch, {
504
+ intervalMs: deps.config.sweepIntervalMs,
505
+ // Event metadata rides with reconciliation rather than on a third timer: both walk the same set of
506
+ // live bindings, and a Run worth a status read is worth an event page.
507
+ events: eventMirror,
508
+ onEvent: (event) => {
509
+ // LOST is an operator event, not a Run outcome: the binding asserts a Run exists and the child
510
+ // denies it. Loud in the log; a webhook belongs with the alerting work in a later phase.
511
+ deps.logger.error('binding lost: child has no such Run', {
512
+ fleetRunId: event.fleetRunId, hostId: event.hostId, childRunId: event.childRunId,
513
+ detail: event.detail,
514
+ });
515
+ },
516
+ onError: (err) => deps.logger.error('reconciliation sweep failed', {
517
+ err: err instanceof Error ? err : new Error(String(err)),
518
+ }),
519
+ });
520
+ return {
521
+ host: deps.config.bindHost,
522
+ port: typeof addr === 'object' && addr ? addr.port : deps.config.port,
523
+ tls,
524
+ };
525
+ },
526
+ async close() {
527
+ // In-flight probes are abandoned, not awaited: the sweep writes cache rows and cache is cheap to lose.
528
+ prober.stop();
529
+ // Stopped before the socket closes so a pass cannot start against a half-torn-down process and write a
530
+ // cache row after the database is on its way out.
531
+ sweeperRef.handle?.stop();
532
+ sweeperRef.handle = null;
533
+ await new Promise((resolve) => {
534
+ server.closeAllConnections?.();
535
+ server.close(() => resolve());
536
+ });
537
+ },
538
+ };
539
+ }
540
+ export { hostAllowed };
package/dist/stream.js ADDED
@@ -0,0 +1,132 @@
1
+ import { listMirroredEvents } from "./events.js";
2
+ import { isTerminal } from "./sweep.js";
3
+ function write(res, event, data, id) {
4
+ // writeHead must precede the first write; the caller does that so status and headers go out together.
5
+ // Framing is explicit rather than relying on newlines inside a template literal, which reads as a bug even
6
+ // when it happens to produce the right bytes.
7
+ const idLine = id === undefined ? '' : `id: ${id}\n`;
8
+ return res.write(`event: ${event}\n${idLine}data: ${JSON.stringify(data)}\n\n`);
9
+ }
10
+ export function startEventStream(res, opts) {
11
+ const pollMs = opts.pollIntervalMs ?? 1000;
12
+ const keepAliveMs = opts.keepAliveMs ?? 15000;
13
+ const drainTimeoutMs = opts.drainTimeoutMs ?? 30000;
14
+ let closed = false;
15
+ // Set before the first pump. Doing this by calling back into the handle afterwards cannot work: the backlog
16
+ // is written synchronously inside startEventStream, so it would already have been sent.
17
+ let cursor = opts.after ?? 0;
18
+ let backlogDone = false;
19
+ let pollTimer = null;
20
+ let keepTimer = null;
21
+ let drainTimer = null;
22
+ const clearTimers = () => {
23
+ if (pollTimer)
24
+ clearInterval(pollTimer);
25
+ if (keepTimer)
26
+ clearInterval(keepTimer);
27
+ if (drainTimer)
28
+ clearTimeout(drainTimer);
29
+ pollTimer = null;
30
+ keepTimer = null;
31
+ drainTimer = null;
32
+ };
33
+ const stop = (reason) => {
34
+ if (closed)
35
+ return;
36
+ closed = true;
37
+ clearTimers();
38
+ opts.onEnd?.(reason);
39
+ // res.end() throws on a destroyed socket, which is often the very reason this path is running. Nothing
40
+ // can act on it, and letting it escape would surface as an uncaught exception during teardown.
41
+ try {
42
+ if (!res.writableEnded)
43
+ res.end();
44
+ }
45
+ catch {
46
+ /* already gone */
47
+ }
48
+ };
49
+ /**
50
+ * A subscriber that has stopped reading gets its socket torn down rather than a graceful end. Ending lets
51
+ * the kernel keep buffering a transcript nobody will read, and the event whose write returned false has not
52
+ * been recorded in the cursor, so a reconnect could receive it twice. Same reasoning as Mercury's own SSE
53
+ * backpressure handling.
54
+ */
55
+ const abortUnreadable = (reason) => {
56
+ if (closed)
57
+ return;
58
+ closed = true;
59
+ clearTimers();
60
+ opts.onEnd?.(reason);
61
+ try {
62
+ res.destroy();
63
+ }
64
+ catch {
65
+ /* already gone */
66
+ }
67
+ };
68
+ const armDrainWatch = () => {
69
+ if (drainTimer)
70
+ clearTimeout(drainTimer);
71
+ drainTimer = setTimeout(() => stop('subscriber stopped reading'), drainTimeoutMs);
72
+ // A stream nobody is watching must not be the reason the process stays alive.
73
+ drainTimer.unref?.();
74
+ };
75
+ const pump = () => {
76
+ if (closed)
77
+ return;
78
+ // A throw inside a timer callback is an uncaught exception, not a failed request, and would take the whole
79
+ // service down over one bad read. Every other periodic task here (sweep, prober) wraps its body likewise.
80
+ try {
81
+ pumpInner();
82
+ }
83
+ catch (err) {
84
+ opts.onError?.(err);
85
+ stop('read failed');
86
+ }
87
+ };
88
+ const pumpInner = () => {
89
+ const page = listMirroredEvents(opts.db, opts.fleetRunId, cursor, 500);
90
+ for (const ev of page.events) {
91
+ if (!write(res, 'event', ev, ev.sequence)) {
92
+ // The kernel buffer is full: the subscriber has stopped reading.
93
+ abortUnreadable('backpressure');
94
+ return;
95
+ }
96
+ cursor = ev.sequence;
97
+ }
98
+ armDrainWatch();
99
+ if (!backlogDone) {
100
+ backlogDone = true;
101
+ // Tell the client where it stands before any live events, so a UI can distinguish "caught up" from
102
+ // "still replaying".
103
+ write(res, 'snapshot', { cursor, hasMore: page.hasMore });
104
+ }
105
+ // A terminal Run whose log is drained has nothing left to send; end rather than hold the socket.
106
+ const state = opts.bindings.state(opts.fleetRunId);
107
+ if (!page.hasMore && state && isTerminal(state.status) && state.eventsDrained) {
108
+ write(res, 'done', { cursor, status: state.status });
109
+ stop('terminal');
110
+ }
111
+ };
112
+ res.on('close', () => stop('client disconnected'));
113
+ armDrainWatch();
114
+ pump();
115
+ if (!closed) {
116
+ pollTimer = setInterval(pump, pollMs);
117
+ pollTimer.unref?.();
118
+ keepTimer = setInterval(() => {
119
+ if (closed)
120
+ return;
121
+ if (!res.write(': keep-alive\n\n'))
122
+ stop('backpressure');
123
+ }, keepAliveMs);
124
+ keepTimer.unref?.();
125
+ }
126
+ return {
127
+ stop: () => stop('stopped'),
128
+ get closed() {
129
+ return closed;
130
+ },
131
+ };
132
+ }