@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,2004 @@
1
+ /**
2
+ * W2M Rabbit relay — in-memory state machine.
3
+ *
4
+ * Contract: E:\DS\_work\w2m\PROTOCOL.md (protocol_version = 1, frozen).
5
+ * This module is intentionally PURE LOGIC:
6
+ * - no network, no filesystem, no timers
7
+ * - time is injectable via `now()` (required for lease-expiry unit tests)
8
+ * - randomness is injectable for deterministic tests
9
+ *
10
+ * Persistence: none. All state is in-memory and is lost on restart (v0.0.1 scope);
11
+ * there is intentionally no snapshot/serialization hook yet.
12
+ */
13
+
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+
16
+ export const PROTOCOL_VERSION = 1;
17
+
18
+ /**
19
+ * PROTOCOL §1 table + §7 code table, plus the two v0.1.2 additions:
20
+ * `OPERATOR_REQUIRED` (§5.2 → 401) and `RATE_LIMITED` (§6 → 429).
21
+ * A code missing from this map silently degrades to HTTP 500 via
22
+ * `ProtocolError`, which keeps the right error code but lies about the status.
23
+ */
24
+ export const ERROR_STATUS = Object.freeze({
25
+ UNAUTHORIZED: 401,
26
+ BAD_REQUEST: 400,
27
+ NOT_FOUND: 404,
28
+ PAIRING_INVALID: 400,
29
+ PAIRING_EXPIRED: 400,
30
+ TASK_EXISTS: 409,
31
+ FRAME_TOO_LARGE: 413,
32
+ NO_ONLINE_DEVICE: 503,
33
+ INTERNAL: 500,
34
+ OPERATOR_REQUIRED: 401, // v0.1.2 §5.2
35
+ RATE_LIMITED: 429, // v0.1.2 §6
36
+
37
+ /* v0.3.0 §9 request signing. Every client-side signature failure is a 401.
38
+ * `SIGNING_NOT_CONFIGURED` is deliberately NOT 401: the relay was started with
39
+ * `--require-signature` but no secret, so the fault is the relay's own
40
+ * configuration. Reporting that as 401 would send every operator to debug the
41
+ * client -- the one side that is not broken. 500 rather than 503 because
42
+ * retrying never fixes a missing configuration, so nothing should be invited to
43
+ * retry. */
44
+ SIGNATURE_REQUIRED: 401,
45
+ SIGNATURE_INCOMPLETE: 401,
46
+ SIGNATURE_MISMATCH: 401,
47
+ SIGNATURE_BAD_TIMESTAMP: 401,
48
+ SIGNATURE_BAD_NONCE: 401,
49
+ SIGNATURE_EXPIRED: 401,
50
+ SIGNATURE_REPLAY: 401,
51
+ SIGNING_NOT_CONFIGURED: 500,
52
+ });
53
+
54
+ /** §5.1 required envelope fields — missing any one means `unverifiable`. */
55
+ export const REQUIRED_ENVELOPE_FIELDS = Object.freeze([
56
+ 'envelope_version', 'task_id', 'attempt', 'dedupe_key', 'machine_id', 'machine_name',
57
+ 'platform', 'caps', 'index', 'index_total', 'mode', 'cwd_rel', 'base_commit', 'base_tree',
58
+ 'pre_tree_fingerprint', 'post_tree_fingerprint', 'fingerprint_algo', 'fingerprint_error',
59
+ 'head_commit', 'dirty_before', 'command_argv', 'command_hash', 'shell_id',
60
+ 'started_at', 'ended_at', 'duration_ms', 'exit_code', 'status', 'refusal_reason',
61
+ 'stdout_sha256', 'stdout_bytes', 'stderr_sha256', 'stderr_bytes', 'warnings',
62
+ 'envelope_sha256',
63
+ ]);
64
+
65
+ /** §5.3 comparable fields — the ONLY fields that take part in consistency judgement. */
66
+ export const COMPARABLE_FIELDS = Object.freeze([
67
+ 'task_id', 'index', 'index_total', 'mode', 'cwd_rel', 'base_commit', 'base_tree',
68
+ 'pre_tree_fingerprint', 'fingerprint_algo', 'fingerprint_error', 'head_commit',
69
+ 'command_hash', 'shell_id', 'exit_code', 'status', 'refusal_reason',
70
+ 'stdout_sha256', 'stdout_bytes', 'stdout_normalized_sha256',
71
+ 'stderr_sha256', 'artifacts', 'diff_numstat', 'tests', 'semantic_counts', 'warnings',
72
+ ]);
73
+
74
+ /** §5.1 status enum. */
75
+ export const RESULT_STATUSES = Object.freeze([
76
+ 'ok', 'nonzero_exit', 'timeout', 'crashed', 'refused', 'unverifiable',
77
+ ]);
78
+
79
+ export const FAILURE_STATUSES = Object.freeze(['nonzero_exit', 'timeout', 'crashed']);
80
+
81
+ /** §4.3 phase enum. */
82
+ export const LEASE_PHASES = Object.freeze(['preparing', 'running', 'finalizing']);
83
+
84
+ /** Lease states that can still be expired / renewed. */
85
+ export const ACTIVE_LEASE_STATES = Object.freeze(['queued', 'offered', 'running']);
86
+
87
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; // §4.3: agent heartbeats every 10s
88
+ export const DEFAULT_MISSED_HEARTBEATS = 2; // §4.3: 2 missed cycles = 20s
89
+ export const DEFAULT_LEASE_GRACE_MS = 30_000; // §4.3: + grace 30s
90
+ /**
91
+ * v0.4.0: the bounded hold on the relay's own offer when a dispatcher is pushing it directly.
92
+ *
93
+ * Sized against what it has to cover: one punch round trip (measured 15-370 ms on the reference
94
+ * networks), the frame's delivery, and the executor's first heartbeat POST — all on a host that may
95
+ * be busy. 1.2 s was the first guess and it was too tight: measured on a 1-vCPU shared host, two
96
+ * agent tests saw the relay's copy overtake the direct one, which for `p2pMode: direct` means a
97
+ * refusal of an offer that was about to arrive. 3 s covers a slow host with room to spare.
98
+ *
99
+ * The cost is honest and bounded: a punch that fails delays the relay's copy by up to this much,
100
+ * once per task. Nothing else waits on it.
101
+ */
102
+ export const DEFAULT_P2P_OFFER_GRACE_MS = 3_000;
103
+ export const DEFAULT_PAIRING_TTL_MS = 24 * 60 * 60 * 1000; // §2.2: 24h
104
+ export const DEFAULT_EVENT_BUFFER_SIZE = 1000; // §requirement: keep last 1000 events
105
+ export const MAX_FRAME_BYTES = 64 * 1024; // 64 KiB single frame
106
+ export const SHELL_ID_DIRECT = 'direct-exec'; // §5.1: v0.0.1 only direct exec
107
+ /** v0.3.0: RTT sanity ceiling — anything above 24h is treated as a broken value. */
108
+ export const MAX_RTT_MS = 24 * 60 * 60 * 1000;
109
+ /** v0.3.0: an RTT measurement older than this is reported as stale.
110
+ * Must stay comfortably ABOVE the idle-heartbeat interval (60s): at 1x the value
111
+ * would flip stale just before every refresh and flap, which would make
112
+ * `rtt_stale === false` useless as a "trust this number" predicate. 3x gives two
113
+ * consecutive missed idle heartbeats of margin. See PROTOCOL-v0.3.0.md §7. */
114
+ export const DEFAULT_RTT_STALE_MS = 180_000;
115
+ /** v0.3.0: recommended cadence for an agent with no lease (reference for the agent side). */
116
+ export const DEFAULT_IDLE_HEARTBEAT_MS = 60_000;
117
+ export const ENVELOPE_VERSION = '1.0';
118
+ export const FINGERPRINT_ALGO = 'git-temp-index-tree/v1';
119
+
120
+ /* ------------------------------------------------------------------ */
121
+ /* Errors */
122
+ /* ------------------------------------------------------------------ */
123
+
124
+ export class ProtocolError extends Error {
125
+ constructor(code, message, detail = {}) {
126
+ super(message);
127
+ this.name = 'ProtocolError';
128
+ this.code = code;
129
+ this.detail = detail;
130
+ this.status = ERROR_STATUS[code] ?? 500;
131
+ }
132
+ toBody() {
133
+ return { error: { code: this.code, message: this.message, detail: this.detail } };
134
+ }
135
+ }
136
+
137
+ /* ------------------------------------------------------------------ */
138
+ /* Small pure helpers */
139
+ /* ------------------------------------------------------------------ */
140
+
141
+ export function sha256Hex(input) {
142
+ return createHash('sha256').update(input).digest('hex');
143
+ }
144
+
145
+ /** RFC 8785-ish canonical JSON (JCS): sorted keys, no whitespace. */
146
+ export function jcs(value) {
147
+ if (value === null) return 'null';
148
+ const t = typeof value;
149
+ if (t === 'number') return Number.isFinite(value) ? JSON.stringify(value) : 'null';
150
+ if (t === 'boolean') return value ? 'true' : 'false';
151
+ if (t === 'string') return JSON.stringify(value);
152
+ if (t === 'undefined' || t === 'function' || t === 'symbol') return 'null';
153
+ if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? 'null' : jcs(v))).join(',')}]`;
154
+ if (t === 'object') {
155
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
156
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`).join(',')}}`;
157
+ }
158
+ return JSON.stringify(String(value));
159
+ }
160
+
161
+ /**
162
+ * Upper bound on the stages a `pipeline` may contain.
163
+ *
164
+ * Each stage is another command run on someone else's machine, so an unbounded list is an unbounded
165
+ * amount of work created by one request. The limit is generous for the intended use (build, test,
166
+ * package) and exists so the failure is a clear refusal rather than a fleet-wide surprise.
167
+ */
168
+ export const MAX_PIPELINE_STAGES = 16;
169
+
170
+ /**
171
+ * v0.4.0: upper bound on `POST /v1/task`'s `origin_machine_id`.
172
+ *
173
+ * A machine id is an identifier, not a payload: 128 characters is far beyond any id this system
174
+ * mints, and the ceiling is what stops the field from becoming an unbounded free-text channel into
175
+ * every offer frame the relay emits.
176
+ */
177
+ export const MAX_ORIGIN_MACHINE_ID_LENGTH = 128;
178
+
179
+ /**
180
+ * v0.4.0: the P2P modes a task may be dispatched under (§1 of the frozen v0.4.0 contract).
181
+ *
182
+ * Mirrored here rather than imported from `src/agent/p2p-node.mjs`: the relay is a standalone
183
+ * process that must not depend on agent code, and a mode list is three strings. A value outside
184
+ * this list is a configuration error, never a silent fall back to the default.
185
+ */
186
+ export const P2P_MODES = Object.freeze(['auto', 'direct', 'relay']);
187
+
188
+ /**
189
+ * v0.4.0: how a result travelled — the direct P2P channel or the relay.
190
+ *
191
+ * A **transport is not a result**: how an envelope arrived must never take part in the consistency
192
+ * judgement (§5 of the contract), or two machines that produced byte-identical output would be
193
+ * reported `divergent` merely because one of them punched a hole. Hence: not required, not
194
+ * comparable, and absent on every v0.3.9 envelope.
195
+ */
196
+ export const TRANSPORT_VALUES = Object.freeze(['p2p', 'relay']);
197
+
198
+ /** §4.4 helper: command_hash = sha256(JCS(argv)+"|"+shell_id+"|"+cwd_rel). */
199
+ export function computeCommandHash(commandArgv, shellId = SHELL_ID_DIRECT, cwdRel = '.') {
200
+ return sha256Hex(`${jcs(commandArgv)}|${shellId}|${cwdRel}`);
201
+ }
202
+
203
+ /**
204
+ * `command_hash` for a whole pipeline.
205
+ *
206
+ * The hash must bind **every** stage, not just the first. Bound to stage 0 alone, two pipelines that
207
+ * differ after the first stage would carry the same hash -- so an envelope produced by the wrong
208
+ * chain would still verify, and making "the machine ran what I asked" checkable is the entire point
209
+ * of this field. Each stage's cwd participates for the same reason.
210
+ *
211
+ * The `|pipeline` suffix means this can never collide with a single-command hash: the two hash over
212
+ * different shapes, and that difference is deliberate rather than incidental.
213
+ */
214
+ export function computePipelineCommandHash(stages, shellId = SHELL_ID_DIRECT) {
215
+ const canonical = stages.map((s) => ({
216
+ command_argv: s.command_argv,
217
+ cwd_rel: s.cwd_rel ?? '.',
218
+ continue_on_failure: s.continue_on_failure === true,
219
+ }));
220
+ return sha256Hex(`${jcs(canonical)}|${shellId}|pipeline`);
221
+ }
222
+
223
+ /** §4.4: dedupe_key = sha256(task_id + "|" + index + "|" + command_hash + "|" + base_tree). */
224
+ export function computeDedupeKey(taskId, index, commandHash, baseTree) {
225
+ return sha256Hex(`${taskId}|${index}|${commandHash}|${baseTree}`);
226
+ }
227
+
228
+ /** Epoch ms -> RFC3339 UTC, second precision (matches the PROTOCOL examples). */
229
+ export function rfc3339(ms) {
230
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
231
+ }
232
+
233
+ export function isRfc3339(value) {
234
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value);
235
+ }
236
+
237
+ export function deepEqual(a, b) {
238
+ return jcs(a === undefined ? null : a) === jcs(b === undefined ? null : b);
239
+ }
240
+
241
+ function normalizeNow(now) {
242
+ if (typeof now !== 'function') return () => Date.now();
243
+ return () => {
244
+ const v = now();
245
+ if (typeof v === 'number') return v;
246
+ if (v instanceof Date) return v.getTime();
247
+ return Date.parse(v);
248
+ };
249
+ }
250
+
251
+ /* ------------------------------------------------------------------ */
252
+ /* ULID */
253
+ /* ------------------------------------------------------------------ */
254
+
255
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
256
+
257
+ function encodeBig(value, len) {
258
+ let out = '';
259
+ let v = value;
260
+ for (let i = 0; i < len; i += 1) {
261
+ out = CROCKFORD[Number(v & 31n)] + out;
262
+ v >>= 5n;
263
+ }
264
+ return out;
265
+ }
266
+
267
+ export function createIdFactory() {
268
+ let lastMs = -1;
269
+ let lastRand = 0n;
270
+ return function nextId(nowMs = Date.now()) {
271
+ const bytes = randomBytes(10);
272
+ let rand = 0n;
273
+ for (const b of bytes) rand = (rand << 8n) | BigInt(b);
274
+ if (nowMs === lastMs) rand = (lastRand + 1n) & ((1n << 80n) - 1n);
275
+ lastMs = nowMs;
276
+ lastRand = rand;
277
+ return encodeBig(BigInt(nowMs), 10) + encodeBig(rand, 16);
278
+ };
279
+ }
280
+
281
+ export function ulid(nowMs = Date.now()) {
282
+ return createIdFactory()(nowMs);
283
+ }
284
+
285
+ /* ------------------------------------------------------------------ */
286
+ /* Version / capability gate (§6.1) */
287
+ /* ------------------------------------------------------------------ */
288
+
289
+ function parseVersion(text) {
290
+ if (typeof text !== 'string') return null;
291
+ const m = text.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/);
292
+ if (!m) return null;
293
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0), Number(m[4] ?? 0)];
294
+ }
295
+
296
+ function cmpVersion(a, b) {
297
+ for (let i = 0; i < 4; i += 1) {
298
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
299
+ }
300
+ return 0;
301
+ }
302
+
303
+ /**
304
+ * Minimal semver-range check used by the §6.1 capability gate.
305
+ * Supports bare versions and comma/space separated `>= > <= < = ==` conjunctions.
306
+ * Unparseable ranges are treated as NOT satisfied (never silently pass).
307
+ */
308
+ export function satisfiesRange(version, range) {
309
+ const v = parseVersion(version);
310
+ if (v === null) return false;
311
+ if (typeof range !== 'string') return false;
312
+ const parts = range.split(/[\s,]+/).filter(Boolean);
313
+ if (parts.length === 0) return false;
314
+ for (const part of parts) {
315
+ const m = part.match(/^(>=|<=|==|=|>|<)?\s*(.+)$/);
316
+ if (!m) return false;
317
+ const op = m[1] ?? '=';
318
+ const target = parseVersion(m[2]);
319
+ if (target === null) return false;
320
+ const c = cmpVersion(v, target);
321
+ if (op === '>=' && c < 0) return false;
322
+ if (op === '<=' && c > 0) return false;
323
+ if (op === '>' && c <= 0) return false;
324
+ if (op === '<' && c >= 0) return false;
325
+ if ((op === '=' || op === '==') && c !== 0) return false;
326
+ }
327
+ return true;
328
+ }
329
+
330
+ function machineWritable(caps) {
331
+ if (!caps || typeof caps !== 'object') return false;
332
+ if (caps.write === false || caps.writable === false || caps.write_allowed === false) return false;
333
+ if (caps.read_only === true || caps.readonly === true || caps.ro_mount === true) return false;
334
+ return true;
335
+ }
336
+
337
+ /**
338
+ * §6.1 capability gate, evaluated per machine BEFORE dispatch.
339
+ * @returns {null | {status:'refused', refusal_reason:string, detail:object}}
340
+ */
341
+ export function gateMachine(task, machine) {
342
+ const caps = machine?.caps ?? {};
343
+ const req = task.requirements ?? {};
344
+
345
+ if (req.platform && Array.isArray(req.platform) && req.platform.length > 0) {
346
+ const os = String(machine?.platform?.os ?? '').toLowerCase();
347
+ if (!req.platform.map((p) => String(p).toLowerCase()).includes(os)) {
348
+ return { status: 'refused', refusal_reason: 'PLATFORM_MISMATCH', detail: { os, want: req.platform } };
349
+ }
350
+ }
351
+
352
+ const toolchain = req.toolchain ?? {};
353
+ for (const [tool, range] of Object.entries(toolchain)) {
354
+ const declared = caps[tool];
355
+ if (declared === null || declared === undefined || declared === '') {
356
+ return {
357
+ status: 'refused',
358
+ refusal_reason: `MISSING_${String(tool).toUpperCase()}`,
359
+ detail: { tool, want: range, have: declared ?? null },
360
+ };
361
+ }
362
+ if (typeof range === 'string' && !satisfiesRange(declared, range)) {
363
+ return {
364
+ status: 'refused',
365
+ refusal_reason: `MISSING_${String(tool).toUpperCase()}`,
366
+ detail: { tool, want: range, have: declared },
367
+ };
368
+ }
369
+ }
370
+
371
+ if (task.write === true && !machineWritable(caps)) {
372
+ return { status: 'refused', refusal_reason: 'READ_ONLY_MACHINE', detail: { caps } };
373
+ }
374
+
375
+ return null;
376
+ }
377
+
378
+ /* ------------------------------------------------------------------ */
379
+ /* RabbitState */
380
+ /* ------------------------------------------------------------------ */
381
+
382
+ const INSTANCE_SEP = '\u0000';
383
+
384
+ export class RabbitState {
385
+ constructor(options = {}) {
386
+ this._now = normalizeNow(options.now);
387
+ this._idFactory = options.idFactory ?? createIdFactory();
388
+ this._randomToken = options.tokenFactory ?? (() => randomBytes(24).toString('hex'));
389
+ this._randomChars = options.randomChars
390
+ ?? ((n) => { const a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const b = randomBytes(n); let s = ''; for (let i = 0; i < n; i += 1) s += a[b[i] % a.length]; return s; });
391
+
392
+ this.protocolVersion = PROTOCOL_VERSION;
393
+ this.eventBufferSize = options.eventBufferSize ?? DEFAULT_EVENT_BUFFER_SIZE;
394
+ this.pairingTtlMs = options.pairingTtlMs ?? DEFAULT_PAIRING_TTL_MS;
395
+ /** When true one code stays valid for its whole TTL instead of rotating. */
396
+ this.pairingCodeReusable = options.pairingCodeReusable === true;
397
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
398
+ this.missedHeartbeats = options.missedHeartbeats ?? DEFAULT_MISSED_HEARTBEATS;
399
+ this.leaseGraceMs = options.leaseGraceMs ?? DEFAULT_LEASE_GRACE_MS;
400
+ /**
401
+ * v0.4.0: how long the relay holds its own offer back when the dispatcher says it will push the
402
+ * offer over the direct path (`origin_machine_id` + `p2p.mode`).
403
+ *
404
+ * Without this, the direct path could never win: the relay's offer goes out over an SSE stream
405
+ * that is already open, while a punch costs at least one round trip, so the relay copy would
406
+ * arrive first essentially always and `transport` would read `relay` even on a network where
407
+ * the punch succeeded. Measured on loopback, and it is exactly what the first version of this
408
+ * release did.
409
+ *
410
+ * The hold is bounded and self-healing: the executor renews its lease (a heartbeat) the moment
411
+ * it starts work on the direct copy, which moves the lease out of `queued`, and the deferred
412
+ * offer is then dropped. If the punch fails, nothing renews the lease and the relay offers
413
+ * normally once the grace expires -- so a failed punch costs this much latency and nothing else.
414
+ */
415
+ this.p2pOfferGraceMs = Number.isFinite(options.p2pOfferGraceMs) && options.p2pOfferGraceMs >= 0
416
+ ? options.p2pOfferGraceMs
417
+ : DEFAULT_P2P_OFFER_GRACE_MS;
418
+ /** Deferred offers, keyed `task_id:machine_id`, so they can be cleared on close(). */
419
+ this._pendingOffers = new Map();
420
+ /** v0.3.0: age beyond which a reported RTT is flagged stale. */
421
+ this.rttStaleMs = Number.isFinite(options.rttStaleMs) ? options.rttStaleMs : DEFAULT_RTT_STALE_MS;
422
+
423
+ this.startedAtMs = this._now();
424
+ /** v0.1.2 §8.3: random per-process id; a change tells clients the relay restarted. */
425
+ this.relayId = options.relayId ?? randomBytes(8).toString('hex');
426
+ /** v0.1.2 §7: optional injected Persistence instance (state.mjs never touches fs itself). */
427
+ this._persist = options.persistence ?? null;
428
+ /** True while replaying a ledger — suppresses event emission and re-persistence. */
429
+ this._restoring = false;
430
+ /** Ledger entries that could not be replayed. */
431
+ this.replayWarnings = [];
432
+ this.seq = 0;
433
+ /** @type {{seq:number,event:object,target:string|null}[]} ring buffer, last N events */
434
+ this.buffer = [];
435
+ this.devices = new Map(); // machine_id -> device
436
+ this.tokens = new Map(); // device_token -> machine_id
437
+ this.tasks = new Map(); // task_id -> task
438
+ this.results = new Map(); // instanceKey -> {envelope, machine_id, received_at_ms, ...}
439
+ this.pairingCodes = new Map(); // code -> {code, created_at_ms, expires_at_ms, used}
440
+ this._listeners = new Set();
441
+ }
442
+
443
+ /* ---------------- clock / ids ---------------- */
444
+
445
+ nowMs() { return this._now(); }
446
+ nowIso() { return rfc3339(this.nowMs()); }
447
+ nextId() { return this._idFactory(this.nowMs()); }
448
+ newToken() { return this._randomToken(); }
449
+
450
+ /** §4.3 lease window = 2 missed heartbeats (20s) + grace (30s) = 50s. */
451
+ leaseWindowMs() { return this.heartbeatIntervalMs * this.missedHeartbeats + this.leaseGraceMs; }
452
+
453
+ /* ---------------- persistence hooks (v0.1.2 §7) ---------------- */
454
+
455
+ /** Inject/replace the persistence backend (also used by tests). */
456
+ setPersistence(persistence) { this._persist = persistence ?? null; return this._persist; }
457
+
458
+ /** Snapshot the device table; failures must never break the request path. */
459
+ _saveDevices() {
460
+ if (this._restoring || !this._persist) return false;
461
+ try {
462
+ return this._persist.saveDevices(this.devices);
463
+ } catch (err) {
464
+ this._persist.warn?.('devices.json', `snapshot failed: ${err.message}`);
465
+ return false;
466
+ }
467
+ }
468
+
469
+ /** Append one ledger entry; failures must never break the request path. */
470
+ _ledger(entry) {
471
+ if (this._restoring || !this._persist) return false;
472
+ try {
473
+ return this._persist.append(entry);
474
+ } catch (err) {
475
+ this._persist.warn?.('ledger.jsonl', `append failed: ${err.message}`);
476
+ return false;
477
+ }
478
+ }
479
+
480
+ _ledgerLease(task, lease) {
481
+ this._ledger({ type: 'lease.changed', task_id: task.task_id, machine_id: lease.machine_id, lease: { ...lease } });
482
+ }
483
+
484
+ /**
485
+ * v0.1.2 §7 startup recovery: rebuild the device table, token index, tasks,
486
+ * leases and the result/dedupe index from a devices snapshot + ledger replay.
487
+ * Never emits events and never writes back what it just read.
488
+ */
489
+ restore({ devices = {}, ledger = [] } = {}) {
490
+ this._restoring = true;
491
+ try {
492
+ for (const [machineId, d] of Object.entries(devices)) {
493
+ if (!d || typeof d !== 'object') continue;
494
+ const pairedAt = Date.parse(d.paired_at ?? '');
495
+ const device = {
496
+ machine_id: machineId,
497
+ machine_name: typeof d.machine_name === 'string' && d.machine_name ? d.machine_name : machineId,
498
+ platform: d.platform ?? {},
499
+ caps: d.caps ?? {},
500
+ user_id: d.user_id ?? null,
501
+ device_token: typeof d.device_token === 'string' ? d.device_token : null,
502
+ paired_at_ms: Number.isFinite(pairedAt) ? pairedAt : this.nowMs(),
503
+ re_paired: d.re_paired === true,
504
+ last_seen_at_ms: this.nowMs(),
505
+ // a snapshot proves the device exists, not that it is connected
506
+ online: false,
507
+ streams: 0,
508
+ // v0.3.0: RTT is volatile and is NEVER restored. Even if a snapshot on
509
+ // disk happens to carry `rtt_ms`, it is dropped here: after a restart the
510
+ // relay genuinely does not know the latency any more, and reporting a
511
+ // pre-restart number as current would be a lie that no reader can detect.
512
+ rtt_ms: null,
513
+ rtt_at_ms: null,
514
+ last_heartbeat_at_ms: null,
515
+ stream_connects: 0,
516
+ };
517
+ this.devices.set(machineId, device);
518
+ if (device.device_token) this.tokens.set(device.device_token, machineId);
519
+ }
520
+
521
+ let revivedTasks = 0;
522
+ for (const entry of ledger) {
523
+ try {
524
+ if (this._applyLedgerEntry(entry) === true) revivedTasks += 1;
525
+ } catch (err) {
526
+ this.replayWarnings.push({ type: entry?.type ?? '<unknown>', message: err.message });
527
+ }
528
+ }
529
+ return { revived_devices: this.devices.size, revived_tasks: revivedTasks };
530
+ } finally {
531
+ this._restoring = false;
532
+ }
533
+ }
534
+
535
+ /** @returns {boolean} true when a new task was created. */
536
+ _applyLedgerEntry(entry) {
537
+ if (!entry || typeof entry.type !== 'string') return false;
538
+ switch (entry.type) {
539
+ case 'task.created': {
540
+ const t = entry.task;
541
+ if (!t || typeof t.task_id !== 'string') return false;
542
+ const createdAt = Number.isFinite(t.created_at_ms) ? t.created_at_ms : Date.parse(entry.ts ?? '');
543
+ const task = {
544
+ task_id: t.task_id,
545
+ mode: t.mode ?? 'replicate',
546
+ command_argv: t.command_argv ?? [],
547
+ cwd_rel: t.cwd_rel ?? '.',
548
+ index_total: t.index_total ?? 1,
549
+ timeout_ms: t.timeout_ms ?? 300_000,
550
+ write: t.write === true,
551
+ write_scope: t.write_scope ?? [],
552
+ require_exclusive_write: t.require_exclusive_write === true,
553
+ base_commit: t.base_commit ?? null,
554
+ base_tree: t.base_tree ?? null,
555
+ requirements: t.requirements ?? {},
556
+ compare_policy: t.compare_policy ?? {},
557
+ halt: t.halt ?? 'never',
558
+ created_by: t.created_by ?? null,
559
+ created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
560
+ command_hash: t.command_hash ?? null,
561
+ attempt: t.attempt ?? 1,
562
+ leases: new Map(),
563
+ cancelled: false,
564
+ cancel_reason: null,
565
+ degraded: t.degraded ?? null,
566
+ deadline_ms: t.deadline_ms ?? 0,
567
+ // v0.4.0 routing metadata. Read defensively: a ledger written by v0.3.9 has neither key,
568
+ // and a replayed task with `null` for both is exactly a v0.3.9 task.
569
+ origin_machine_id: t.origin_machine_id ?? null,
570
+ p2p: t.p2p ?? null,
571
+ result_seqs: [],
572
+ };
573
+ for (const lease of entry.leases ?? []) {
574
+ if (lease && typeof lease.machine_id === 'string') task.leases.set(lease.machine_id, { ...lease });
575
+ }
576
+ this.tasks.set(task.task_id, task);
577
+ return true;
578
+ }
579
+ case 'lease.changed': {
580
+ const task = this.tasks.get(entry.task_id);
581
+ const lease = task?.leases.get(entry.machine_id);
582
+ if (!lease) return false;
583
+ Object.assign(lease, entry.lease ?? {});
584
+ return false;
585
+ }
586
+ case 'result.stored': {
587
+ const record = entry.result;
588
+ if (!record || typeof record.instance_key !== 'string') return false;
589
+ this.results.set(record.instance_key, { ...record });
590
+ const task = this.tasks.get(record.envelope?.task_id);
591
+ if (task && Number.isInteger(record.seq)) task.result_seqs.push(record.seq);
592
+ return false;
593
+ }
594
+ case 'task.cancelled': {
595
+ const task = this.tasks.get(entry.task_id);
596
+ if (task) {
597
+ task.cancelled = true;
598
+ task.cancel_reason = entry.reason ?? null;
599
+ }
600
+ return false;
601
+ }
602
+ default:
603
+ return false; // unknown entry type: forward compatible, ignored
604
+ }
605
+ }
606
+
607
+ /* ---------------- events (§3) ---------------- */
608
+
609
+ nextSeq() { this.seq += 1; return this.seq; }
610
+
611
+ /** Reserve a seq WITHOUT buffering (used for the per-connection `ready` frame). */
612
+ reserveSeq() { return this.nextSeq(); }
613
+
614
+ onEvent(listener) {
615
+ this._listeners.add(listener);
616
+ return () => this._listeners.delete(listener);
617
+ }
618
+
619
+ /**
620
+ * Emit an event: allocates the next global seq, buffers it (ring of N) and
621
+ * notifies subscribers. `target` is the machine_id the event is addressed to
622
+ * (null = broadcast). The target is NOT serialized except as `machine_id`.
623
+ */
624
+ emit(type, fields = {}, opts = {}) {
625
+ const target = opts.target ?? null;
626
+ const seq = this.nextSeq();
627
+ const event = {
628
+ type,
629
+ seq,
630
+ rabbit_time: this.nowIso(),
631
+ ...(target ? { machine_id: target } : {}),
632
+ ...fields,
633
+ };
634
+ const entry = { seq, event, target };
635
+ this.buffer.push(entry);
636
+ if (this.buffer.length > this.eventBufferSize) {
637
+ this.buffer.splice(0, this.buffer.length - this.eventBufferSize);
638
+ }
639
+ for (const listener of this._listeners) {
640
+ try { listener(entry); } catch { /* subscriber errors must never break state */ }
641
+ }
642
+ return event;
643
+ }
644
+
645
+ oldestBufferedSeq() { return this.buffer.length ? this.buffer[0].seq : this.seq + 1; }
646
+ /**
647
+ * Highest seq present in the replay ring (0 when empty).
648
+ *
649
+ * Deliberately different from `lastSeq()`: `ready` frames allocate seq numbers
650
+ * without being buffered, so `lastSeq()` can run ahead of anything a client can
651
+ * actually replay. Diagnostics that describe the REPLAY WINDOW must use this one.
652
+ */
653
+ lastBufferedSeq() { return this.buffer.length ? this.buffer[this.buffer.length - 1].seq : 0; }
654
+ lastSeq() { return this.seq; }
655
+
656
+ /** Replay entries with seq >= from (used by GET /v1/stream?seq=|Last-Event-ID). */
657
+ replayEntries(from) {
658
+ return this.buffer.filter((e) => e.seq >= from);
659
+ }
660
+
661
+ eventsSince(from) { return this.replayEntries(from).map((e) => e.event); }
662
+
663
+ /* ---------------- pairing (§2) ---------------- */
664
+
665
+ createPairingCode({ ttlMs = this.pairingTtlMs } = {}) {
666
+ let code;
667
+ do { code = `PAIR-${this._randomChars(8)}`; } while (this.pairingCodes.has(code));
668
+ const now = this.nowMs();
669
+ this.pairingCodes.set(code, { code, created_at_ms: now, expires_at_ms: now + ttlMs, used: false });
670
+ return code;
671
+ }
672
+
673
+ /** Revoke every outstanding code and mint a new one. */
674
+ rotatePairingCode(opts) {
675
+ for (const rec of this.pairingCodes.values()) rec.used = true;
676
+ return this.createPairingCode(opts);
677
+ }
678
+
679
+ /**
680
+ * Register an externally supplied code (`--pairing-code <c>`).
681
+ *
682
+ * v0.1.2 BUG-1: assigning the code to `relay.pairingCode` without registering it
683
+ * advertised a code that `/v1/pair` then rejected with PAIRING_INVALID, so every
684
+ * documented scripted-onboarding flow failed. "Which code do we use" and "is the
685
+ * code registered" are two different questions; this answers the second.
686
+ */
687
+ registerPairingCode(code, { ttlMs = this.pairingTtlMs } = {}) {
688
+ if (typeof code !== 'string' || code.trim() === '') {
689
+ throw new ProtocolError('BAD_REQUEST', 'pairing code must be a non-empty string', { pairing_code: code });
690
+ }
691
+ const value = code.trim();
692
+ const now = this.nowMs();
693
+ this.pairingCodes.set(value, { code: value, created_at_ms: now, expires_at_ms: now + ttlMs, used: false });
694
+ return value;
695
+ }
696
+
697
+ /** §2.2 step 3: validate a one-time code. */
698
+ validatePairingCode(code) {
699
+ if (typeof code !== 'string' || code.length === 0) {
700
+ throw new ProtocolError('PAIRING_INVALID', 'pairing_code is required');
701
+ }
702
+ const rec = this.pairingCodes.get(code);
703
+ if (!rec) throw new ProtocolError('PAIRING_INVALID', 'unknown pairing code', { pairing_code: code });
704
+ if (rec.used) throw new ProtocolError('PAIRING_INVALID', 'pairing code already used', { pairing_code: code });
705
+ if (this.nowMs() >= rec.expires_at_ms) {
706
+ throw new ProtocolError('PAIRING_EXPIRED', 'pairing code expired', { pairing_code: code, expired_at: rfc3339(rec.expires_at_ms) });
707
+ }
708
+ return rec;
709
+ }
710
+
711
+ /** §2.2: exchange a one-time code for a device_token. §1: idempotent. */
712
+ pair(request = {}) {
713
+ const rec = this.validatePairingCode(request.pairing_code);
714
+ const machineId = request.machine_id;
715
+ if (typeof machineId !== 'string' || machineId.length === 0) {
716
+ throw new ProtocolError('BAD_REQUEST', 'machine_id is required');
717
+ }
718
+ const now = this.nowMs();
719
+ const token = this.newToken();
720
+ const existing = this.devices.get(machineId);
721
+ if (existing?.device_token) this.tokens.delete(existing.device_token);
722
+
723
+ const device = {
724
+ machine_id: machineId,
725
+ machine_name: typeof request.machine_name === 'string' && request.machine_name
726
+ ? request.machine_name : machineId.slice(0, 12),
727
+ platform: request.platform ?? {},
728
+ caps: request.caps ?? {},
729
+ user_id: request.user_id ?? null,
730
+ device_token: token,
731
+ paired_at_ms: now,
732
+ re_paired: Boolean(existing),
733
+ last_seen_at_ms: now,
734
+ online: true,
735
+ streams: existing?.streams ?? 0,
736
+ // v0.3.0 RTT: starts unknown. `null` is "never measured"; 0 would mean "instant".
737
+ rtt_ms: null,
738
+ rtt_at_ms: null,
739
+ last_heartbeat_at_ms: null,
740
+ stream_connects: existing?.stream_connects ?? 0,
741
+ };
742
+ this.devices.set(machineId, device);
743
+ this.tokens.set(token, machineId);
744
+ rec.used = true;
745
+
746
+ // A group has more than one machine, and a single-use code would leave the
747
+ // operator unable to pair the second one without restarting the relay. So
748
+ // consuming a code immediately mints the next one and announces it: the
749
+ // operator pairs machine A, sees a fresh code, and pairs machine B with it.
750
+ // `pairingCodeReusable` keeps one code alive for the whole window instead,
751
+ // for scripted onboarding where nobody is watching stdout.
752
+ let nextCode = null;
753
+ const reusable = this.pairingCodeReusable === true;
754
+ if (reusable) {
755
+ rec.used = false;
756
+ } else {
757
+ nextCode = this.rotatePairingCode();
758
+ }
759
+
760
+ this.emit('peer.hello', {
761
+ machine_id: machineId,
762
+ machine_name: device.machine_name,
763
+ platform: device.platform,
764
+ });
765
+
766
+ if (nextCode) {
767
+ this.emit('notice', {
768
+ level: 'info',
769
+ code: 'PAIRING_CODE_ROTATED',
770
+ message: 'pairing code consumed; a new one is available',
771
+ pairing_code: nextCode,
772
+ });
773
+ }
774
+
775
+ // §7: a device change is snapshotted immediately.
776
+ this._saveDevices();
777
+ this._ledger({
778
+ type: 'device.paired',
779
+ machine_id: machineId,
780
+ machine_name: device.machine_name,
781
+ re_paired: device.re_paired,
782
+ });
783
+
784
+ return {
785
+ device_token: token,
786
+ protocol_version: PROTOCOL_VERSION,
787
+ rabbit_time: rfc3339(now),
788
+ ...(nextCode ? { next_pairing_code: nextCode } : {}),
789
+ };
790
+ }
791
+
792
+ /** @returns {object|null} device for a bearer token. */
793
+ authenticate(token) {
794
+ if (typeof token !== 'string' || token.length === 0) return null;
795
+ const machineId = this.tokens.get(token);
796
+ if (!machineId) return null;
797
+ return this.devices.get(machineId) ?? null;
798
+ }
799
+
800
+ touchDevice(machineId, { online = true } = {}) {
801
+ const device = this.devices.get(machineId);
802
+ if (!device) return null;
803
+ device.last_seen_at_ms = this.nowMs();
804
+ if (online) device.online = true;
805
+ return device;
806
+ }
807
+
808
+ setOnline(machineId, online, reason = null) {
809
+ const device = this.devices.get(machineId);
810
+ if (!device) return null;
811
+ if (device.online === online) return device;
812
+ device.online = online;
813
+ if (online) {
814
+ this.touchDevice(machineId);
815
+ this.emit('peer.hello', {
816
+ machine_id: machineId,
817
+ machine_name: device.machine_name,
818
+ platform: device.platform,
819
+ });
820
+ } else {
821
+ this.emit('peer.bye', { machine_id: machineId, reason: reason ?? 'stream_closed' });
822
+ }
823
+ return device;
824
+ }
825
+
826
+ /* ---------------- RTT (v0.3.0 §R) ---------------- */
827
+
828
+ /**
829
+ * Validate an inbound `rtt_ms`.
830
+ *
831
+ * The contract is deliberately liberal: a MISSING or INVALID value must never
832
+ * reject the heartbeat and must never clobber a previously known good value.
833
+ * RTT is a diagnostic; it must not be able to break lease renewal.
834
+ *
835
+ * Sanity ceiling: anything above `MAX_RTT_MS` (24h) is treated as invalid. A
836
+ * single garbage value would otherwise poison the fleet aggregate.
837
+ */
838
+ static normalizeRtt(value) {
839
+ if (value === undefined) return { provided: false, valid: false };
840
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
841
+ return { provided: true, valid: false };
842
+ }
843
+ if (value > MAX_RTT_MS) return { provided: true, valid: false };
844
+ return { provided: true, valid: true, value };
845
+ }
846
+
847
+ /**
848
+ * RTT is a VOLATILE diagnostic: it is intentionally NOT written to
849
+ * `devices.json` and NOT replayed from the ledger, so a restarted relay reports
850
+ * `rtt_ms: null` until a machine heartbeats again. A persisted value would be
851
+ * indistinguishable from a fresh one and would let the relay claim knowledge it
852
+ * lost when the process died.
853
+ */
854
+ rttStaleAfterMs() { return this.rttStaleMs; }
855
+
856
+ /**
857
+ * v0.3.0: absorb the optional `rtt_ms` carried by a heartbeat.
858
+ *
859
+ * Deliberately liberal, because RTT is a diagnostic and must never be able to
860
+ * break lease renewal:
861
+ * - the field is optional; a v0.2.3 agent never sends it
862
+ * - a MISSING or INVALID value is IGNORED, keeping the previous value, and the
863
+ * heartbeat still succeeds
864
+ * - unknown extra fields are ignored entirely (forward compatibility)
865
+ *
866
+ * `last_heartbeat_at` advances on EVERY accepted heartbeat, with or without a
867
+ * usable RTT: it answers "is the machine alive", while `rtt_at` answers "when
868
+ * was this number measured". Keeping them separate is what lets a reader tell a
869
+ * genuinely slow machine from one that is simply gone.
870
+ */
871
+ recordHeartbeatMetrics(machineId, input = {}, nowMs = this.nowMs()) {
872
+ const device = this.devices.get(machineId);
873
+ if (!device) return null;
874
+ device.last_heartbeat_at_ms = nowMs;
875
+ const rtt = RabbitState.normalizeRtt(input.rtt_ms);
876
+ if (rtt.valid) {
877
+ device.rtt_ms = rtt.value;
878
+ device.rtt_at_ms = nowMs;
879
+ }
880
+ return device;
881
+ }
882
+
883
+ /** True when there is no value, or the value is older than the staleness window. */
884
+ isRttStale(device, nowMs = this.nowMs()) {
885
+ if (!device || device.rtt_ms === null || device.rtt_ms === undefined) return true;
886
+ if (device.rtt_at_ms === null || device.rtt_at_ms === undefined) return true;
887
+ return nowMs - device.rtt_at_ms > this.rttStaleMs;
888
+ }
889
+
890
+ /** How old the RTT measurement is, in ms; null when there is none. */
891
+ rttAgeMs(device, nowMs = this.nowMs()) {
892
+ if (!device || device.rtt_at_ms === null || device.rtt_at_ms === undefined) return null;
893
+ return Math.max(0, nowMs - device.rtt_at_ms);
894
+ }
895
+
896
+ /** Record that a machine attached a stream (a reconnect is any attach after the first). */
897
+ noteStreamConnect(machineId) {
898
+ const device = this.devices.get(machineId);
899
+ if (!device) return null;
900
+ device.stream_connects = (device.stream_connects ?? 0) + 1;
901
+ return device.stream_connects;
902
+ }
903
+
904
+ /**
905
+ * v0.3.0 aggregate for `/healthz`.
906
+ *
907
+ * Only FRESH measurements count. A machine that has been gone for a day must not
908
+ * drag min/max/avg around, and with no fresh data every numeric field is null --
909
+ * never 0, which would read as "instantaneous".
910
+ */
911
+ rttSummary(nowMs = this.nowMs()) {
912
+ const fresh = [];
913
+ let stale = 0;
914
+ let unknown = 0;
915
+ for (const device of this.devices.values()) {
916
+ if (device.rtt_ms === null || device.rtt_ms === undefined) { unknown += 1; continue; }
917
+ if (this.isRttStale(device, nowMs)) { stale += 1; continue; }
918
+ fresh.push(device.rtt_ms);
919
+ }
920
+ if (fresh.length === 0) {
921
+ return {
922
+ machines_reporting: 0,
923
+ min_ms: null,
924
+ max_ms: null,
925
+ avg_ms: null,
926
+ machines_stale: stale,
927
+ machines_unknown: unknown,
928
+ };
929
+ }
930
+ const sum = fresh.reduce((a, b) => a + b, 0);
931
+ return {
932
+ machines_reporting: fresh.length,
933
+ min_ms: Math.min(...fresh),
934
+ max_ms: Math.max(...fresh),
935
+ avg_ms: Math.round((sum / fresh.length) * 10) / 10,
936
+ machines_stale: stale,
937
+ machines_unknown: unknown,
938
+ };
939
+ }
940
+
941
+ /**
942
+ * v0.3.0 single-machine status view.
943
+ *
944
+ * `last_heartbeat_at` and `rtt_at` answer different questions, and the gap
945
+ * between them is the whole point: a machine can be heartbeating (alive) while
946
+ * its RTT number is old (a v0.2.3 agent that never sends `rtt_ms`), and a
947
+ * machine can be gone while a plausible-looking RTT is still on file.
948
+ */
949
+ deviceStatus(machineId, nowMs = this.nowMs()) {
950
+ const device = this.devices.get(machineId);
951
+ if (!device) return null;
952
+ const rttAt = device.rtt_at_ms ?? null;
953
+ const lastHeartbeat = device.last_heartbeat_at_ms ?? null;
954
+ return {
955
+ machine_id: device.machine_id,
956
+ machine_name: device.machine_name,
957
+ relay_id: this.relayId,
958
+ connected: (device.streams ?? 0) > 0,
959
+ streams: device.streams ?? 0,
960
+ online: device.online === true,
961
+ rtt_ms: device.rtt_ms ?? null,
962
+ rtt_at: rttAt === null ? null : rfc3339(rttAt),
963
+ rtt_age_ms: this.rttAgeMs(device, nowMs),
964
+ rtt_stale: this.isRttStale(device, nowMs),
965
+ last_heartbeat_at: lastHeartbeat === null ? null : rfc3339(lastHeartbeat),
966
+ last_seen_at: device.last_seen_at_ms === null || device.last_seen_at_ms === undefined
967
+ ? null : rfc3339(device.last_seen_at_ms),
968
+ reconnect_attempts: Math.max(0, (device.stream_connects ?? 0) - 1),
969
+ stream_connects: device.stream_connects ?? 0,
970
+ paired_at: device.paired_at_ms === null || device.paired_at_ms === undefined
971
+ ? null : rfc3339(device.paired_at_ms),
972
+ platform: device.platform,
973
+ caps: device.caps,
974
+ user_id: device.user_id ?? null,
975
+ now: rfc3339(nowMs),
976
+ };
977
+ }
978
+
979
+ /** Public device view — never leaks device_token. */
980
+ publicDevice(device) {
981
+ return {
982
+ machine_id: device.machine_id,
983
+ machine_name: device.machine_name,
984
+ platform: device.platform,
985
+ caps: device.caps,
986
+ user_id: device.user_id,
987
+ online: device.online,
988
+ streams: device.streams,
989
+ paired_at: rfc3339(device.paired_at_ms),
990
+ last_seen_at: rfc3339(device.last_seen_at_ms),
991
+ // v0.3.0: `null` means "never measured", which is NOT the same as 0ms.
992
+ rtt_ms: device.rtt_ms ?? null,
993
+ rtt_at: device.rtt_at_ms === null || device.rtt_at_ms === undefined ? null : rfc3339(device.rtt_at_ms),
994
+ rtt_age_ms: this.rttAgeMs(device),
995
+ rtt_stale: this.isRttStale(device),
996
+ last_heartbeat_at: device.last_heartbeat_at_ms === null || device.last_heartbeat_at_ms === undefined
997
+ ? null : rfc3339(device.last_heartbeat_at_ms),
998
+ reconnect_attempts: Math.max(0, (device.stream_connects ?? 0) - 1),
999
+ };
1000
+ }
1001
+
1002
+ listDevices() { return [...this.devices.values()].map((d) => this.publicDevice(d)); }
1003
+
1004
+ /* ---------------- tasks (§4) ---------------- */
1005
+
1006
+ /**
1007
+ * v0.4.0: validate the routing metadata a `POST /v1/task` body may carry.
1008
+ *
1009
+ * A machine, not a mode, is `origin_machine_id`: where the task was typed (`device.json`), so an
1010
+ * offer can carry that fact to the machine that has to run it. `p2p.mode` is the dispatcher's
1011
+ * resolved mode for this particular task — the plugin may be configured `direct` while a
1012
+ * scheduled run is `relay`.
1013
+ *
1014
+ * Validation is deliberately shallow and strictly typed, in the house style of this file: a
1015
+ * missing value is the v0.3.9 shape and stays valid, while a present-but-wrong value is refused
1016
+ * by name. Accepting a non-string here would put a number into `origin_machine_id` and only fail
1017
+ * later, on a machine that cannot tell a bad field from a bad relay.
1018
+ *
1019
+ * @returns {{origin_machine_id:string|null, p2p:{mode:string}|null}}
1020
+ */
1021
+ static validateTaskRouting(input = {}) {
1022
+ let originMachineId = null;
1023
+ if (input.origin_machine_id !== undefined && input.origin_machine_id !== null) {
1024
+ if (typeof input.origin_machine_id !== 'string'
1025
+ || input.origin_machine_id.length === 0
1026
+ || input.origin_machine_id.length > MAX_ORIGIN_MACHINE_ID_LENGTH) {
1027
+ throw new ProtocolError(
1028
+ 'BAD_REQUEST',
1029
+ `origin_machine_id must be a non-empty string of at most ${MAX_ORIGIN_MACHINE_ID_LENGTH} characters`,
1030
+ { origin_machine_id: input.origin_machine_id },
1031
+ );
1032
+ }
1033
+ originMachineId = input.origin_machine_id;
1034
+ }
1035
+
1036
+ let p2p = null;
1037
+ if (input.p2p !== undefined && input.p2p !== null) {
1038
+ if (typeof input.p2p !== 'object' || Array.isArray(input.p2p)) {
1039
+ throw new ProtocolError('BAD_REQUEST', 'p2p must be an object with a `mode` field', { p2p: input.p2p });
1040
+ }
1041
+ if (!P2P_MODES.includes(input.p2p.mode)) {
1042
+ throw new ProtocolError(
1043
+ 'BAD_REQUEST',
1044
+ `p2p.mode must be one of ${P2P_MODES.join('|')}`,
1045
+ { 'p2p.mode': input.p2p.mode ?? null },
1046
+ );
1047
+ }
1048
+ // Copied rather than referenced: the recorded task must not be mutable through the request
1049
+ // body the caller still holds.
1050
+ p2p = { ...input.p2p };
1051
+ }
1052
+
1053
+ return { origin_machine_id: originMachineId, p2p };
1054
+ }
1055
+
1056
+ /**
1057
+ * v0.4.0 §5.2: validate the optional transport fields of a result envelope.
1058
+ *
1059
+ * Same reasoning as `validateTaskRouting`: `transport` is an enum and `p2p` is an opaque object,
1060
+ * so anything else is refused by name at the door. Both fields stay OUT of
1061
+ * `REQUIRED_ENVELOPE_FIELDS` (a missing one is a v0.3.9 envelope, not an incomplete one) and out
1062
+ * of `COMPARABLE_FIELDS` (a path is not a result).
1063
+ */
1064
+ static validateTransportFields(envelope = {}) {
1065
+ if (envelope.transport !== undefined && envelope.transport !== null) {
1066
+ if (!TRANSPORT_VALUES.includes(envelope.transport)) {
1067
+ throw new ProtocolError(
1068
+ 'BAD_REQUEST',
1069
+ `transport must be one of ${TRANSPORT_VALUES.join('|')}`,
1070
+ { transport: envelope.transport },
1071
+ );
1072
+ }
1073
+ }
1074
+ if (envelope.p2p !== undefined && envelope.p2p !== null
1075
+ && (typeof envelope.p2p !== 'object' || Array.isArray(envelope.p2p))) {
1076
+ throw new ProtocolError('BAD_REQUEST', 'p2p must be an object', { p2p: envelope.p2p });
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * §4.1 POST /v1/task.
1082
+ * Machine selection is not specified by the PROTOCOL; we implement:
1083
+ * - `target_machines` (optional explicit list), else every paired device
1084
+ * - capability gate §6.1 per machine (failures become `refused` leases)
1085
+ * - index assignment: replicate -> 0, split -> round-robin modulo index_total
1086
+ */
1087
+ createTask(input = {}) {
1088
+ // v0.4.0: refused BEFORE the task literal is built and before any offer is emitted, so a bad
1089
+ // value cannot leave a half-created task behind. `POST /v1/task` is all-or-nothing.
1090
+ const routing = RabbitState.validateTaskRouting(input);
1091
+
1092
+ const mode = input.mode ?? 'replicate';
1093
+ if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline' && mode !== 'compose') {
1094
+ throw new ProtocolError(
1095
+ 'BAD_REQUEST',
1096
+ 'mode must be replicate|split|broadcast|pipeline|compose',
1097
+ { mode },
1098
+ );
1099
+ }
1100
+ /**
1101
+ * v0.3.3 `pipeline`: the whole chain runs on each machine, stage after stage.
1102
+ *
1103
+ * The semantics were chosen against the alternative (fan out stage 1, converge its outputs into
1104
+ * stage 2) because that one has no well-defined input: on a fleet, stage 1 produces n different
1105
+ * outputs, and "feed them all to stage 2" does not say in what order, or whether stage 2 then
1106
+ * runs once or n times. Worse, two machines finishing in a different order would produce
1107
+ * different stage-2 inputs, so one task_id would no longer describe one reproducible execution --
1108
+ * which is the property the rest of this system is built on. See docs/PIPELINE-DESIGN.md.
1109
+ *
1110
+ * So: a pipeline is `replicate` applied to a sequence. Every machine runs the whole chain, there
1111
+ * is no cross-machine data flow, and nothing new can be silently dropped in transit.
1112
+ */
1113
+ const rawStages = Array.isArray(input.stages) ? input.stages : null;
1114
+ const isPipeline = mode === 'pipeline';
1115
+ if (isPipeline) {
1116
+ if (rawStages === null || rawStages.length === 0) {
1117
+ throw new ProtocolError('BAD_REQUEST', 'mode=pipeline requires a non-empty `stages` array', {
1118
+ stages: rawStages === null ? null : rawStages.length,
1119
+ });
1120
+ }
1121
+ if (rawStages.length > MAX_PIPELINE_STAGES) {
1122
+ throw new ProtocolError('BAD_REQUEST', `stages must have at most ${MAX_PIPELINE_STAGES} entries`, {
1123
+ stages: rawStages.length,
1124
+ });
1125
+ }
1126
+ rawStages.forEach((stage, i) => {
1127
+ const argv = stage?.command_argv;
1128
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((a) => typeof a !== 'string')) {
1129
+ throw new ProtocolError('BAD_REQUEST', `stages[${i}].command_argv must be a non-empty string[]`, { index: i });
1130
+ }
1131
+ });
1132
+ // A chain is one execution on one machine; sharding it would mean each machine ran a chain of
1133
+ // its own slice, which is a different feature and not what the caller asked for.
1134
+ if (Number.isInteger(input.index_total) && input.index_total !== 1) {
1135
+ throw new ProtocolError('BAD_REQUEST', 'mode=pipeline runs the whole chain per machine, so index_total must be 1', {
1136
+ index_total: input.index_total,
1137
+ });
1138
+ }
1139
+ } else if (rawStages !== null) {
1140
+ // Refused rather than ignored: the caller believes they described a sequence, and running only
1141
+ // `command_argv` would silently drop every stage after the first.
1142
+ throw new ProtocolError('BAD_REQUEST', '`stages` is only valid with mode=pipeline', { mode, stages: rawStages.length });
1143
+ }
1144
+
1145
+ /**
1146
+ * Normalized stage list. For every mode there is at least one stage, so downstream code has a
1147
+ * single shape to reason about and `pipeline` is not a special case at the execution layer.
1148
+ */
1149
+ const stages = isPipeline
1150
+ ? rawStages.map((s, i) => ({
1151
+ index: i,
1152
+ command_argv: [...s.command_argv],
1153
+ cwd_rel: typeof s.cwd_rel === 'string' && s.cwd_rel ? s.cwd_rel : (typeof input.cwd_rel === 'string' && input.cwd_rel ? input.cwd_rel : '.'),
1154
+ timeout_ms: Number.isInteger(s.timeout_ms) ? s.timeout_ms : null,
1155
+ continue_on_failure: s.continue_on_failure === true,
1156
+ }))
1157
+ : null;
1158
+
1159
+ const commandArgv = isPipeline ? [...rawStages[0].command_argv] : input.command_argv;
1160
+ if (!Array.isArray(commandArgv) || commandArgv.length === 0 || commandArgv.some((a) => typeof a !== 'string')) {
1161
+ throw new ProtocolError('BAD_REQUEST', 'command_argv must be a non-empty string[]');
1162
+ }
1163
+ const indexTotal = Number.isInteger(input.index_total) ? input.index_total : 1;
1164
+ if (indexTotal < 1) throw new ProtocolError('BAD_REQUEST', 'index_total must be >= 1');
1165
+
1166
+ const halt = input.halt ?? 'never';
1167
+ if (halt !== 'never' && halt !== 'now') throw new ProtocolError('BAD_REQUEST', 'halt must be never|now', { halt });
1168
+
1169
+ const taskId = input.task_id ?? this.nextId();
1170
+ if (this.tasks.has(taskId)) {
1171
+ throw new ProtocolError('TASK_EXISTS', 'task_id already exists', { task_id: taskId });
1172
+ }
1173
+
1174
+ const now = this.nowMs();
1175
+ const cwdRel = typeof input.cwd_rel === 'string' && input.cwd_rel ? input.cwd_rel : '.';
1176
+ const baseTree = input.base_tree ?? null;
1177
+ const commandHash = input.command_hash ?? (isPipeline
1178
+ ? computePipelineCommandHash(stages, SHELL_ID_DIRECT)
1179
+ : computeCommandHash(commandArgv, SHELL_ID_DIRECT, cwdRel));
1180
+ const timeoutMs = Number.isInteger(input.timeout_ms) ? input.timeout_ms : 300_000;
1181
+
1182
+ // v0.3.3 `broadcast`: one machine runs the command, every other machine is told the outcome.
1183
+ //
1184
+ // The executor choice is deterministic rather than random. A random leader makes a task
1185
+ // impossible to reproduce from its own record -- the same task id would run somewhere else on a
1186
+ // retry -- and this project's whole value rests on being able to say exactly where something ran.
1187
+ // The first target in the relay's own device order is stable for a given device table.
1188
+ //
1189
+ // Observers get a lease so they are tracked and notified, but their lease is `observing`, which
1190
+ // every aggregation step treats as "did not run". That distinction is the point: a machine that
1191
+ // never executed anything agreeing with a result would be a fabrication, and it is exactly the
1192
+ // fabrication this mode could most easily produce.
1193
+ //
1194
+ // Resolved before the task literal because the literal records it. (`targets` is computed below,
1195
+ // so the executor is re-validated against it after that.)
1196
+ const targetIds = Array.isArray(input.target_machines) && input.target_machines.length > 0
1197
+ ? input.target_machines
1198
+ : [...this.devices.keys()];
1199
+ const executorMachineId = mode === 'broadcast'
1200
+ ? (typeof input.executor_machine_id === 'string' && input.executor_machine_id !== ''
1201
+ ? input.executor_machine_id
1202
+ : targetIds[0] ?? null)
1203
+ : null;
1204
+
1205
+ const task = {
1206
+ task_id: taskId,
1207
+ mode,
1208
+ command_argv: commandArgv,
1209
+ cwd_rel: cwdRel,
1210
+ index_total: indexTotal,
1211
+ timeout_ms: timeoutMs,
1212
+ write: input.write === true,
1213
+ write_scope: input.write_scope ?? [],
1214
+ require_exclusive_write: input.require_exclusive_write === true,
1215
+ base_commit: input.base_commit ?? null,
1216
+ base_tree: baseTree,
1217
+ requirements: input.requirements ?? {},
1218
+ compare_policy: input.compare_policy ?? {},
1219
+ halt,
1220
+ created_by: input.created_by ?? null,
1221
+ created_at_ms: now,
1222
+ command_hash: commandHash,
1223
+ attempt: 1,
1224
+ leases: new Map(),
1225
+ cancelled: false,
1226
+ cancel_reason: null,
1227
+ degraded: null,
1228
+ deadline_ms: now + timeoutMs,
1229
+ result_seqs: [],
1230
+ /** v0.3.3: which machine actually executes under `broadcast`; null for the other modes. */
1231
+ executor_machine_id: executorMachineId,
1232
+ /**
1233
+ * v0.3.3: the normalized stage list for `pipeline`, or null.
1234
+ *
1235
+ * Sent to the machine in the offer so it can run the whole chain. Kept on the task as well so
1236
+ * a report can say how many stages were supposed to run even when the machine crashed before
1237
+ * reporting any of them.
1238
+ */
1239
+ stages,
1240
+ /**
1241
+ * v0.4.0: where the task came from and under which P2P mode it was dispatched, or null.
1242
+ *
1243
+ * Kept on the task (not just passed to `emitOffer`) because an offer is emitted more than
1244
+ * once: `redeliverPendingOffers` re-states unclaimed work on every reconnect and
1245
+ * `_takeover` re-offers a freed index. A machine that reconnects must see the same routing
1246
+ * facts as the machine that was connected at dispatch time, or the direct-push half of the
1247
+ * feature would work exactly once and then quietly stop.
1248
+ */
1249
+ origin_machine_id: routing.origin_machine_id,
1250
+ p2p: routing.p2p,
1251
+ };
1252
+
1253
+ const all = [...this.devices.values()];
1254
+ const targets = Array.isArray(input.target_machines) && input.target_machines.length > 0
1255
+ ? all.filter((d) => input.target_machines.includes(d.machine_id))
1256
+ : all;
1257
+
1258
+ // v0.3.3 `broadcast`: validated here, where `targets` is known. The executor id itself was
1259
+ // resolved before the task literal because the literal records it.
1260
+ if (mode === 'broadcast' && executorMachineId !== null && !targets.some((d) => d.machine_id === executorMachineId)) {
1261
+ throw new ProtocolError('BAD_REQUEST', 'executor_machine_id is not among the target machines', {
1262
+ executor_machine_id: executorMachineId,
1263
+ targets: targets.map((d) => d.machine_id),
1264
+ });
1265
+ }
1266
+
1267
+ if (targets.length === 0) {
1268
+ throw new ProtocolError('NO_ONLINE_DEVICE', 'no paired device available for this task', {
1269
+ known_devices: all.length,
1270
+ });
1271
+ }
1272
+
1273
+ const dedupeKeyBase = computeDedupeKey(taskId, 0, commandHash, baseTree);
1274
+ if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
1275
+ throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
1276
+ }
1277
+ let lastSeq = this.seq;
1278
+ targets.forEach((device, i) => {
1279
+ const index = mode === 'replicate' ? 0 : i % indexTotal;
1280
+ const observing = mode === 'broadcast' && device.machine_id !== executorMachineId;
1281
+ const lease = {
1282
+ machine_id: device.machine_id,
1283
+ machine_name: device.machine_name,
1284
+ index,
1285
+ attempt: 1,
1286
+ // `observing` is a lease state, not an outcome: the machine holds a place in this task and
1287
+ // will be told what happened, but it was never handed the command. `gateMachine` below is
1288
+ // skipped for it because refusing to do work you were never asked to do is not a refusal.
1289
+ state: observing ? 'observing' : 'queued',
1290
+ refusal_reason: null,
1291
+ refusal_detail: null,
1292
+ dedupe_key: computeDedupeKey(taskId, index, commandHash, baseTree),
1293
+ last_heartbeat_ms: now,
1294
+ claimed_at_ms: now,
1295
+ lease_until_ms: now + this.leaseWindowMs(),
1296
+ phase: null,
1297
+ progress: null,
1298
+ offered_seq: null,
1299
+ expired_at_ms: null,
1300
+ finished_at_ms: null,
1301
+ heartbeat_count: 0,
1302
+ };
1303
+ if (observing) {
1304
+ // No capability gate and no offer: the machine is not being asked to run anything, so there
1305
+ // is nothing to refuse and nothing to hand out. It is recorded so the task can tell it the
1306
+ // outcome afterwards.
1307
+ task.leases.set(device.machine_id, lease);
1308
+ return;
1309
+ }
1310
+ const gate = gateMachine(task, device);
1311
+ if (gate) {
1312
+ lease.state = 'refused';
1313
+ lease.refusal_reason = gate.refusal_reason;
1314
+ lease.refusal_detail = gate.detail;
1315
+ task.leases.set(device.machine_id, lease);
1316
+ this.emit('notice', {
1317
+ level: 'warn',
1318
+ code: 'CAPABILITY_REFUSED',
1319
+ message: `${device.machine_id} refused: ${gate.refusal_reason}`,
1320
+ task_id: taskId,
1321
+ });
1322
+ return;
1323
+ }
1324
+ task.leases.set(device.machine_id, lease);
1325
+ const grace = this._p2pOfferGraceMs(task);
1326
+ if (grace > 0) {
1327
+ // v0.4.0: the dispatcher is pushing this offer over the direct path right now. Hold the
1328
+ // relay's copy for `grace` ms so the direct one can win; see `p2pOfferGraceMs`.
1329
+ this._scheduleOffer(task, lease, grace);
1330
+ } else {
1331
+ const event = this.emitOffer(task, lease);
1332
+ lastSeq = event.seq;
1333
+ }
1334
+ });
1335
+
1336
+ this.tasks.set(taskId, task);
1337
+
1338
+ this._ledger({
1339
+ type: 'task.created',
1340
+ task: {
1341
+ task_id: task.task_id,
1342
+ mode: task.mode,
1343
+ command_argv: task.command_argv,
1344
+ cwd_rel: task.cwd_rel,
1345
+ index_total: task.index_total,
1346
+ timeout_ms: task.timeout_ms,
1347
+ write: task.write,
1348
+ write_scope: task.write_scope,
1349
+ require_exclusive_write: task.require_exclusive_write,
1350
+ base_commit: task.base_commit,
1351
+ base_tree: task.base_tree,
1352
+ requirements: task.requirements,
1353
+ compare_policy: task.compare_policy,
1354
+ halt: task.halt,
1355
+ created_by: task.created_by,
1356
+ created_at_ms: task.created_at_ms,
1357
+ command_hash: task.command_hash,
1358
+ attempt: task.attempt,
1359
+ deadline_ms: task.deadline_ms,
1360
+ // v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
1361
+ origin_machine_id: task.origin_machine_id ?? null,
1362
+ p2p: task.p2p ?? null,
1363
+ },
1364
+ leases: [...task.leases.values()].map((l) => ({ ...l })),
1365
+ });
1366
+
1367
+ const refusedOnly = [...task.leases.values()].every((l) => l.state === 'refused');
1368
+ if (refusedOnly) {
1369
+ // §6.3 Step 0 territory: a task nobody may execute is still a valid, reportable task.
1370
+ this.emit('notice', {
1371
+ level: 'warn',
1372
+ code: 'NO_ONLINE_DEVICE',
1373
+ message: 'every candidate machine refused the capability gate',
1374
+ task_id: taskId,
1375
+ });
1376
+ }
1377
+
1378
+ return {
1379
+ task_id: taskId,
1380
+ leases: [...task.leases.values()].map((l) => ({
1381
+ machine_id: l.machine_id,
1382
+ index: l.index,
1383
+ state: l.state,
1384
+ // v0.4.0: the dispatcher pushes an offer over the direct path, and the executor dedupes it
1385
+ // against the relay's copy by (task_id, attempt, dedupe_key). Without this field the
1386
+ // dispatcher has to reproduce the §4.4 formula locally, which works for a single-command
1387
+ // task and cannot work for a pipeline one -- the key covers the relay's normalised stage
1388
+ // list. Publishing it makes the pushed copy recognisably the same work item, which is what
1389
+ // keeps the direct path from turning exactly-once into at-least-twice.
1390
+ dedupe_key: l.dedupe_key ?? null,
1391
+ attempt: l.attempt,
1392
+ ...(l.refusal_reason ? { refusal_reason: l.refusal_reason } : {}),
1393
+ })),
1394
+ seq: lastSeq,
1395
+ };
1396
+ }
1397
+
1398
+ /**
1399
+ * v0.4.0: should the relay hold its offer back for this task?
1400
+ *
1401
+ * Only when the dispatcher said two things: that it is a machine (`origin_machine_id`), and that
1402
+ * the direct path is in play (`p2p.mode` of `auto` or `direct`). A `relay` mode or a v0.3.9
1403
+ * dispatcher gets the old behaviour exactly, which is why this predicate is the only gate on the
1404
+ * change and every pre-existing test still sees an immediate offer.
1405
+ *
1406
+ * @param {object} task
1407
+ * @returns {number} milliseconds to hold, or 0 for "offer now"
1408
+ */
1409
+ _p2pOfferGraceMs(task) {
1410
+ if (this.p2pOfferGraceMs <= 0) return 0;
1411
+ const origin = task?.origin_machine_id;
1412
+ if (typeof origin !== 'string' || origin === '') return 0;
1413
+ const mode = task?.p2p?.mode;
1414
+ if (mode !== 'auto' && mode !== 'direct') return 0;
1415
+ return this.p2pOfferGraceMs;
1416
+ }
1417
+
1418
+ /**
1419
+ * v0.4.0: emit `lease`'s offer after `delayMs`, unless the direct path got there first.
1420
+ *
1421
+ * "Got there first" is one fact and it is already tracked: the executor renews the lease
1422
+ * (`POST /v1/heartbeat`) as its first act on any offer it accepts, and a renewal moves the lease
1423
+ * out of `queued`. So the check is the lease's own state — no new message, no new endpoint, and
1424
+ * nothing the executor has to know about.
1425
+ *
1426
+ * The timer is `unref()`ed: a relay with nothing else to do must still be able to exit, and a
1427
+ * pending offer is not a reason to keep a process alive.
1428
+ */
1429
+ _scheduleOffer(task, lease, delayMs) {
1430
+ const key = `${task.task_id}:${lease.machine_id}`;
1431
+ const timer = setTimeout(() => {
1432
+ this._pendingOffers.delete(key);
1433
+ // A lease that was re-created (takeover) or swept is not this one any more.
1434
+ if (task.leases.get(lease.machine_id) !== lease) return;
1435
+ if (lease.state !== 'queued') return; // the direct copy won; nothing to fall back to
1436
+ if (this.isLeaseExpired(lease, this.nowMs())) return; // the sweep owns it now
1437
+ this.emitOffer(task, lease);
1438
+ }, delayMs);
1439
+ timer.unref?.();
1440
+ this._pendingOffers.set(key, timer);
1441
+ }
1442
+
1443
+ /** Drop every deferred offer. Called by the relay's own teardown path. */
1444
+ clearPendingOffers() {
1445
+ for (const timer of this._pendingOffers.values()) clearTimeout(timer);
1446
+ this._pendingOffers.clear();
1447
+ }
1448
+
1449
+ emitOffer(task, lease) {
1450
+ const event = this.emit('task.offer', {
1451
+ task_id: task.task_id,
1452
+ attempt: lease.attempt,
1453
+ mode: task.mode,
1454
+ index: lease.index,
1455
+ index_total: task.index_total,
1456
+ command_argv: task.command_argv,
1457
+ // v0.3.3: present only for `pipeline`, and only then does the machine run a chain. Omitted
1458
+ // entirely otherwise, so an offer for a single-command task is byte-identical to before --
1459
+ // which is what keeps a v0.2.3 agent working against this relay.
1460
+ ...(task.stages ? { stages: task.stages } : {}),
1461
+ /**
1462
+ * v0.4.0: always present, `null` when the caller supplied nothing.
1463
+ *
1464
+ * Deliberately NOT conditional (unlike `stages` above, which is omitted so a single-command
1465
+ * offer stays byte-identical for an old agent): the contract fixes the shape of these two as
1466
+ * `string|null` and `{mode}|null`, and `null` is the honest value for "the dispatcher did not
1467
+ * say" — which is exactly the v0.3.9 case. They are additive keys in an event whose consumers
1468
+ * already ignore unknown fields.
1469
+ */
1470
+ origin_machine_id: task.origin_machine_id ?? null,
1471
+ p2p: task.p2p ?? null,
1472
+ cwd_rel: task.cwd_rel,
1473
+ write: task.write,
1474
+ timeout_ms: task.timeout_ms,
1475
+ base_commit: task.base_commit,
1476
+ base_tree: task.base_tree,
1477
+ requirements: task.requirements,
1478
+ compare_policy: task.compare_policy,
1479
+ dedupe_key: lease.dedupe_key,
1480
+ deadline: rfc3339(task.deadline_ms),
1481
+ }, { target: lease.machine_id });
1482
+ lease.state = 'offered';
1483
+ lease.offered_seq = event.seq;
1484
+ lease.last_heartbeat_ms = this.nowMs();
1485
+ lease.lease_until_ms = lease.last_heartbeat_ms + this.leaseWindowMs();
1486
+ return event;
1487
+ }
1488
+
1489
+ getTask(taskId) { return this.tasks.get(taskId) ?? null; }
1490
+
1491
+ requireTask(taskId) {
1492
+ const task = this.tasks.get(taskId);
1493
+ if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
1494
+ return task;
1495
+ }
1496
+
1497
+ /**
1498
+ * Re-offer the work a machine was handed but has not yet started (v0.1.2 §8.6).
1499
+ *
1500
+ * WHY THIS EXISTS
1501
+ *
1502
+ * An offer is a point-in-time event on a stream. `createTask` publishes
1503
+ * `task.offer` once, and if the target machine's stream happens to be down at
1504
+ * that instant -- the ordinary situation after a relay restart, a tunnel drop,
1505
+ * or any cross-network interruption -- the event is simply gone. The lease sits
1506
+ * in `offered` until the sweep expires it, the task never runs, and the
1507
+ * operator sees a timeout with no explanation. Measured: the machine is back
1508
+ * online with a live stream within milliseconds, and still receives nothing.
1509
+ *
1510
+ * So the relay re-states unclaimed work when a machine connects. This is safe
1511
+ * because the state it acts on is durable: the lease the offer refers to is
1512
+ * still in the ledger.
1513
+ *
1514
+ * WHY ONLY `offered` AND `queued`
1515
+ *
1516
+ * `running` is deliberately excluded, and that exclusion is the whole safety
1517
+ * argument. A running lease means a machine has already STARTED the command --
1518
+ * it acknowledged the offer and is heartbeating. Re-offering it would make that
1519
+ * machine execute the work a second time, so the fix for a lost offer would
1520
+ * introduce the one failure this project is built to avoid: two machines, or
1521
+ * one machine twice, running the same side-effecting command.
1522
+ *
1523
+ * A machine that dies mid-run therefore does not get its work back here; that
1524
+ * path stays with the sweep, which expires the lease and hands the index to
1525
+ * another machine with `attempt + 1`.
1526
+ *
1527
+ * Re-emitted offers are new events and are replayed like any other, so a client
1528
+ * that reconnects repeatedly can see the same offer more than once. That is
1529
+ * intentional: the agent already de-duplicates by `task_id` (it ignores an
1530
+ * offer whose task is queued or running), and an at-least-once offer with a
1531
+ * client-side dedupe is strictly better than an at-most-once offer that is
1532
+ * silently dropped.
1533
+ *
1534
+ * @param {string} machineId
1535
+ * @returns {{count: number, task_ids: string[]}}
1536
+ */
1537
+ redeliverPendingOffers(machineId) {
1538
+ const taskIds = [];
1539
+ const now = this.nowMs();
1540
+
1541
+ for (const task of this.tasks.values()) {
1542
+ if (task.cancelled === true) continue;
1543
+ const lease = task.leases.get(machineId);
1544
+ if (!lease) continue;
1545
+ // Only work the machine has not begun. See the note above: `running` is
1546
+ // excluded on purpose, not by omission.
1547
+ if (lease.state !== 'offered' && lease.state !== 'queued') continue;
1548
+ // Do not resurrect a lease the sweep has already written off; the takeover
1549
+ // path owns that decision.
1550
+ if (this.isLeaseExpired(lease, now)) continue;
1551
+
1552
+ this.emitOffer(task, lease);
1553
+ taskIds.push(task.task_id);
1554
+ }
1555
+
1556
+ return { count: taskIds.length, task_ids: taskIds };
1557
+ }
1558
+
1559
+ listTasks({ limit = 50 } = {}) {
1560
+ const n = Number.isInteger(limit) && limit > 0 ? limit : 50;
1561
+ return [...this.tasks.values()]
1562
+ .sort((a, b) => b.created_at_ms - a.created_at_ms)
1563
+ .slice(0, n)
1564
+ .map((t) => this.taskSummary(t));
1565
+ }
1566
+
1567
+ taskSummary(task) {
1568
+ return {
1569
+ task_id: task.task_id,
1570
+ mode: task.mode,
1571
+ created_at: rfc3339(task.created_at_ms),
1572
+ created_by: task.created_by,
1573
+ index_total: task.index_total,
1574
+ cancelled: task.cancelled,
1575
+ degraded: task.degraded,
1576
+ leases: [...task.leases.values()].map((l) => l.machine_id),
1577
+ lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
1578
+ };
1579
+ }
1580
+
1581
+ cancelTask(taskId, reason = 'cancelled_by_operator') {
1582
+ const task = this.requireTask(taskId);
1583
+ if (task.cancelled) return task;
1584
+ task.cancelled = true;
1585
+ task.cancel_reason = reason;
1586
+ for (const lease of task.leases.values()) {
1587
+ if (ACTIVE_LEASE_STATES.includes(lease.state)) lease.state = 'cancelled';
1588
+ }
1589
+ this.emit('task.cancel', { task_id: taskId, reason });
1590
+ this._ledger({ type: 'task.cancelled', task_id: taskId, reason });
1591
+ return task;
1592
+ }
1593
+
1594
+ /* ---------------- leases / heartbeat (§4.3) ---------------- */
1595
+
1596
+ /** Shared `phase`/`progress` validation for both heartbeat modes. */
1597
+ _validatePhaseProgress(input) {
1598
+ if (input.phase !== undefined && input.phase !== null && !LEASE_PHASES.includes(input.phase)) {
1599
+ throw new ProtocolError('BAD_REQUEST', 'phase must be preparing|running|finalizing', { phase: input.phase });
1600
+ }
1601
+ if (input.progress !== undefined && input.progress !== null
1602
+ && (typeof input.progress !== 'number' || input.progress < 0 || input.progress > 1)) {
1603
+ throw new ProtocolError('BAD_REQUEST', 'progress must be a number in [0,1]', { progress: input.progress });
1604
+ }
1605
+ }
1606
+
1607
+ /**
1608
+ * v0.3.0 IDLE (diagnostic) heartbeat — an agent with no lease keeps its latency
1609
+ * and liveness visible to other machines.
1610
+ *
1611
+ * This path deliberately NEVER looks at `this.tasks`. That is the whole safety
1612
+ * argument, and it is structural rather than a matter of remembering a rule: an
1613
+ * idle heartbeat cannot renew, resurrect or otherwise touch a lease because it
1614
+ * never obtains one. Without that, an idle ping from a machine whose lease had
1615
+ * expired would flip `expired` back to `running` and manufacture a live task out
1616
+ * of nothing -- a fake "the work is still being done" signal, which is the most
1617
+ * dangerous lie this relay can tell.
1618
+ */
1619
+ diagnosticHeartbeat(input = {}) {
1620
+ const machineId = typeof input.machine_id === 'string' && input.machine_id !== ''
1621
+ ? input.machine_id : null;
1622
+ if (machineId === null) {
1623
+ throw new ProtocolError('BAD_REQUEST', 'machine_id is required', {});
1624
+ }
1625
+ if (!this.devices.has(machineId)) {
1626
+ throw new ProtocolError('NOT_FOUND', 'unknown machine_id', { machine_id: machineId });
1627
+ }
1628
+ this._validatePhaseProgress(input);
1629
+
1630
+ const device = this.touchDevice(machineId);
1631
+ this.recordHeartbeatMetrics(machineId, input);
1632
+ return {
1633
+ // No lease is involved, and the response says so instead of inventing one.
1634
+ lease_until: null,
1635
+ cancel: false,
1636
+ diagnostic: true,
1637
+ machine_id: machineId,
1638
+ rtt_ms: device?.rtt_ms ?? null,
1639
+ };
1640
+ }
1641
+
1642
+ /**
1643
+ * §4.3 POST /v1/heartbeat — renews the lease using SERVER time only.
1644
+ *
1645
+ * Two modes since v0.3.0:
1646
+ * - `task_id` present -> LEASE heartbeat (unchanged §4.3 behaviour)
1647
+ * - `diagnostic: true`, no task -> IDLE heartbeat (diagnostics only, no lease)
1648
+ * - neither -> 400. A heartbeat that silently renewed
1649
+ * nothing would let an agent whose `task_id` went missing stop renewing while
1650
+ * believing it was fine, ending in a falsely `expired` task. §0 forbids
1651
+ * silent degradation, so this is loud.
1652
+ *
1653
+ * @returns {{lease_until:string|null, cancel:boolean, phase?:string, progress?:number, diagnostic?:boolean}}
1654
+ */
1655
+ heartbeat(input = {}) {
1656
+ const hasTask = typeof input.task_id === 'string' && input.task_id !== '';
1657
+ if (!hasTask) {
1658
+ if (input.diagnostic !== true) {
1659
+ throw new ProtocolError('BAD_REQUEST',
1660
+ 'a heartbeat must carry task_id (lease renewal) or diagnostic:true (idle diagnostics); '
1661
+ + 'refusing to treat a missing task_id as a no-op renewal', {
1662
+ received_keys: Object.keys(input).sort(),
1663
+ });
1664
+ }
1665
+ return this.diagnosticHeartbeat(input);
1666
+ }
1667
+ if (input.diagnostic === true) {
1668
+ // task_id wins: a heartbeat that names a task IS a lease heartbeat.
1669
+ // (documented; the flag is simply redundant here)
1670
+ }
1671
+ return this.leaseHeartbeat(input);
1672
+ }
1673
+
1674
+ leaseHeartbeat(input = {}) {
1675
+ const task = this.requireTask(input.task_id);
1676
+ const lease = task.leases.get(input.machine_id);
1677
+ if (!lease) {
1678
+ throw new ProtocolError('NOT_FOUND', 'no lease for this machine on this task', {
1679
+ task_id: input.task_id, machine_id: input.machine_id,
1680
+ });
1681
+ }
1682
+ this._validatePhaseProgress(input);
1683
+
1684
+ const now = this.nowMs();
1685
+ this.touchDevice(input.machine_id);
1686
+ this.recordHeartbeatMetrics(input.machine_id, input, now);
1687
+
1688
+ if (task.cancelled) {
1689
+ return { lease_until: rfc3339(lease.lease_until_ms), cancel: true, reason: task.cancel_reason };
1690
+ }
1691
+
1692
+ // §4.3: once halt=now has handed this index to another attempt, the old owner
1693
+ // must stop — a late heartbeat from the expired machine is not a valid re-claim.
1694
+ if (lease.state === 'expired' && task.attempt > lease.attempt) {
1695
+ return { lease_until: rfc3339(lease.lease_until_ms), cancel: true, reason: 'LEASE_EXPIRED_TAKEN_OVER' };
1696
+ }
1697
+
1698
+ const stateBefore = lease.state;
1699
+ lease.last_heartbeat_ms = now;
1700
+ lease.lease_until_ms = now + this.leaseWindowMs();
1701
+ lease.heartbeat_count += 1;
1702
+ if (input.phase !== undefined && input.phase !== null) lease.phase = input.phase;
1703
+ if (input.progress !== undefined && input.progress !== null) lease.progress = input.progress;
1704
+ if (Number.isInteger(input.attempt) && input.attempt >= 1) lease.attempt = input.attempt;
1705
+ if (lease.state === 'queued' || lease.state === 'offered') lease.state = 'running';
1706
+ // A live heartbeat is stronger evidence than a timer: with halt=never nobody took
1707
+ // the work away, so the late-but-alive agent re-claims its own lease.
1708
+ if (lease.state === 'expired') { lease.state = 'running'; lease.expired_at_ms = null; }
1709
+
1710
+ // §7 ledger records lease STATE changes, not every 10s heartbeat.
1711
+ if (lease.state !== stateBefore) this._ledgerLease(task, lease);
1712
+
1713
+ return { lease_until: rfc3339(lease.lease_until_ms), cancel: false, phase: lease.phase, progress: lease.progress };
1714
+ }
1715
+
1716
+ /** §4.3: expiry is decided by Rabbit's clock only. */
1717
+ isLeaseExpired(lease, nowMs = this.nowMs()) {
1718
+ if (!ACTIVE_LEASE_STATES.includes(lease.state)) return false;
1719
+ return nowMs >= lease.lease_until_ms;
1720
+ }
1721
+
1722
+ /**
1723
+ * §4.3 expiry sweep.
1724
+ * - halt=never → book-keeping only, task is marked `partial`
1725
+ * - halt=now → notify remaining machines immediately that they may take over
1726
+ */
1727
+ sweepExpired(nowMs = this.nowMs()) {
1728
+ const expired = [];
1729
+ for (const task of this.tasks.values()) {
1730
+ if (task.cancelled) continue;
1731
+ let changed = false;
1732
+ for (const lease of task.leases.values()) {
1733
+ if (!this.isLeaseExpired(lease, nowMs)) continue;
1734
+ lease.state = 'expired';
1735
+ lease.expired_at_ms = nowMs;
1736
+ changed = true;
1737
+ expired.push({ task, lease });
1738
+ this._ledgerLease(task, lease);
1739
+ this.emit('notice', {
1740
+ level: 'warn',
1741
+ code: 'LEASE_EXPIRED',
1742
+ message: `lease expired: machine=${lease.machine_id} task=${task.task_id} attempt=${lease.attempt}`,
1743
+ task_id: task.task_id,
1744
+ machine_id: lease.machine_id,
1745
+ });
1746
+ if (task.halt === 'now') {
1747
+ task.attempt += 1;
1748
+ this._takeover(task, lease, nowMs);
1749
+ }
1750
+ }
1751
+ if (changed) task.degraded = 'partial';
1752
+ }
1753
+ return expired;
1754
+ }
1755
+
1756
+ /**
1757
+ * §4.3 `halt=now`: "立即通知其他机器可接管".
1758
+ *
1759
+ * v0.0.1's PROTOCOL defines no per-index lease reassignment, so takeover is
1760
+ * implemented as: re-offer the freed `index` (attempt+1) to every live machine
1761
+ * that is idle — no lease yet, or a lease still queued/offered — and broadcast a
1762
+ * notice. Machines already `running` are never re-offered, to avoid double
1763
+ * execution of the same index.
1764
+ */
1765
+ _takeover(task, expiredLease, nowMs = this.nowMs()) {
1766
+ const candidates = [];
1767
+ for (const device of this.devices.values()) {
1768
+ if (device.machine_id === expiredLease.machine_id) continue;
1769
+ const lease = task.leases.get(device.machine_id);
1770
+ if (lease && !['queued', 'offered'].includes(lease.state)) continue;
1771
+ if (!lease && gateMachine(task, device)) continue;
1772
+ candidates.push({ device, lease: lease ?? null });
1773
+ }
1774
+
1775
+ for (const { device, lease } of candidates) {
1776
+ const target = lease ?? {
1777
+ machine_id: device.machine_id,
1778
+ machine_name: device.machine_name,
1779
+ index: expiredLease.index,
1780
+ attempt: task.attempt,
1781
+ state: 'queued',
1782
+ refusal_reason: null,
1783
+ refusal_detail: null,
1784
+ dedupe_key: null,
1785
+ last_heartbeat_ms: nowMs,
1786
+ claimed_at_ms: nowMs,
1787
+ lease_until_ms: nowMs + this.leaseWindowMs(),
1788
+ phase: null,
1789
+ progress: null,
1790
+ offered_seq: null,
1791
+ expired_at_ms: null,
1792
+ finished_at_ms: null,
1793
+ heartbeat_count: 0,
1794
+ };
1795
+ target.index = expiredLease.index;
1796
+ target.attempt = task.attempt;
1797
+ target.dedupe_key = computeDedupeKey(task.task_id, target.index, task.command_hash, task.base_tree);
1798
+ task.leases.set(device.machine_id, target);
1799
+ this.emitOffer(task, target);
1800
+ this._ledgerLease(task, target);
1801
+ }
1802
+
1803
+ this.emit('notice', {
1804
+ level: 'info',
1805
+ code: candidates.length > 0 ? 'LEASE_TAKEOVER' : 'LEASE_TAKEOVER_UNAVAILABLE',
1806
+ message: candidates.length > 0
1807
+ ? `task ${task.task_id} index ${expiredLease.index} re-offered at attempt ${task.attempt} to: `
1808
+ + candidates.map((c) => c.device.machine_id).join(',')
1809
+ : `task ${task.task_id} index ${expiredLease.index} has no idle machine to take over`,
1810
+ task_id: task.task_id,
1811
+ });
1812
+ return candidates.map((c) => c.device.machine_id);
1813
+ }
1814
+
1815
+ /* ---------------- results (§5) ---------------- */
1816
+
1817
+ static instanceKey(machineId, dedupeKey, attempt) {
1818
+ return `${machineId}${INSTANCE_SEP}${dedupeKey}${INSTANCE_SEP}${attempt}`;
1819
+ }
1820
+
1821
+ /**
1822
+ * §5 result envelope validation.
1823
+ * Missing §5.1 required fields do NOT reject the frame — they make the result
1824
+ * `unverifiable` (§5.1 "缺一即 unverifiable").
1825
+ */
1826
+ static validateEnvelope(envelope) {
1827
+ const missing = [];
1828
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
1829
+ return { missing: [...REQUIRED_ENVELOPE_FIELDS], malformed: true };
1830
+ }
1831
+ for (const field of REQUIRED_ENVELOPE_FIELDS) {
1832
+ if (!(field in envelope) || envelope[field] === undefined) missing.push(field);
1833
+ }
1834
+ const problems = [];
1835
+ if ('status' in envelope && !RESULT_STATUSES.includes(envelope.status)) {
1836
+ problems.push(`status must be one of ${RESULT_STATUSES.join('|')}`);
1837
+ }
1838
+ if (envelope.status === 'refused' && !envelope.refusal_reason) {
1839
+ problems.push('refusal_reason is required when status=refused');
1840
+ }
1841
+ if ('warnings' in envelope && !Array.isArray(envelope.warnings)) problems.push('warnings must be string[]');
1842
+ return { missing, problems };
1843
+ }
1844
+
1845
+ /**
1846
+ * §1 idempotent POST /v1/result.
1847
+ *
1848
+ * NOTE (contract ambiguity, see report): §4.4 `dedupe_key` deliberately excludes
1849
+ * `machine_id`/`attempt`, but §6.3 aggregates results from *several* machines in
1850
+ * replicate mode — they all share one dedupe_key. Deduping purely on dedupe_key
1851
+ * would silently drop every machine's result but the first. We therefore key the
1852
+ * idempotency check on (machine_id, dedupe_key, attempt): an identical redelivery
1853
+ * is `deduped:true`, distinct machines/attempts are all recorded.
1854
+ */
1855
+ submitResult(envelope = {}) {
1856
+ const { missing, problems, malformed } = RabbitState.validateEnvelope(envelope);
1857
+ if (malformed) throw new ProtocolError('BAD_REQUEST', 'result must be a JSON object');
1858
+
1859
+ /**
1860
+ * v0.4.0: `transport` / `p2p` are OPTIONAL envelope fields, and they are validated as values
1861
+ * rather than added to the §5.1 required set. A v0.3.9 agent sends neither and must keep
1862
+ * working unchanged; an agent that sends `transport: 'satellite'` is refused by name instead of
1863
+ * storing a value the report would then show as a path that does not exist.
1864
+ */
1865
+ RabbitState.validateTransportFields(envelope);
1866
+
1867
+ const taskId = envelope.task_id;
1868
+ if (typeof taskId !== 'string' || taskId.length === 0) {
1869
+ throw new ProtocolError('BAD_REQUEST', 'task_id is required');
1870
+ }
1871
+ const task = this.tasks.get(taskId);
1872
+ if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
1873
+
1874
+ const machineId = typeof envelope.machine_id === 'string' ? envelope.machine_id : null;
1875
+ const dedupeKey = typeof envelope.dedupe_key === 'string' && envelope.dedupe_key
1876
+ ? envelope.dedupe_key : null;
1877
+ const attempt = Number.isInteger(envelope.attempt) && envelope.attempt >= 1 ? envelope.attempt : 1;
1878
+
1879
+ if (dedupeKey === null) {
1880
+ // Cannot dedupe without a key; still record it, as unverifiable.
1881
+ missing.push('dedupe_key');
1882
+ }
1883
+ const instanceKey = RabbitState.instanceKey(machineId ?? '<unknown>', dedupeKey ?? '<none>', attempt);
1884
+
1885
+ // §4.4 sanity check against the lease this machine actually holds. Booked as a
1886
+ // problem instead of a hard rejection so a buggy client cannot lose its result.
1887
+ const leaseForMachine = machineId ? task.leases.get(machineId) : null;
1888
+ if (leaseForMachine?.dedupe_key && dedupeKey && leaseForMachine.dedupe_key !== dedupeKey) {
1889
+ problems.push('dedupe_key does not match the §4.4 formula for this lease');
1890
+ }
1891
+
1892
+ if (this.results.has(instanceKey)) {
1893
+ const prior = this.results.get(instanceKey);
1894
+ return { deduped: true, task_id: taskId, dedupe_key: dedupeKey, seq: prior.seq };
1895
+ }
1896
+
1897
+ const now = this.nowMs();
1898
+ let envelopeShaOk = null;
1899
+ if (typeof envelope.envelope_sha256 === 'string') {
1900
+ const { envelope_sha256: _drop, ...rest } = envelope;
1901
+ envelopeShaOk = sha256Hex(jcs(rest)) === envelope.envelope_sha256;
1902
+ }
1903
+
1904
+ const record = {
1905
+ envelope,
1906
+ machine_id: machineId,
1907
+ dedupe_key: dedupeKey,
1908
+ attempt,
1909
+ instance_key: instanceKey,
1910
+ received_at_ms: now,
1911
+ validation: {
1912
+ missing: [...new Set(missing)],
1913
+ problems,
1914
+ envelope_sha256_ok: envelopeShaOk,
1915
+ },
1916
+ };
1917
+
1918
+ const effectiveStatus = record.validation.missing.length > 0 ? 'unverifiable' : envelope.status;
1919
+
1920
+ const seq = this.nextSeq();
1921
+ record.seq = seq;
1922
+ record.effective_status = effectiveStatus;
1923
+ this.results.set(instanceKey, record);
1924
+
1925
+ if (machineId) {
1926
+ const lease = task.leases.get(machineId);
1927
+ if (lease) {
1928
+ lease.state = effectiveStatus === 'refused' ? 'refused' : 'done';
1929
+ if (effectiveStatus === 'refused' && !lease.refusal_reason) {
1930
+ lease.refusal_reason = envelope.refusal_reason ?? 'REFUSED';
1931
+ }
1932
+ lease.finished_at_ms = now;
1933
+ lease.attempt = attempt;
1934
+ }
1935
+ this.touchDevice(machineId);
1936
+ }
1937
+ task.result_seqs.push(seq);
1938
+
1939
+ this.emit('task.result', {
1940
+ task_id: taskId,
1941
+ dedupe_key: dedupeKey,
1942
+ attempt,
1943
+ index: envelope.index ?? null,
1944
+ status: effectiveStatus,
1945
+ machine_id: machineId,
1946
+ deduped: false,
1947
+ validation_errors: record.validation.missing,
1948
+ });
1949
+
1950
+ // §7: results go into the ledger so the dedupe index survives a restart.
1951
+ this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
1952
+ if (machineId) {
1953
+ const changed = task.leases.get(machineId);
1954
+ if (changed) this._ledgerLease(task, changed);
1955
+ }
1956
+
1957
+ return { deduped: false, task_id: taskId, dedupe_key: dedupeKey, seq, _instanceKey: instanceKey };
1958
+ }
1959
+
1960
+ /** All recorded results for a task, keyed by machine_id (later attempts win). */
1961
+ taskResults(taskId) {
1962
+ const task = this.tasks.get(taskId);
1963
+ if (!task) return new Map();
1964
+ const out = new Map();
1965
+ for (const rec of this.results.values()) {
1966
+ if (rec.envelope.task_id !== taskId) continue;
1967
+ const prev = out.get(rec.machine_id);
1968
+ if (!prev || rec.received_at_ms >= prev.received_at_ms) out.set(rec.machine_id, rec);
1969
+ }
1970
+ return out;
1971
+ }
1972
+
1973
+ /** Global dedupe book-keeping: dedupe_key -> instances seen. */
1974
+ dedupeReport() {
1975
+ const out = {};
1976
+ for (const rec of this.results.values()) {
1977
+ if (!rec.dedupe_key) continue;
1978
+ out[rec.dedupe_key] = out[rec.dedupe_key] ?? [];
1979
+ out[rec.dedupe_key].push({
1980
+ machine_id: rec.machine_id,
1981
+ attempt: rec.attempt,
1982
+ received_at: rfc3339(rec.received_at_ms),
1983
+ });
1984
+ }
1985
+ return out;
1986
+ }
1987
+
1988
+ /* ---------------- serialization ---------------- */
1989
+
1990
+ stats() {
1991
+ return {
1992
+ protocol_version: PROTOCOL_VERSION,
1993
+ seq: this.seq,
1994
+ buffered_events: this.buffer.length,
1995
+ devices: this.devices.size,
1996
+ tasks: this.tasks.size,
1997
+ results: this.results.size,
1998
+ pairing_codes: [...this.pairingCodes.values()].filter((c) => !c.used && this.nowMs() < c.expires_at_ms).length,
1999
+ uptime_ms: this.nowMs() - this.startedAtMs,
2000
+ };
2001
+ }
2002
+ }
2003
+
2004
+ export default RabbitState;