@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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The host registry: Fleet's only piece of truth about what it may talk to.
3
+ *
4
+ * Phase 0 scope from docs/fleet-design.md section 12 is registry plus probe, with no dispatch. Routing
5
+ * does not exist yet, so nothing here decides where a Run goes.
6
+ */
7
+ export class RegistryError extends Error {
8
+ }
9
+ /** Operator-assigned and stable: it appears in output and becomes part of every future binding. */
10
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
11
+ /**
12
+ * Validate a base URL.
13
+ *
14
+ * Credentials embedded in the URL are rejected outright. Allowing https://user:token@host would store the
15
+ * secret in the hosts table, which is exactly what credential_ref exists to prevent -- and the table is not
16
+ * written to with 0600 the way the credential file is.
17
+ */
18
+ export function normalizeBaseUrl(raw) {
19
+ let url;
20
+ try {
21
+ url = new URL(raw);
22
+ }
23
+ catch {
24
+ throw new RegistryError(`base url is not a valid URL: ${JSON.stringify(raw)}`);
25
+ }
26
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
27
+ throw new RegistryError(`base url must be http or https, got ${url.protocol}`);
28
+ }
29
+ if (url.username || url.password) {
30
+ throw new RegistryError('base url must not embed credentials. Put the token in the credential file and pass its name ' +
31
+ 'with --credential; a URL secret lands in the database, where the file has 0600 and the table does not.');
32
+ }
33
+ if (url.search || url.hash) {
34
+ throw new RegistryError('base url must not carry a query or fragment');
35
+ }
36
+ // Strip a trailing slash so callers can append paths without producing //api/agents.
37
+ return url.href.replace(/\/+$/, '');
38
+ }
39
+ function validateId(id) {
40
+ if (!ID_RE.test(id)) {
41
+ throw new RegistryError(`host id ${JSON.stringify(id)} is not usable. Use a stable slug of lowercase letters, digits, ` +
42
+ `dot, dash and underscore, e.g. "mac-studio" or "box-lan-2".`);
43
+ }
44
+ }
45
+ function parseJson(raw, fallback) {
46
+ try {
47
+ return JSON.parse(raw);
48
+ }
49
+ catch {
50
+ // A corrupt row must not make the whole registry unreadable; fall back and let the next probe rewrite it.
51
+ return fallback;
52
+ }
53
+ }
54
+ function rowToHost(row) {
55
+ return {
56
+ id: row.id,
57
+ baseUrl: row.base_url,
58
+ credentialRef: row.credential_ref,
59
+ enabled: row.enabled === 1,
60
+ labels: parseJson(row.labels, {}),
61
+ localPaths: parseJson(row.local_paths, []),
62
+ agentsCache: parseJson(row.agents_cache, []),
63
+ addedAt: row.added_at,
64
+ lastSeenAt: row.last_seen_at,
65
+ };
66
+ }
67
+ function rowToProbe(row) {
68
+ return {
69
+ hostId: row.host_id,
70
+ outcome: row.outcome,
71
+ detail: row.detail,
72
+ activeRuns: row.active_runs,
73
+ queueDepth: row.queue_depth,
74
+ workerCount: row.worker_count,
75
+ workerId: row.worker_id,
76
+ agents: row.agents === null ? null : parseJson(row.agents, null),
77
+ probedAt: row.probed_at,
78
+ lastError: row.last_error,
79
+ };
80
+ }
81
+ export class HostRegistry {
82
+ db;
83
+ constructor(db) {
84
+ this.db = db;
85
+ }
86
+ add(input) {
87
+ validateId(input.id);
88
+ const baseUrl = normalizeBaseUrl(input.baseUrl);
89
+ if (!input.credentialRef || !input.credentialRef.trim()) {
90
+ throw new RegistryError('credential_ref is required; pass the NAME of a credential, not the secret');
91
+ }
92
+ for (const p of input.localPaths ?? []) {
93
+ if (!p.startsWith('/')) {
94
+ throw new RegistryError(`local path ${JSON.stringify(p)} is not absolute. These paths are declared as they exist on the ` +
95
+ `WORKER, so a relative path has no meaning to resolve against.`);
96
+ }
97
+ }
98
+ const exists = this.db.prepare('SELECT 1 FROM hosts WHERE id = ?').get(input.id);
99
+ if (exists) {
100
+ throw new RegistryError(`host ${input.id} already exists. Ids are stable and operator-assigned; remove it first if you ` +
101
+ `really mean to replace it, because a binding refers to it by id.`);
102
+ }
103
+ this.db
104
+ .prepare(`INSERT INTO hosts (id, base_url, credential_ref, enabled, labels, local_paths, agents_cache, added_at)
105
+ VALUES (?, ?, ?, ?, ?, ?, '[]', ?)`)
106
+ .run(input.id, baseUrl, input.credentialRef.trim(), input.enabled === false ? 0 : 1, JSON.stringify(input.labels ?? {}), JSON.stringify(input.localPaths ?? []), new Date().toISOString());
107
+ return this.get(input.id);
108
+ }
109
+ get(id) {
110
+ const row = this.db.prepare('SELECT * FROM hosts WHERE id = ?').get(id);
111
+ return row ? rowToHost(row) : null;
112
+ }
113
+ list() {
114
+ const rows = this.db.prepare('SELECT * FROM hosts ORDER BY id').all();
115
+ return rows.map(rowToHost);
116
+ }
117
+ /** Registry plus the cached probe snapshot. This is what `fleet hosts list` renders. */
118
+ listWithProbe() {
119
+ return this.list().map((host) => ({ ...host, probe: this.probeFor(host.id) }));
120
+ }
121
+ probeFor(id) {
122
+ const row = this.db.prepare('SELECT * FROM host_probe WHERE host_id = ?').get(id);
123
+ return row ? rowToProbe(row) : null;
124
+ }
125
+ setEnabled(id, enabled) {
126
+ const res = this.db.prepare('UPDATE hosts SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
127
+ if (Number(res.changes) === 0)
128
+ throw new RegistryError(`no such host: ${id}`);
129
+ return this.get(id);
130
+ }
131
+ /**
132
+ * Forget a host and its cached probe.
133
+ *
134
+ * Refused while the host still owns Runs. `host_probe` cascades because it is cache and a cache row for a
135
+ * host that no longer exists describes nothing. `fleet_runs` must NOT cascade: deleting it would orphan
136
+ * Runs that are executing right now, on a machine Fleet can no longer name. The caller has to deal with
137
+ * those Runs first, or accept the loss explicitly.
138
+ */
139
+ remove(id, opts = {}) {
140
+ if (!opts.force) {
141
+ const owned = this.db
142
+ .prepare('SELECT COUNT(*) AS n, SUM(child_run_id IS NULL) AS unresolved FROM fleet_runs WHERE host_id = ?')
143
+ .get(id);
144
+ const count = Number(owned.n);
145
+ if (count > 0) {
146
+ const unresolved = Number(owned.unresolved ?? 0);
147
+ throw new RegistryError(`host ${id} still owns ${count} Fleet Run(s)${unresolved ? `, ${unresolved} of them with no ` +
148
+ `recorded child answer` : ''}. Removing it would delete the only record of those Runs and leave ` +
149
+ `them running on a machine Fleet cannot name. Let them finish, or pass force to accept the loss.`);
150
+ }
151
+ }
152
+ else {
153
+ // Explicit force: drop the bindings first so the FK does not reject the host row, and say nothing
154
+ // here -- the caller asked for this and the route records it.
155
+ this.db.prepare('DELETE FROM fleet_runs WHERE host_id = ?').run(id);
156
+ }
157
+ const res = this.db.prepare('DELETE FROM hosts WHERE id = ?').run(id);
158
+ return Number(res.changes) > 0;
159
+ }
160
+ /**
161
+ * Record a probe. The cache row is always written, including for failures, because "we tried and it
162
+ * refused" is information the operator needs; last_seen_at moves only on success, because it answers
163
+ * "when did we last know this host was alive", and a 401 does not establish that.
164
+ */
165
+ recordProbe(rec) {
166
+ const host = this.get(rec.hostId);
167
+ if (!host)
168
+ throw new RegistryError(`cannot record probe for unknown host ${rec.hostId}`);
169
+ this.db
170
+ .prepare(`INSERT INTO host_probe
171
+ (host_id, outcome, detail, active_runs, queue_depth, worker_count, worker_id, agents, probed_at, last_error)
172
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
173
+ ON CONFLICT(host_id) DO UPDATE SET
174
+ outcome = excluded.outcome, detail = excluded.detail,
175
+ active_runs = excluded.active_runs, queue_depth = excluded.queue_depth,
176
+ worker_count = excluded.worker_count, worker_id = excluded.worker_id,
177
+ agents = excluded.agents, probed_at = excluded.probed_at, last_error = excluded.last_error`)
178
+ .run(rec.hostId, rec.outcome, rec.detail, rec.activeRuns, rec.queueDepth, rec.workerCount, rec.workerId, rec.agents === null ? null : JSON.stringify(rec.agents), rec.probedAt, rec.lastError);
179
+ if (rec.outcome === 'ok') {
180
+ this.db
181
+ .prepare('UPDATE hosts SET last_seen_at = ?, agents_cache = ? WHERE id = ?')
182
+ .run(rec.probedAt, JSON.stringify(rec.agents ?? []), rec.hostId);
183
+ }
184
+ else if (rec.agents !== null) {
185
+ // A reachable host that failed a deeper check still told us something about its agents; keep the
186
+ // advisory cache honest without claiming the host was seen healthy.
187
+ this.db.prepare('UPDATE hosts SET agents_cache = ? WHERE id = ?').run(JSON.stringify(rec.agents), rec.hostId);
188
+ }
189
+ }
190
+ /** Drop cached probe rows for hosts that no longer exist. Cheap hygiene after an interrupted delete. */
191
+ pruneProbeCache() {
192
+ const res = this.db
193
+ .prepare('DELETE FROM host_probe WHERE host_id NOT IN (SELECT id FROM hosts)')
194
+ .run();
195
+ return Number(res.changes);
196
+ }
197
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Routing (docs/fleet-design.md section 6).
3
+ *
4
+ * A pure function over declared facts. It is pure deliberately: a router tested only by "a Run got placed"
5
+ * cannot tell a working filter from an inert one, because both place the Run. Every exclusion here is returned
6
+ * as data, so a test can assert not just that a host was chosen but that the WRONG hosts were rejected and
7
+ * for the stated reason.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ export class RoutingError extends Error {
11
+ status;
12
+ exclusions;
13
+ constructor(message, status, exclusions) {
14
+ super(message);
15
+ this.name = 'RoutingError';
16
+ this.status = status;
17
+ this.exclusions = exclusions;
18
+ }
19
+ }
20
+ /**
21
+ * Normalise a path for comparison without touching the filesystem.
22
+ *
23
+ * Fleet cannot see a child's disk (section 4.2), so this is string work on declared values only -- it must not
24
+ * resolve symlinks or stat anything, because the path exists on another machine.
25
+ */
26
+ function normalizePath(p) {
27
+ const trimmed = p.replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
28
+ return trimmed;
29
+ }
30
+ function declaresPath(host, localPath) {
31
+ const want = normalizePath(localPath);
32
+ return host.localPaths.some((declared) => {
33
+ const have = normalizePath(declared);
34
+ // A declared path covers itself and anything beneath it: declaring a repo root is the common case and
35
+ // requiring operators to list every subdirectory would push them toward declaring '/' instead.
36
+ return have === want || want.startsWith(`${have}/`);
37
+ });
38
+ }
39
+ /**
40
+ * Score a host for preference. Lower is better.
41
+ *
42
+ * Only reached when several hosts survive the hard filters, and it can never override one: capacity is a
43
+ * preference, locality is a constraint. A null capacity means the host has not been probed recently, which is
44
+ * treated as neutral rather than free -- an unprobed host must not look like the emptiest machine in the fleet.
45
+ */
46
+ function score(host) {
47
+ const p = host.probe;
48
+ if (!p || p.outcome !== 'ok')
49
+ return 1_000;
50
+ const active = p.activeRuns ?? 0;
51
+ const queued = p.queueDepth ?? 0;
52
+ const workers = p.workerCount ?? 1;
53
+ // One unit of headroom per worker keeps a big machine from being penalised for its capacity.
54
+ const headroom = Math.max(0, active + queued - workers);
55
+ return active * 2 + queued + headroom;
56
+ }
57
+ function bad(field, value) {
58
+ throw new RoutingError(`${field} must be ${'a non-empty string'}, got ${JSON.stringify(value) ?? typeof value}`, 400, []);
59
+ }
60
+ function validateRequest(request) {
61
+ if (request.host !== undefined && (typeof request.host !== 'string' || !request.host))
62
+ bad('host', request.host);
63
+ if (request.agent !== undefined && (typeof request.agent !== 'string' || !request.agent))
64
+ bad('agent', request.agent);
65
+ if (request.repository !== undefined) {
66
+ if (typeof request.repository !== 'object' || request.repository === null || Array.isArray(request.repository)) {
67
+ bad('repository', request.repository);
68
+ }
69
+ for (const key of ['url', 'localPath', 'ref']) {
70
+ const v = request.repository[key];
71
+ if (v !== undefined && (typeof v !== 'string' || !v))
72
+ bad(`repository.${key}`, v);
73
+ }
74
+ }
75
+ if (request.labels !== undefined) {
76
+ if (typeof request.labels !== 'object' || request.labels === null || Array.isArray(request.labels)) {
77
+ bad('labels', request.labels);
78
+ }
79
+ for (const [k, v] of Object.entries(request.labels)) {
80
+ if (typeof v !== 'string')
81
+ bad(`labels.${k}`, v);
82
+ }
83
+ }
84
+ }
85
+ export function routeRun(hosts, request, opts = {}) {
86
+ // Types are checked here rather than trusted. A body like {"repository":{"localPath":123}} would otherwise
87
+ // reach a .replace() on a number and surface as a 500 -- sending the operator to the service logs for what
88
+ // is a mistake in their own request.
89
+ validateRequest(request);
90
+ const considered = hosts.map((h) => h.id);
91
+ const exclusions = [];
92
+ if (hosts.length === 0) {
93
+ throw new RoutingError('no hosts are registered with Fleet', 400, []);
94
+ }
95
+ // Explicit placement first: an operator who names a host has already made the decision.
96
+ if (request.host) {
97
+ const chosen = hosts.find((h) => h.id === request.host);
98
+ if (!chosen) {
99
+ throw new RoutingError(`host ${JSON.stringify(request.host)} is not registered. Known hosts: ${considered.join(', ') || '(none)'}`, 404, []);
100
+ }
101
+ if (!chosen.enabled) {
102
+ throw new RoutingError(`host ${chosen.id} is disabled`, 409, [{ hostId: chosen.id, reason: 'disabled' }]);
103
+ }
104
+ // The rewrite still applies. Naming a host removes locality as a FILTER, but a localPath handed to a child
105
+ // that does not have it still fails there -- and it fails as a Run failure rather than as a routing
106
+ // decision, which is the worse place to learn it.
107
+ const explicitUrl = !request.repository?.url && request.repository?.localPath
108
+ ? opts.resolveCloneUrl?.(normalizePath(request.repository.localPath)) ?? null
109
+ : null;
110
+ return {
111
+ hostId: chosen.id,
112
+ score: 0,
113
+ rewroteLocalPath: explicitUrl !== null,
114
+ repository: explicitUrl ? { ...request.repository, url: explicitUrl, localPath: undefined } : request.repository,
115
+ considered,
116
+ };
117
+ }
118
+ const repo = request.repository;
119
+ const localPath = repo?.localPath;
120
+ // A caller who supplies a clone URL has already removed the constraint themselves; the design calls the
121
+ // rewrite the most valuable thing in this section precisely because it turns the hardest rule into a non-issue.
122
+ const rewritten = !repo?.url && localPath ? opts.resolveCloneUrl?.(normalizePath(localPath)) ?? null : null;
123
+ const localityConstrained = Boolean(localPath) && !repo?.url && !rewritten;
124
+ const survivors = [];
125
+ for (const host of hosts) {
126
+ if (!host.enabled) {
127
+ exclusions.push({ hostId: host.id, reason: 'disabled' });
128
+ continue;
129
+ }
130
+ if (localityConstrained && !declaresPath(host, localPath)) {
131
+ exclusions.push({
132
+ hostId: host.id,
133
+ reason: `does not declare ${normalizePath(localPath)} among its local paths `
134
+ + `[${host.localPaths.join(', ') || 'none declared'}]`,
135
+ });
136
+ continue;
137
+ }
138
+ if (request.agent) {
139
+ // Prefer a fresh probe over the cached list; the cache is advisory (section 5) and only used when
140
+ // nothing fresher exists.
141
+ const agents = host.probe?.agents ?? host.agentsCache;
142
+ if (!agents.includes(request.agent)) {
143
+ exclusions.push({
144
+ hostId: host.id,
145
+ reason: `does not offer agent ${JSON.stringify(request.agent)} `
146
+ + `(known: ${agents.join(', ') || 'none'})`,
147
+ });
148
+ continue;
149
+ }
150
+ }
151
+ const unmatched = Object.entries(request.labels ?? {}).filter(([k, v]) => host.labels[k] !== v);
152
+ if (unmatched.length > 0) {
153
+ exclusions.push({
154
+ hostId: host.id,
155
+ reason: `labels do not match: needs ${unmatched.map(([k, v]) => `${k}=${v}`).join(', ')}`
156
+ + `; has ${Object.keys(host.labels).length ? Object.entries(host.labels).map(([k, v]) => `${k}=${v}`).join(', ') : 'no labels'}`,
157
+ });
158
+ continue;
159
+ }
160
+ survivors.push({ host, score: score(host) });
161
+ }
162
+ if (survivors.length === 0) {
163
+ // Section 6: a localPath with no matching host is a submission error, not a scheduling wait. Silently
164
+ // queueing it, or quietly rewriting it to a URL nobody asked for, turns a five-second mistake into an hour
165
+ // of confusion -- so the answer names every host that was considered and why each was ruled out.
166
+ const lines = exclusions.map((e) => ` - ${e.hostId}: ${e.reason}`).join('\n');
167
+ throw new RoutingError(`no host can run this task. ${hosts.length} considered:\n${lines}`
168
+ + (localityConstrained
169
+ ? '\nDeclare the path on a host (fleet hosts edit --local-path), or give a git url so locality stops mattering.'
170
+ : ''), 400, exclusions);
171
+ }
172
+ survivors.sort((a, b) => a.score - b.score || a.host.id.localeCompare(b.host.id));
173
+ const chosen = survivors[0];
174
+ const repository = rewritten
175
+ ? { ...repo, url: rewritten, localPath: undefined }
176
+ : repo;
177
+ return {
178
+ hostId: chosen.host.id,
179
+ score: chosen.score,
180
+ rewroteLocalPath: rewritten !== null,
181
+ repository,
182
+ considered,
183
+ };
184
+ }
185
+ /**
186
+ * Load the local-path -> clone-URL map.
187
+ *
188
+ * Returns null when unset so callers keep locality as a hard constraint rather than silently routing
189
+ * everywhere. A malformed file is an operator error worth hearing about, so it throws rather than degrading to
190
+ * "no mapping", which would look like a routing bug.
191
+ */
192
+ export function loadRepoUrlMap(file) {
193
+ if (!file)
194
+ return undefined;
195
+ let raw;
196
+ try {
197
+ raw = readFileSync(file, 'utf8');
198
+ }
199
+ catch (err) {
200
+ throw new Error(`FLEET_REPO_URLS_FILE ${JSON.stringify(file)} could not be read: ${err.message}`);
201
+ }
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(raw);
205
+ }
206
+ catch (err) {
207
+ throw new Error(`FLEET_REPO_URLS_FILE is not valid JSON: ${err.message}`);
208
+ }
209
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
210
+ throw new Error('FLEET_REPO_URLS_FILE must contain a JSON object of localPath -> git URL');
211
+ }
212
+ const map = {};
213
+ for (const [k, v] of Object.entries(parsed)) {
214
+ if (typeof v !== 'string' || !v)
215
+ throw new Error(`repo url for ${JSON.stringify(k)} must be a non-empty string`);
216
+ map[k.replace(/\/+$/, '') || '/'] = v;
217
+ }
218
+ return (localPath) => map[localPath.replace(/\/+$/, '') || '/'] ?? null;
219
+ }