@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/sweep.js ADDED
@@ -0,0 +1,183 @@
1
+ import { UNKNOWN } from "./bindings.js";
2
+ import { resolveHost } from "./dispatch.js";
3
+ import { mirrorEvents } from "./events.js";
4
+ /**
5
+ * Exactly Mercury's TERMINAL_STATUSES (src/domain/stateMachine.ts). Section 7: these are the ONLY states
6
+ * Fleet may copy from a child. Anything else Fleet either derives (UNKNOWN, LOST) or leaves alone.
7
+ */
8
+ export const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT']);
9
+ /**
10
+ * Fleet's own state for "the binding names a Run the child says it never heard of". Not a Run outcome: the
11
+ * Run may be finished and garbage-collected, or the child's database may have been reset. Either way it is
12
+ * an operator event, which is why it is reported rather than mapped onto FAILED.
13
+ */
14
+ export const LOST = 'LOST';
15
+ export function isTerminal(status) {
16
+ return status !== null && status !== undefined && TERMINAL_STATUSES.has(status);
17
+ }
18
+ /**
19
+ * Compose an operator-facing reason with the child's own detail when it gave one. Appending unconditionally
20
+ * left a trailing colon on every response with an empty body, which is most 502s from a proxy.
21
+ */
22
+ function withDetail(base, detail) {
23
+ return detail ? `${base}: ${detail}` : base;
24
+ }
25
+ function empty() {
26
+ return { examined: 0, advanced: 0, stale: 0, lost: 0, pending: 0, skippedTerminal: 0 };
27
+ }
28
+ /**
29
+ * One reconciliation pass: for every non-terminal binding, re-read the child and advance the cache.
30
+ *
31
+ * Section 7 is the spec, and the asymmetry in it is the point. A transport failure is not a Run outcome, so
32
+ * the last known status stands and only staleness is recorded; overwriting it with FAILED would destroy a Run
33
+ * that is still running and spending money. A 404 is different in kind -- the binding asserts existence and
34
+ * the child denies it -- so it becomes LOST and is reported, never silently kept.
35
+ */
36
+ export async function sweepOnce(deps, opts = {}) {
37
+ const report = empty();
38
+ const views = deps.bindings.list(opts.hostIds ?? '*');
39
+ const now = new Date().toISOString();
40
+ for (const view of views) {
41
+ const terminal = isTerminal(view.state?.status);
42
+ // A terminal Run needs no further status reads, but it may still owe an event log. Skipping terminal
43
+ // bindings outright was wrong twice over: a Run that finished between two sweeps never had its log read,
44
+ // and a Run whose one log read failed was never given another. The drain flag is what makes "finished"
45
+ // mean finished rather than "finished and permanently unread".
46
+ const owesEvents = opts.events !== undefined && !(terminal && (view.state?.eventsDrained ?? false));
47
+ if (terminal && !owesEvents) {
48
+ report.skippedTerminal++;
49
+ continue;
50
+ }
51
+ if (terminal) {
52
+ // Finished, but its log is still owed -- either never read or a read that failed. Events only: the
53
+ // status is settled and re-reading it would undo the skip above.
54
+ try {
55
+ await mirrorEvents(opts.events, view.fleetRunId);
56
+ }
57
+ catch (err) {
58
+ deps.bindings.recordState({
59
+ fleetRunId: view.fleetRunId, status: view.state.status,
60
+ cursor: view.state?.cursor ?? 0, lastSeenAt: view.state?.lastSeenAt ?? null,
61
+ lastError: `events still unreadable: ${err.message}`,
62
+ });
63
+ }
64
+ continue;
65
+ }
66
+ if (!view.childRunId) {
67
+ // No child answer yet. This sweep has nothing to read; recoverPending owns getting an answer.
68
+ report.pending++;
69
+ continue;
70
+ }
71
+ let host;
72
+ try {
73
+ host = resolveHost(deps, view.hostId);
74
+ }
75
+ catch (err) {
76
+ // Host gone from the registry or disabled. Keep the last known status and say why it is stale; the
77
+ // registry refuses deletion while Runs are bound precisely so this stays rare and recoverable.
78
+ report.stale++;
79
+ deps.bindings.recordState({
80
+ fleetRunId: view.fleetRunId,
81
+ status: view.state?.status ?? UNKNOWN,
82
+ cursor: view.state?.cursor ?? 0,
83
+ lastSeenAt: view.state?.lastSeenAt ?? null,
84
+ lastError: err.message,
85
+ });
86
+ continue;
87
+ }
88
+ report.examined++;
89
+ const childRunId = view.childRunId;
90
+ const result = await deps.child.getRun(host, childRunId);
91
+ if (result.kind === 'ok') {
92
+ deps.bindings.recordState({
93
+ fleetRunId: view.fleetRunId,
94
+ status: result.value.status,
95
+ cursor: view.state?.cursor ?? 0,
96
+ lastSeenAt: now,
97
+ lastError: result.value.error ?? null,
98
+ });
99
+ report.advanced++;
100
+ // Mirror events, including for a Run that has just finished. Skipping terminal Runs outright was a real
101
+ // bug found end-to-end: a Run that ended before the first sweep never had its log mirrored at all, so
102
+ // Fleet could not show how anything finished. Once drained, a terminal Run is left alone.
103
+ if (opts.events && !(isTerminal(result.value.status) && (view.state?.eventsDrained ?? false))) {
104
+ try {
105
+ await mirrorEvents(opts.events, view.fleetRunId);
106
+ }
107
+ catch (err) {
108
+ deps.bindings.recordState({
109
+ fleetRunId: view.fleetRunId, status: result.value.status,
110
+ cursor: view.state?.cursor ?? 0, lastSeenAt: now,
111
+ lastError: `status read ok, events unreadable: ${err.message}`,
112
+ });
113
+ }
114
+ }
115
+ continue;
116
+ }
117
+ if (result.kind === 'rejected' && result.status === 404) {
118
+ const wasLost = view.state?.status === LOST;
119
+ deps.bindings.recordState({
120
+ fleetRunId: view.fleetRunId, status: LOST, cursor: view.state?.cursor ?? 0,
121
+ lastSeenAt: now,
122
+ lastError: withDetail('child reports no such Run (HTTP 404)', result.detail),
123
+ });
124
+ if (!wasLost) {
125
+ report.lost++;
126
+ opts.onEvent?.({ kind: 'lost', fleetRunId: view.fleetRunId, hostId: view.hostId, childRunId, detail: result.detail });
127
+ }
128
+ continue;
129
+ }
130
+ // 5xx, or the child is unreachable. Keep whatever we last knew and record the reason for doubt.
131
+ report.stale++;
132
+ deps.bindings.recordState({
133
+ fleetRunId: view.fleetRunId,
134
+ status: view.state?.status ?? UNKNOWN,
135
+ cursor: view.state?.cursor ?? 0,
136
+ lastSeenAt: view.state?.lastSeenAt ?? null,
137
+ lastError: result.kind === 'unknown'
138
+ ? result.reason
139
+ : withDetail(`child said HTTP ${result.status}`, result.detail),
140
+ });
141
+ }
142
+ return report;
143
+ }
144
+ /**
145
+ * Run the sweep on a timer, from startup, until stopped.
146
+ *
147
+ * Its own timer rather than a hook in the request path: section 7 says reconciliation is what makes
148
+ * everything else recoverable, so it must keep working when nobody is asking. A sweep that only runs when an
149
+ * operator opens the page reports exactly the staleness it exists to prevent.
150
+ */
151
+ export function startSweeper(deps, opts) {
152
+ let running = false;
153
+ let stopped = false;
154
+ const tick = async () => {
155
+ // Set synchronously, before the first await. An await between the check and the set lets two timers both
156
+ // see false and run concurrent passes that fight over the same cache rows.
157
+ if (running || stopped)
158
+ return;
159
+ running = true;
160
+ try {
161
+ await sweepOnce(deps, { hostIds: opts.hostIds, onEvent: opts.onEvent, events: opts.events });
162
+ }
163
+ catch (err) {
164
+ opts.onError?.(err);
165
+ }
166
+ finally {
167
+ running = false;
168
+ }
169
+ };
170
+ const timer = setInterval(() => void tick(), opts.intervalMs);
171
+ // A pending timer must not be the reason the process stays alive during shutdown.
172
+ timer.unref?.();
173
+ void tick();
174
+ return {
175
+ stop() {
176
+ stopped = true;
177
+ clearInterval(timer);
178
+ },
179
+ get running() {
180
+ return running;
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,7 @@
1
+ /** Fleet product id as reported by `--version` and `GET /healthz`. */
2
+ export const FLEET_PRODUCT = 'fleet';
3
+ /**
4
+ * Fleet SemVer. Must equal `fleet/package.json` `"version"`.
5
+ * `fleet/test/version.test.ts` asserts the two stay the same.
6
+ */
7
+ export const FLEET_VERSION = '0.1.0';
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@aywengo/mercury-fleet",
3
+ "version": "0.0.1-bootstrap",
4
+ "license": "MIT",
5
+ "description": "Federation layer for independent Mercury hosts: host registry, probing, Run dispatch and routing, reconciliation, event aggregation and a Prometheus rollup",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22.18.0"
9
+ },
10
+ "bin": {
11
+ "fleet": "dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist/",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "prepare": "node -e \"const fs=require('node:fs');const p='../node_modules/typescript/bin/tsc';if(!fs.existsSync(p)){console.error('fleet: TypeScript is not installed. Run npm ci at the repository root before packing or publishing fleet/.');process.exit(1)}\" && node ../node_modules/typescript/bin/tsc -p ../tsconfig.fleet.json"
24
+ }
25
+ }