@rivalis/fleet 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1217 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/agent/FleetAgent.ts
21
+ var FleetAgent_exports = {};
22
+ __export(FleetAgent_exports, {
23
+ FleetAgent: () => FleetAgent
24
+ });
25
+ module.exports = __toCommonJS(FleetAgent_exports);
26
+ var import_base = require("@toolcase/base");
27
+
28
+ // src/agent/Snapshot.ts
29
+ var import_node_crypto2 = require("crypto");
30
+
31
+ // src/util/canonical.ts
32
+ var import_node_crypto = require("crypto");
33
+ function canonicalize(value) {
34
+ return encode(value);
35
+ }
36
+ function encode(value) {
37
+ if (value === null) {
38
+ return "null";
39
+ }
40
+ const type = typeof value;
41
+ if (type === "string") {
42
+ return JSON.stringify(value);
43
+ }
44
+ if (type === "number") {
45
+ return Number.isFinite(value) ? String(value) : "null";
46
+ }
47
+ if (type === "boolean") {
48
+ return value ? "true" : "false";
49
+ }
50
+ if (type === "bigint") {
51
+ return value.toString();
52
+ }
53
+ if (Array.isArray(value)) {
54
+ const items = value.map((item) => encodeArrayItem(item));
55
+ return "[" + items.join(",") + "]";
56
+ }
57
+ if (type === "object") {
58
+ const obj = value;
59
+ const keys = Object.keys(obj).sort();
60
+ const parts = [];
61
+ for (const key of keys) {
62
+ const child = obj[key];
63
+ if (isSkippable(child)) {
64
+ continue;
65
+ }
66
+ parts.push(JSON.stringify(key) + ":" + encode(child));
67
+ }
68
+ return "{" + parts.join(",") + "}";
69
+ }
70
+ return "null";
71
+ }
72
+ function encodeArrayItem(item) {
73
+ return isSkippable(item) ? "null" : encode(item);
74
+ }
75
+ function isSkippable(value) {
76
+ const type = typeof value;
77
+ return value === void 0 || type === "function" || type === "symbol";
78
+ }
79
+ function hash64(value) {
80
+ const digest = (0, import_node_crypto.createHash)("sha256").update(canonicalize(value)).digest();
81
+ return digest.subarray(0, 8).toString("hex");
82
+ }
83
+
84
+ // src/util/logger.ts
85
+ var NOOP_LOGGER = {
86
+ error() {
87
+ },
88
+ warning() {
89
+ },
90
+ info() {
91
+ },
92
+ debug() {
93
+ },
94
+ verbose() {
95
+ },
96
+ log() {
97
+ }
98
+ };
99
+
100
+ // src/util/packageVersion.ts
101
+ var import_node_module = require("module");
102
+ var import_meta = {};
103
+ function packageVersion() {
104
+ try {
105
+ const metaUrl = import_meta.url;
106
+ const req = metaUrl ? (0, import_node_module.createRequire)(metaUrl) : require;
107
+ const pkg = req("../package.json");
108
+ return pkg.version ?? "0.0.0";
109
+ } catch {
110
+ return "0.0.0";
111
+ }
112
+ }
113
+
114
+ // src/wire/topics.ts
115
+ var PROTOCOL_VERSION = 3;
116
+ var WS_SUBPROTOCOL = "rivalis-fleet.v1";
117
+ var Topics = {
118
+ /** orch → agent: assigns id + heartbeat (poll cadence) on join; followed by the first poll. */
119
+ hello: "fleet/hello",
120
+ /** orch → agent: state poll. Carries `knownHash` (dedup) + the last recorded `status` (echo). */
121
+ poll: "fleet/poll",
122
+ /** agent → orch: poll reply. Full snapshot when the hash differs from `knownHash`, hash-only otherwise. */
123
+ state: "fleet/state",
124
+ /** orch → agent: command push. */
125
+ cmd: "fleet/cmd",
126
+ /** agent → orch: command result. */
127
+ ack: "fleet/ack"
128
+ };
129
+
130
+ // src/wire/serializer.ts
131
+ var import_node_module2 = require("module");
132
+ var import_meta2 = {};
133
+ var WIRE_MAJOR = PROTOCOL_VERSION;
134
+ var WIRE_MINOR = 0;
135
+ var HEADER_BYTES = 2;
136
+ var WireVersionError = class extends Error {
137
+ /** The major byte read off the incompatible frame (123 for a legacy JSON `{...}` frame). */
138
+ theirVersion;
139
+ /** This build's protocol major. */
140
+ ourVersion;
141
+ constructor(theirVersion) {
142
+ super(
143
+ `fleet wire protocol version mismatch: peer speaks major v${theirVersion}, this build speaks v${PROTOCOL_VERSION} \u2014 agents and orchestrator must run the same @rivalis/fleet major (\xA77). A v1 (JSON) peer against a v${PROTOCOL_VERSION} peer is exactly this case; upgrade both halves in lockstep.`
144
+ );
145
+ this.name = "WireVersionError";
146
+ this.theirVersion = theirVersion;
147
+ this.ourVersion = PROTOCOL_VERSION;
148
+ }
149
+ };
150
+ var Type = {
151
+ Label: "Label",
152
+ SyncRoom: "SyncRoom",
153
+ Capacity: "Capacity",
154
+ AckRoom: "AckRoom",
155
+ Hello: "Hello",
156
+ Poll: "Poll",
157
+ State: "State",
158
+ Cmd: "Cmd",
159
+ Ack: "Ack"
160
+ };
161
+ var TOPIC_TYPE = {
162
+ [Topics.hello]: Type.Hello,
163
+ [Topics.poll]: Type.Poll,
164
+ [Topics.state]: Type.State,
165
+ [Topics.cmd]: Type.Cmd,
166
+ [Topics.ack]: Type.Ack
167
+ };
168
+ var serializer = null;
169
+ function getSerializer() {
170
+ if (serializer !== null) {
171
+ return serializer;
172
+ }
173
+ const metaUrl = import_meta2.url;
174
+ const req = metaUrl ? (0, import_node_module2.createRequire)(metaUrl) : require;
175
+ const mod = req("@toolcase/serializer");
176
+ const Serializer = mod.Serializer ?? mod.default;
177
+ const F = Serializer.FieldType;
178
+ const s = new Serializer("fleet");
179
+ s.define(Type.Label, [
180
+ { key: "key", type: F.STRING, rule: "optional" },
181
+ { key: "value", type: F.STRING, rule: "optional" }
182
+ ]);
183
+ s.define(Type.SyncRoom, [
184
+ { key: "id", type: F.STRING, rule: "optional" },
185
+ { key: "type", type: F.STRING, rule: "optional" },
186
+ { key: "connections", type: F.UINT32, rule: "optional" },
187
+ { key: "origin", type: F.STRING, rule: "optional" }
188
+ ]);
189
+ s.define(Type.Capacity, [
190
+ // null = unlimited (§6). Absent on the wire ⇒ null; an explicit 0 ⇒ 0.
191
+ { key: "maxConnections", type: F.INT32, rule: "optional", default: null },
192
+ { key: "maxRooms", type: F.INT32, rule: "optional", default: null }
193
+ ]);
194
+ s.define(Type.AckRoom, [
195
+ { key: "id", type: F.STRING, rule: "optional" },
196
+ { key: "type", type: F.STRING, rule: "optional" }
197
+ ]);
198
+ s.define(Type.Hello, [
199
+ { key: "instanceId", type: F.STRING, rule: "optional" },
200
+ { key: "protocolVersion", type: F.UINT32, rule: "optional" },
201
+ { key: "heartbeatMs", type: F.UINT32, rule: "optional" }
202
+ ]);
203
+ s.define(Type.Poll, [
204
+ { key: "reqId", type: F.STRING, rule: "optional" },
205
+ // Absent ⇒ null (no prior state / forced full, subsumes the old fleet/resync).
206
+ { key: "knownHash", type: F.STRING, rule: "optional" },
207
+ { key: "status", type: F.STRING, rule: "optional" }
208
+ ]);
209
+ s.define(Type.State, [
210
+ { key: "reqId", type: F.STRING, rule: "optional" },
211
+ // full=false is a hash-only liveness reply: the snapshot fields below are
212
+ // omitted on the wire (preserving the old sync/ping dedup, orch-initiated).
213
+ { key: "full", type: F.BOOL, rule: "optional" },
214
+ { key: "seq", type: F.UINT32, rule: "optional" },
215
+ { key: "hash", type: F.STRING, rule: "optional" },
216
+ { key: "name", type: F.STRING, rule: "optional" },
217
+ { key: "processUid", type: F.STRING, rule: "optional" },
218
+ { key: "agentVersion", type: F.STRING, rule: "optional" },
219
+ { key: "protocolVersion", type: F.UINT32, rule: "optional" },
220
+ { key: "endpointUrl", type: F.STRING, rule: "optional" },
221
+ { key: "labels", type: Type.Label, rule: "repeated" },
222
+ { key: "capacity", type: Type.Capacity, rule: "optional" },
223
+ { key: "autoCreate", type: F.BOOL, rule: "optional" },
224
+ { key: "roomTypes", type: F.STRING, rule: "repeated" },
225
+ { key: "rooms", type: Type.SyncRoom, rule: "repeated" },
226
+ { key: "status", type: F.STRING, rule: "optional" }
227
+ ]);
228
+ s.define(Type.Cmd, [
229
+ { key: "cmdId", type: F.STRING, rule: "optional" },
230
+ { key: "op", type: F.STRING, rule: "optional" },
231
+ { key: "roomId", type: F.STRING, rule: "optional" },
232
+ { key: "roomType", type: F.STRING, rule: "optional" }
233
+ ]);
234
+ s.define(Type.Ack, [
235
+ { key: "cmdId", type: F.STRING, rule: "optional" },
236
+ { key: "ok", type: F.BOOL, rule: "optional" },
237
+ { key: "error", type: F.STRING, rule: "optional" },
238
+ { key: "alreadyGone", type: F.BOOL, rule: "optional" },
239
+ { key: "room", type: Type.AckRoom, rule: "optional" },
240
+ // APPEND-ONLY (task 003): the room-already-exists signal must stay LAST so
241
+ // existing tags are unmoved (see the append-only tag rule in the file header).
242
+ { key: "exists", type: F.BOOL, rule: "optional" }
243
+ ]);
244
+ serializer = s;
245
+ return s;
246
+ }
247
+ function present(obj, key) {
248
+ return obj !== null && obj !== void 0 && Object.prototype.hasOwnProperty.call(obj, key);
249
+ }
250
+ function labelsToList(labels) {
251
+ return Object.entries(labels ?? {}).map(([key, value]) => ({ key, value }));
252
+ }
253
+ function labelsFromList(list) {
254
+ const labels = {};
255
+ for (const entry of list ?? []) {
256
+ labels[entry.key ?? ""] = entry.value ?? "";
257
+ }
258
+ return labels;
259
+ }
260
+ function capacityToMessage(capacity) {
261
+ return {
262
+ maxConnections: capacity?.maxConnections ?? null,
263
+ maxRooms: capacity?.maxRooms ?? null
264
+ };
265
+ }
266
+ function capacityFromMessage(capacity) {
267
+ return {
268
+ // Absent ⇒ null (unlimited, §6); an explicit 0 is preserved as 0.
269
+ maxConnections: present(capacity, "maxConnections") ? capacity.maxConnections : null,
270
+ maxRooms: present(capacity, "maxRooms") ? capacity.maxRooms : null
271
+ };
272
+ }
273
+ function stateToMessage(p) {
274
+ if (!p.full) {
275
+ return { reqId: p.reqId, full: false, seq: p.seq, hash: p.hash };
276
+ }
277
+ return {
278
+ reqId: p.reqId,
279
+ full: true,
280
+ seq: p.seq,
281
+ hash: p.hash,
282
+ name: p.name,
283
+ processUid: p.processUid,
284
+ agentVersion: p.agentVersion,
285
+ protocolVersion: p.protocolVersion,
286
+ endpointUrl: p.endpointUrl,
287
+ labels: labelsToList(p.labels),
288
+ capacity: capacityToMessage(p.capacity),
289
+ autoCreate: p.autoCreate,
290
+ roomTypes: p.roomTypes ?? [],
291
+ rooms: (p.rooms ?? []).map((r) => ({
292
+ id: r.id,
293
+ type: r.type,
294
+ connections: r.connections,
295
+ origin: r.origin
296
+ })),
297
+ status: p.status
298
+ };
299
+ }
300
+ function stateFromMessage(m) {
301
+ return {
302
+ reqId: m.reqId ?? "",
303
+ full: m.full ?? false,
304
+ seq: m.seq ?? 0,
305
+ hash: m.hash ?? "",
306
+ name: m.name ?? "",
307
+ processUid: m.processUid ?? "",
308
+ agentVersion: m.agentVersion ?? "",
309
+ protocolVersion: m.protocolVersion ?? 0,
310
+ endpointUrl: m.endpointUrl ?? "",
311
+ labels: labelsFromList(m.labels),
312
+ capacity: capacityFromMessage(m.capacity),
313
+ autoCreate: m.autoCreate ?? false,
314
+ roomTypes: Array.isArray(m.roomTypes) ? m.roomTypes : [],
315
+ rooms: (Array.isArray(m.rooms) ? m.rooms : []).map((r) => ({
316
+ id: r.id ?? "",
317
+ type: r.type ?? "",
318
+ connections: r.connections ?? 0,
319
+ origin: r.origin ?? "local"
320
+ })),
321
+ status: m.status ?? "active"
322
+ };
323
+ }
324
+ function toMessage(topic, payload) {
325
+ switch (topic) {
326
+ case Topics.state:
327
+ return stateToMessage(payload);
328
+ case Topics.poll: {
329
+ const p = payload;
330
+ const msg = { reqId: p.reqId, status: p.status };
331
+ if (p.knownHash !== null && p.knownHash !== void 0) {
332
+ msg.knownHash = p.knownHash;
333
+ }
334
+ return msg;
335
+ }
336
+ default:
337
+ return payload;
338
+ }
339
+ }
340
+ function fromMessage(topic, m) {
341
+ switch (topic) {
342
+ case Topics.hello:
343
+ return {
344
+ instanceId: m.instanceId ?? "",
345
+ protocolVersion: m.protocolVersion ?? 0,
346
+ heartbeatMs: m.heartbeatMs ?? 0
347
+ };
348
+ case Topics.poll:
349
+ return {
350
+ reqId: m.reqId ?? "",
351
+ // Absent knownHash ⇒ null (no prior state / forced full).
352
+ knownHash: present(m, "knownHash") ? m.knownHash : null,
353
+ status: m.status ?? "active"
354
+ };
355
+ case Topics.state:
356
+ return stateFromMessage(m);
357
+ case Topics.cmd: {
358
+ const cmd = { cmdId: m.cmdId ?? "", op: m.op };
359
+ if (present(m, "roomId")) {
360
+ cmd.roomId = m.roomId;
361
+ }
362
+ if (present(m, "roomType")) {
363
+ cmd.roomType = m.roomType;
364
+ }
365
+ return cmd;
366
+ }
367
+ case Topics.ack: {
368
+ const ack = { cmdId: m.cmdId ?? "", ok: m.ok ?? false };
369
+ if (present(m, "error")) {
370
+ ack.error = m.error;
371
+ }
372
+ if (present(m, "alreadyGone")) {
373
+ ack.alreadyGone = m.alreadyGone;
374
+ }
375
+ if (present(m, "exists")) {
376
+ ack.exists = m.exists;
377
+ }
378
+ if (present(m, "room")) {
379
+ ack.room = { id: m.room.id ?? "", type: m.room.type ?? "" };
380
+ }
381
+ return ack;
382
+ }
383
+ default:
384
+ return m;
385
+ }
386
+ }
387
+ function encodeFrame(topic, payload) {
388
+ const type = TOPIC_TYPE[topic];
389
+ if (type === void 0) {
390
+ throw new Error(`fleet wire: no message type for topic=${topic}`);
391
+ }
392
+ const body = getSerializer().encode(type, toMessage(topic, payload));
393
+ const frame = new Uint8Array(HEADER_BYTES + body.length);
394
+ frame[0] = WIRE_MAJOR;
395
+ frame[1] = WIRE_MINOR;
396
+ frame.set(body, HEADER_BYTES);
397
+ return frame;
398
+ }
399
+ function decodeFrame(topic, frame) {
400
+ const type = TOPIC_TYPE[topic];
401
+ if (type === void 0) {
402
+ throw new Error(`fleet wire: no message type for topic=${topic}`);
403
+ }
404
+ if (frame === null || frame === void 0 || frame.length < HEADER_BYTES) {
405
+ throw new Error("fleet wire: truncated frame (shorter than the 2-byte version header)");
406
+ }
407
+ const major = frame[0];
408
+ if (major !== WIRE_MAJOR) {
409
+ throw new WireVersionError(major);
410
+ }
411
+ const body = frame.subarray(HEADER_BYTES);
412
+ const decoded = getSerializer().decode(type, body);
413
+ return fromMessage(topic, decoded);
414
+ }
415
+
416
+ // src/agent/Snapshot.ts
417
+ var MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024;
418
+ var WARN_RATIO = 0.5;
419
+ var ERROR_RATIO = 0.9;
420
+ var MIN_CORE_VERSION = "6.1.0";
421
+ function generateProcessUid() {
422
+ return "p_" + (0, import_node_crypto2.randomBytes)(12).toString("hex");
423
+ }
424
+ var Snapshot = class {
425
+ /** Stable per-process id (§6) — constant across reconnects. */
426
+ processUid;
427
+ rivalis;
428
+ logger;
429
+ name;
430
+ endpointUrl;
431
+ labels;
432
+ capacity;
433
+ autoCreate;
434
+ agentVersion;
435
+ protocolVersion;
436
+ /** Room ids created in response to `fleet/cmd` → stamped `origin: 'fleet'`. */
437
+ fleetOrigins = /* @__PURE__ */ new Set();
438
+ /** Agent owns `status` (§7); flipped via `setStatus`. */
439
+ statusValue;
440
+ /** Per-connection monotonic frame counter — defensive hardening only (§7). */
441
+ seq = 0;
442
+ constructor(rivalis, options, logger) {
443
+ this.assertCoreSupport(rivalis);
444
+ this.rivalis = rivalis;
445
+ this.logger = logger ?? rivalis.logging?.getLogger?.("fleet:agent") ?? NOOP_LOGGER;
446
+ this.name = options.name;
447
+ this.endpointUrl = options.endpointUrl;
448
+ this.labels = options.labels ?? {};
449
+ this.capacity = {
450
+ maxConnections: options.capacity?.maxConnections ?? null,
451
+ maxRooms: options.capacity?.maxRooms ?? null
452
+ };
453
+ this.autoCreate = options.autoCreate ?? true;
454
+ this.agentVersion = options.agentVersion ?? packageVersion();
455
+ this.protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION;
456
+ this.processUid = options.processUid ?? generateProcessUid();
457
+ this.statusValue = options.status ?? "active";
458
+ }
459
+ get status() {
460
+ return this.statusValue;
461
+ }
462
+ /** Flip the agent-owned status (§7). The next snapshot carries the new value. */
463
+ setStatus(status) {
464
+ this.statusValue = status;
465
+ }
466
+ /** Stamp a room as fleet-created (`origin: 'fleet'`). Called on a `fleet/cmd` create. */
467
+ markFleetOrigin(roomId) {
468
+ this.fleetOrigins.add(roomId);
469
+ }
470
+ /** Drop provenance for a destroyed room so a future id reuse is not mis-stamped. */
471
+ forgetRoom(roomId) {
472
+ this.fleetOrigins.delete(roomId);
473
+ }
474
+ /**
475
+ * New connection (reconnect): reset the `seq` counter. The reconnect assigns a
476
+ * fresh `instanceId`, so the orchestrator holds no prior hash and its first poll
477
+ * carries `knownHash: null` → the next reply is always a full snapshot (§7).
478
+ */
479
+ resetConnection() {
480
+ this.seq = 0;
481
+ }
482
+ /**
483
+ * Rebuild the full semantic snapshot from live core state and hash it. Pure:
484
+ * no `seq`, no size guard, no dedup-state mutation — used for hash inspection
485
+ * and as the basis for {@link pollReply}.
486
+ */
487
+ rebuild() {
488
+ const content = this.buildContent();
489
+ return { content, hash: hash64(content) };
490
+ }
491
+ /**
492
+ * Build a `fleet/state` reply to an orchestrator `fleet/poll` (§7, task 011).
493
+ * The orchestrator drives the dedup: a FULL snapshot when the rebuilt hash
494
+ * differs from the poll's `knownHash` (or `knownHash` is null — no prior state /
495
+ * forced full), a hash-only reply otherwise. Always advances `seq`.
496
+ */
497
+ pollReply(reqId, knownHash) {
498
+ const { content, hash } = this.rebuild();
499
+ const seq = this.nextSeq();
500
+ if (knownHash !== null && hash === knownHash) {
501
+ const payload2 = { reqId, full: false, seq, hash, ...content };
502
+ return { kind: "state", full: false, hash, encodedBytes: 0, payload: payload2 };
503
+ }
504
+ const payload = { reqId, full: true, seq, hash, ...content };
505
+ const encodedBytes = encodeFrame(Topics.state, payload).length;
506
+ this.checkSize(encodedBytes, content.rooms.length);
507
+ return { kind: "state", full: true, hash, encodedBytes, payload };
508
+ }
509
+ nextSeq() {
510
+ this.seq += 1;
511
+ return this.seq;
512
+ }
513
+ buildContent() {
514
+ const manager = this.rivalis.rooms;
515
+ const roomTypes = [...manager.definitions()].sort();
516
+ const rooms = [];
517
+ for (const id of manager.keys()) {
518
+ const room = manager.get(id);
519
+ if (room === null) {
520
+ continue;
521
+ }
522
+ if (typeof room.type !== "string") {
523
+ throw new Error(this.coreSupportError(`room id=(${id}) has no string \`type\``));
524
+ }
525
+ rooms.push({
526
+ id,
527
+ type: room.type,
528
+ connections: room.actorCount,
529
+ origin: this.fleetOrigins.has(id) ? "fleet" : "local"
530
+ });
531
+ }
532
+ rooms.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
533
+ return {
534
+ name: this.name,
535
+ processUid: this.processUid,
536
+ agentVersion: this.agentVersion,
537
+ protocolVersion: this.protocolVersion,
538
+ endpointUrl: this.endpointUrl,
539
+ labels: this.labels,
540
+ capacity: this.capacity,
541
+ autoCreate: this.autoCreate,
542
+ roomTypes,
543
+ rooms,
544
+ status: this.statusValue
545
+ };
546
+ }
547
+ checkSize(bytes, roomCount) {
548
+ const pct = Math.round(bytes / MAX_SNAPSHOT_BYTES * 100);
549
+ if (bytes >= MAX_SNAPSHOT_BYTES * ERROR_RATIO) {
550
+ this.logger.error(
551
+ `fleet snapshot at ${pct}% of the 4 MiB transport frame limit (${bytes} bytes, ${roomCount} rooms). An oversized snapshot is terminated by the transport, which causes a permanent reconnect loop. Remediation: host fewer rooms per instance, raise the orchestrator's WSTransport.maxPayload, or split the fleet across more instances (chunked sync is roadmap \xA716).`
552
+ );
553
+ } else if (bytes >= MAX_SNAPSHOT_BYTES * WARN_RATIO) {
554
+ this.logger.warning(
555
+ `fleet snapshot at ${pct}% of the 4 MiB transport frame limit (${bytes} bytes, ${roomCount} rooms) \u2014 approaching the size guard.`
556
+ );
557
+ }
558
+ }
559
+ /**
560
+ * Feature-detect the §4 core additions and throw an actionable, version-naming
561
+ * error when they are absent — a clean failure at startup instead of
562
+ * `undefined` types in snapshots at runtime. `Room.type` can only be checked
563
+ * against rooms that already exist; with zero rooms the `definitions()` gate
564
+ * is the primary guard (and `buildContent` re-checks each room defensively).
565
+ */
566
+ assertCoreSupport(rivalis) {
567
+ const manager = rivalis?.rooms;
568
+ if (manager === void 0 || manager === null) {
569
+ throw new Error(this.coreSupportError("rivalis.rooms is not available"));
570
+ }
571
+ if (typeof manager.definitions !== "function") {
572
+ throw new Error(this.coreSupportError("rivalis.rooms.definitions() is not available"));
573
+ }
574
+ if (typeof manager.keys !== "function" || typeof manager.get !== "function") {
575
+ throw new Error(this.coreSupportError("rivalis.rooms.keys()/get() are not available"));
576
+ }
577
+ for (const id of manager.keys()) {
578
+ const room = manager.get(id);
579
+ if (room !== null && typeof room.type !== "string") {
580
+ throw new Error(this.coreSupportError(`Room.type is not available (room id=(${id}) has no string \`type\`)`));
581
+ }
582
+ }
583
+ }
584
+ coreSupportError(detail) {
585
+ return `@rivalis/fleet requires @rivalis/core >= ${MIN_CORE_VERSION}: ${detail}. Upgrade @rivalis/core to >= ${MIN_CORE_VERSION} (the \xA74 additions: Room.type, RoomManager.definitions()).`;
586
+ }
587
+ };
588
+
589
+ // src/util/errors.ts
590
+ function describe(error) {
591
+ return error instanceof Error ? error.message : String(error);
592
+ }
593
+
594
+ // src/agent/FleetAgent.ts
595
+ var import_node = require("@rivalis/node");
596
+
597
+ // src/util/scheduler.ts
598
+ var defaultScheduler = {
599
+ setTimeout: (fn, ms) => {
600
+ const t = setTimeout(fn, ms);
601
+ t.unref?.();
602
+ return t;
603
+ },
604
+ clearTimeout: (h) => clearTimeout(h),
605
+ setInterval: (fn, ms) => {
606
+ const t = setInterval(fn, ms);
607
+ t.unref?.();
608
+ return t;
609
+ },
610
+ clearInterval: (h) => clearInterval(h)
611
+ };
612
+
613
+ // src/agent/FleetAgent.ts
614
+ var DEFAULT_BACKOFF_BASE_MS = 500;
615
+ var DEFAULT_BACKOFF_CAP_MS = 3e4;
616
+ var DEFAULT_AWAIT_EMPTY_POLL_MS = 200;
617
+ function defaultCreateClient(url) {
618
+ return new import_node.WSClient(url, {
619
+ ticketSource: "protocol",
620
+ subprotocols: [WS_SUBPROTOCOL]
621
+ });
622
+ }
623
+ var FleetAgent = class extends import_base.Broadcast {
624
+ rivalis;
625
+ logger;
626
+ snapshot;
627
+ url;
628
+ key;
629
+ autoCreate;
630
+ maxRooms;
631
+ connectTimeoutMs;
632
+ client;
633
+ scheduler;
634
+ random;
635
+ backoffBaseMs;
636
+ backoffCapMs;
637
+ awaitEmptyPollMs;
638
+ installSignalHandlers;
639
+ lifecycle = "closed";
640
+ instanceId = null;
641
+ /** Set once `connect()`/reconnects should stop (intentional `disconnect()` or fatal error). */
642
+ closed = false;
643
+ /** Distinguishes an operator-driven close from a transport drop that should reconnect. */
644
+ intentionalClose = false;
645
+ reconnectTimer = null;
646
+ connectDeadline = null;
647
+ reconnectAttempt = 0;
648
+ connectResolve = null;
649
+ connectReject = null;
650
+ /**
651
+ * Pending `drain()` / `undrain()` promises (task 011): each waits for a
652
+ * `fleet/poll` echoing its target status — the orchestrator's acknowledged
653
+ * confirmation that it recorded the agent-owned status flip. No unsolicited frame.
654
+ */
655
+ pendingStatus = [];
656
+ uninstallSignals = null;
657
+ /**
658
+ * Whether the room/transport listeners are currently attached (task 008). The
659
+ * subscription lifecycle tracks the connection lifecycle: attached on construct
660
+ * and on every `connect()`, detached on the terminal paths (`disconnect()`,
661
+ * `failConnect()`) so a discarded/replaced agent stops reacting to room events
662
+ * and the host can drop it (otherwise `RoomManager`'s broadcast retains it).
663
+ */
664
+ listenersAttached = false;
665
+ /**
666
+ * Drop provenance when a room is destroyed so a future id reuse is not mis-stamped
667
+ * (§7). Room create/destroy/define no longer trigger a push — changes surface at
668
+ * the next orchestrator poll (task 011).
669
+ */
670
+ onRoomDestroy = (roomId) => {
671
+ this.snapshot.forgetRoom(roomId);
672
+ };
673
+ constructor(rivalis, options, internals = {}) {
674
+ super();
675
+ this.rivalis = rivalis;
676
+ this.logger = rivalis.logging?.getLogger?.("fleet:agent") ?? NOOP_LOGGER;
677
+ const snapshotOptions = {
678
+ name: options.name,
679
+ endpointUrl: options.endpointUrl,
680
+ agentVersion: packageVersion()
681
+ };
682
+ if (options.labels !== void 0) {
683
+ snapshotOptions.labels = options.labels;
684
+ }
685
+ if (options.capacity !== void 0) {
686
+ snapshotOptions.capacity = options.capacity;
687
+ }
688
+ if (options.autoCreate !== void 0) {
689
+ snapshotOptions.autoCreate = options.autoCreate;
690
+ }
691
+ this.snapshot = new Snapshot(rivalis, snapshotOptions, this.logger);
692
+ this.url = options.url;
693
+ this.key = options.key;
694
+ this.autoCreate = options.autoCreate ?? true;
695
+ this.maxRooms = options.capacity?.maxRooms ?? null;
696
+ this.connectTimeoutMs = options.connectTimeoutMs;
697
+ this.scheduler = internals.scheduler ?? defaultScheduler;
698
+ this.random = internals.random ?? Math.random;
699
+ this.backoffBaseMs = internals.backoff?.baseMs ?? DEFAULT_BACKOFF_BASE_MS;
700
+ this.backoffCapMs = internals.backoff?.capMs ?? DEFAULT_BACKOFF_CAP_MS;
701
+ this.awaitEmptyPollMs = internals.awaitEmptyPollMs ?? DEFAULT_AWAIT_EMPTY_POLL_MS;
702
+ this.installSignalHandlers = internals.installSignalHandlers;
703
+ this.client = (internals.createClient ?? defaultCreateClient)(this.url);
704
+ this.attachListeners();
705
+ }
706
+ /** Lifecycle status (§8): `'connecting' | 'connected' | 'draining' | 'closed'`. */
707
+ get status() {
708
+ return this.lifecycle;
709
+ }
710
+ /** Stable per-process id (§6), constant across reconnects. */
711
+ get processUid() {
712
+ return this.snapshot.processUid;
713
+ }
714
+ /**
715
+ * Connect to the orchestrator; resolves on the first `fleet/hello`. Default:
716
+ * retries forever (backoff per §7) — the promise stays pending while the
717
+ * orchestrator is unreachable. With `connectTimeoutMs` set, rejects after the
718
+ * deadline and transitions to `'closed'` with no background retry loop (§8).
719
+ */
720
+ connect() {
721
+ if (this.lifecycle === "connected" || this.lifecycle === "draining") {
722
+ return Promise.resolve();
723
+ }
724
+ if (this.connectResolve !== null) {
725
+ return new Promise((resolve, reject) => {
726
+ const prevResolve = this.connectResolve;
727
+ const prevReject = this.connectReject;
728
+ this.connectResolve = () => {
729
+ prevResolve();
730
+ resolve();
731
+ };
732
+ this.connectReject = (e) => {
733
+ prevReject(e);
734
+ reject(e);
735
+ };
736
+ });
737
+ }
738
+ this.closed = false;
739
+ this.intentionalClose = false;
740
+ this.lifecycle = "connecting";
741
+ this.reconnectAttempt = 0;
742
+ this.attachListeners();
743
+ return new Promise((resolve, reject) => {
744
+ this.connectResolve = resolve;
745
+ this.connectReject = reject;
746
+ if (this.connectTimeoutMs !== void 0) {
747
+ this.connectDeadline = this.scheduler.setTimeout(
748
+ () => this.failConnect(new Error("fleet:agent connect timeout exceeded")),
749
+ this.connectTimeoutMs
750
+ );
751
+ }
752
+ this.openConnection();
753
+ });
754
+ }
755
+ /**
756
+ * Mark this instance draining (§7, task 011): flips the agent-owned status
757
+ * immediately (so the next `fleet/state` reply carries it) and resolves only when
758
+ * a subsequent `fleet/poll` echoes `status: 'draining'` — the orchestrator's
759
+ * acknowledged confirmation that it recorded the flip. No unsolicited frame.
760
+ */
761
+ drain() {
762
+ return this.requestStatus("draining");
763
+ }
764
+ /** Reverse of `drain()` — restore the instance to `active`; resolves on the poll echo (§7). */
765
+ undrain() {
766
+ return this.requestStatus("active");
767
+ }
768
+ /** Resolve once every local room is empty (zero connections), or reject on `timeoutMs` (§8). */
769
+ awaitEmpty({ timeoutMs } = {}) {
770
+ const empty = () => {
771
+ for (const id of this.rivalis.rooms.keys()) {
772
+ const room = this.rivalis.rooms.get(id);
773
+ if (room !== null && room.actorCount > 0) {
774
+ return false;
775
+ }
776
+ }
777
+ return true;
778
+ };
779
+ if (empty()) {
780
+ return Promise.resolve();
781
+ }
782
+ return new Promise((resolve, reject) => {
783
+ let poll = null;
784
+ let deadline = null;
785
+ const cleanup = () => {
786
+ if (poll !== null) {
787
+ this.scheduler.clearInterval(poll);
788
+ poll = null;
789
+ }
790
+ if (deadline !== null) {
791
+ this.scheduler.clearTimeout(deadline);
792
+ deadline = null;
793
+ }
794
+ };
795
+ poll = this.scheduler.setInterval(() => {
796
+ if (empty()) {
797
+ cleanup();
798
+ resolve();
799
+ }
800
+ }, this.awaitEmptyPollMs);
801
+ if (timeoutMs !== void 0) {
802
+ deadline = this.scheduler.setTimeout(() => {
803
+ cleanup();
804
+ reject(new Error("fleet:agent awaitEmpty timeout exceeded"));
805
+ }, timeoutMs);
806
+ }
807
+ });
808
+ }
809
+ /** Detach cleanly: stop all timers, close the transport, no further reconnects (§8). */
810
+ async disconnect() {
811
+ this.intentionalClose = true;
812
+ this.closed = true;
813
+ this.clearAllTimers();
814
+ this.rejectPendingStatus(new Error("fleet:agent disconnected"));
815
+ try {
816
+ this.client.disconnect();
817
+ } catch (error) {
818
+ this.logger.warning(`fleet:agent transport disconnect error: ${describe(error)}`);
819
+ }
820
+ this.detachListeners();
821
+ this.lifecycle = "closed";
822
+ if (this.connectReject !== null) {
823
+ const reject = this.connectReject;
824
+ this.connectResolve = null;
825
+ this.connectReject = null;
826
+ reject(new Error("fleet:agent disconnected before connect resolved"));
827
+ }
828
+ this.emit("disconnect", Buffer.from("closed"));
829
+ }
830
+ /**
831
+ * Wire `SIGTERM`/`SIGINT` to the graceful sequence (§8):
832
+ * drain → awaitEmpty → disconnect → `rivalis.shutdown()`.
833
+ */
834
+ enableGracefulShutdown({ emptyTimeoutMs = 6e4 } = {}) {
835
+ if (this.uninstallSignals !== null) {
836
+ this.uninstallSignals();
837
+ this.uninstallSignals = null;
838
+ }
839
+ const handler = () => {
840
+ void this.gracefulShutdown(emptyTimeoutMs);
841
+ };
842
+ if (this.installSignalHandlers !== void 0) {
843
+ this.uninstallSignals = this.installSignalHandlers(handler);
844
+ return;
845
+ }
846
+ process.once("SIGTERM", handler);
847
+ process.once("SIGINT", handler);
848
+ this.uninstallSignals = () => {
849
+ process.removeListener("SIGTERM", handler);
850
+ process.removeListener("SIGINT", handler);
851
+ };
852
+ }
853
+ async gracefulShutdown(emptyTimeoutMs) {
854
+ try {
855
+ await this.drain();
856
+ } catch (error) {
857
+ this.logger.warning(`fleet:agent graceful drain failed: ${describe(error)}`);
858
+ }
859
+ try {
860
+ await this.awaitEmpty({ timeoutMs: emptyTimeoutMs });
861
+ } catch (error) {
862
+ this.logger.warning(`fleet:agent graceful awaitEmpty: ${describe(error)}`);
863
+ }
864
+ try {
865
+ await this.disconnect();
866
+ } catch (error) {
867
+ this.logger.warning(`fleet:agent graceful disconnect failed: ${describe(error)}`);
868
+ }
869
+ try {
870
+ await this.rivalis.shutdown();
871
+ } catch (error) {
872
+ this.logger.warning(`fleet:agent graceful rivalis.shutdown failed: ${describe(error)}`);
873
+ }
874
+ }
875
+ // -----------------------------------------------------------------------
876
+ // Transport wiring
877
+ // -----------------------------------------------------------------------
878
+ /**
879
+ * Attach the room-provenance and transport listeners (task 008). Idempotent —
880
+ * re-`connect()` after a `disconnect()` calls this again but it no-ops while
881
+ * already attached, so listeners are never doubled.
882
+ */
883
+ attachListeners() {
884
+ if (this.listenersAttached) {
885
+ return;
886
+ }
887
+ this.wireClient();
888
+ this.subscribeRooms();
889
+ this.listenersAttached = true;
890
+ }
891
+ /**
892
+ * Detach every listener on the terminal paths (task 008): the rooms broadcast
893
+ * stops retaining this agent (no more `forgetRoom` on room destroy) and the
894
+ * transport handlers are removed. Without this a discarded agent leaks — the
895
+ * `RoomManager` broadcast keeps it alive for the host process's lifetime.
896
+ */
897
+ detachListeners() {
898
+ if (!this.listenersAttached) {
899
+ return;
900
+ }
901
+ this.unsubscribeRooms();
902
+ try {
903
+ this.client.removeAllListeners();
904
+ } catch (error) {
905
+ this.logger.warning(`fleet:agent transport removeAllListeners error: ${describe(error)}`);
906
+ }
907
+ this.listenersAttached = false;
908
+ }
909
+ wireClient() {
910
+ this.client.on("client:connect", () => this.guard("transport open", () => this.onTransportOpen()));
911
+ this.client.on("client:disconnect", (reason) => this.guard("transport close", () => this.onTransportClose(reason)));
912
+ this.client.on("client:error", (error) => this.guard("transport error", () => this.onTransportError(error)));
913
+ this.client.on(Topics.hello, (payload) => this.guard("hello", () => this.onHello(payload)));
914
+ this.client.on(Topics.poll, (payload) => this.guard("poll", () => this.onPoll(payload)));
915
+ this.client.on(Topics.cmd, (payload) => this.guard("cmd", () => this.onCmd(payload)));
916
+ }
917
+ subscribeRooms() {
918
+ this.rivalis.rooms.on("destroy", this.onRoomDestroy);
919
+ }
920
+ unsubscribeRooms() {
921
+ this.rivalis.rooms.off("destroy", this.onRoomDestroy);
922
+ }
923
+ openConnection() {
924
+ if (this.closed) {
925
+ return;
926
+ }
927
+ try {
928
+ this.client.connect(this.key);
929
+ } catch (error) {
930
+ this.logger.warning(`fleet:agent connect attempt threw: ${describe(error)}`);
931
+ this.scheduleReconnect();
932
+ }
933
+ }
934
+ onTransportOpen() {
935
+ this.logger.debug?.("fleet:agent transport open \u2014 awaiting fleet/hello");
936
+ }
937
+ onTransportClose(reason) {
938
+ if (this.closed || this.intentionalClose) {
939
+ return;
940
+ }
941
+ this.rejectPendingStatus(new Error("fleet:agent connection lost"));
942
+ this.lifecycle = "connecting";
943
+ this.emit("disconnect", reason);
944
+ this.scheduleReconnect();
945
+ }
946
+ onTransportError(error) {
947
+ this.logger.warning(`fleet:agent transport error: ${describe(error)}`);
948
+ this.emit("error", error);
949
+ }
950
+ scheduleReconnect() {
951
+ if (this.closed || this.intentionalClose || this.reconnectTimer !== null) {
952
+ return;
953
+ }
954
+ const delay = this.backoffDelay();
955
+ this.reconnectAttempt += 1;
956
+ this.reconnectTimer = this.scheduler.setTimeout(() => {
957
+ this.reconnectTimer = null;
958
+ this.openConnection();
959
+ }, delay);
960
+ }
961
+ /** Full-jitter exponential backoff: random in `[0, min(cap, base·2^attempt)]` (§7). */
962
+ backoffDelay() {
963
+ const ceiling = Math.min(this.backoffCapMs, this.backoffBaseMs * Math.pow(2, this.reconnectAttempt));
964
+ return Math.floor(this.random() * ceiling);
965
+ }
966
+ // -----------------------------------------------------------------------
967
+ // Protocol handlers (orch → agent)
968
+ // -----------------------------------------------------------------------
969
+ onHello(raw) {
970
+ let hello;
971
+ try {
972
+ hello = decodeFrame(Topics.hello, toBytes(raw));
973
+ } catch (error) {
974
+ if (error instanceof WireVersionError) {
975
+ this.logger.error(error.message);
976
+ this.emit("error", error);
977
+ this.failConnect(error);
978
+ return;
979
+ }
980
+ this.logger.warning(`fleet:agent failed to decode fleet/hello: ${describe(error)}`);
981
+ return;
982
+ }
983
+ if (hello.protocolVersion !== PROTOCOL_VERSION) {
984
+ const error = new Error(
985
+ `fleet protocol major mismatch: orchestrator=${hello.protocolVersion}, agent=${PROTOCOL_VERSION} \u2014 upgrade so both speak the same major (\xA77)`
986
+ );
987
+ this.logger.error(error.message);
988
+ this.emit("error", error);
989
+ this.failConnect(error);
990
+ return;
991
+ }
992
+ this.instanceId = hello.instanceId;
993
+ this.reconnectAttempt = 0;
994
+ this.clearReconnect();
995
+ this.clearConnectDeadline();
996
+ this.snapshot.resetConnection();
997
+ this.lifecycle = this.snapshot.status === "draining" ? "draining" : "connected";
998
+ if (this.connectResolve !== null) {
999
+ const resolve = this.connectResolve;
1000
+ this.connectResolve = null;
1001
+ this.connectReject = null;
1002
+ resolve();
1003
+ }
1004
+ this.emit("connect", { instanceId: this.instanceId, processUid: this.snapshot.processUid });
1005
+ }
1006
+ /**
1007
+ * Answer an orchestrator `fleet/poll` with a `fleet/state` reply (§7, task 011):
1008
+ * full snapshot when our hash differs from the poll's `knownHash`, hash-only
1009
+ * otherwise. A poll echoing a pending `drain()`/`undrain()` target status also
1010
+ * resolves that promise (the acknowledged confirmation, no unsolicited frame).
1011
+ */
1012
+ onPoll(raw) {
1013
+ const poll = this.decodeInbound(Topics.poll, raw);
1014
+ if (poll === null) {
1015
+ return;
1016
+ }
1017
+ this.resolveStatusOnEcho(poll.status);
1018
+ if (this.client.connected) {
1019
+ this.sendState(this.snapshot.pollReply(poll.reqId, poll.knownHash));
1020
+ }
1021
+ }
1022
+ onCmd(raw) {
1023
+ const cmd = this.decodeInbound(Topics.cmd, raw);
1024
+ if (cmd === null) {
1025
+ return;
1026
+ }
1027
+ this.emit("command", cmd);
1028
+ switch (cmd.op) {
1029
+ case "create":
1030
+ return this.execCreate(cmd);
1031
+ case "destroy":
1032
+ return this.execDestroy(cmd);
1033
+ case "drain":
1034
+ return this.execStatusCmd(cmd, "draining");
1035
+ case "undrain":
1036
+ return this.execStatusCmd(cmd, "active");
1037
+ default:
1038
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: `unknown op: ${String(cmd.op)}` });
1039
+ }
1040
+ }
1041
+ execCreate(cmd) {
1042
+ if (!this.autoCreate) {
1043
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: "autoCreate is disabled on this instance" });
1044
+ return;
1045
+ }
1046
+ if (typeof cmd.roomType !== "string") {
1047
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: "create requires roomType" });
1048
+ return;
1049
+ }
1050
+ if (this.maxRooms !== null && this.rivalis.rooms.count >= this.maxRooms) {
1051
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: `capacity exhausted: maxRooms=${this.maxRooms}` });
1052
+ return;
1053
+ }
1054
+ if (typeof cmd.roomId === "string" && this.rivalis.rooms.get(cmd.roomId) !== null) {
1055
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, exists: true, error: "room id already exists" });
1056
+ return;
1057
+ }
1058
+ try {
1059
+ const room = this.rivalis.rooms.create(cmd.roomType, cmd.roomId ?? null);
1060
+ this.snapshot.markFleetOrigin(room.id);
1061
+ this.sendAck({ cmdId: cmd.cmdId, ok: true, room: { id: room.id, type: room.type } });
1062
+ } catch (error) {
1063
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: describe(error) });
1064
+ }
1065
+ }
1066
+ execDestroy(cmd) {
1067
+ if (typeof cmd.roomId !== "string") {
1068
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: "destroy requires roomId" });
1069
+ return;
1070
+ }
1071
+ if (this.rivalis.rooms.get(cmd.roomId) === null) {
1072
+ this.sendAck({ cmdId: cmd.cmdId, ok: true, alreadyGone: true });
1073
+ return;
1074
+ }
1075
+ try {
1076
+ this.rivalis.rooms.destroy(cmd.roomId);
1077
+ this.snapshot.forgetRoom(cmd.roomId);
1078
+ this.sendAck({ cmdId: cmd.cmdId, ok: true });
1079
+ } catch (error) {
1080
+ this.sendAck({ cmdId: cmd.cmdId, ok: false, error: describe(error) });
1081
+ }
1082
+ }
1083
+ execStatusCmd(cmd, status) {
1084
+ this.snapshot.setStatus(status);
1085
+ this.lifecycle = status === "draining" ? "draining" : "connected";
1086
+ this.sendAck({ cmdId: cmd.cmdId, ok: true });
1087
+ }
1088
+ // -----------------------------------------------------------------------
1089
+ // Outbound (agent → orch) — replies only (task 011)
1090
+ // -----------------------------------------------------------------------
1091
+ requestStatus(target) {
1092
+ this.snapshot.setStatus(target);
1093
+ this.lifecycle = target === "draining" ? "draining" : "connected";
1094
+ return new Promise((resolve, reject) => {
1095
+ this.pendingStatus.push({ target, resolve, reject });
1096
+ });
1097
+ }
1098
+ /** Resolve every pending drain()/undrain() whose target matches the poll-echoed status. */
1099
+ resolveStatusOnEcho(echoed) {
1100
+ if (this.pendingStatus.length === 0) {
1101
+ return;
1102
+ }
1103
+ const remaining = [];
1104
+ for (const pending of this.pendingStatus) {
1105
+ if (pending.target === echoed) {
1106
+ pending.resolve();
1107
+ } else {
1108
+ remaining.push(pending);
1109
+ }
1110
+ }
1111
+ this.pendingStatus = remaining;
1112
+ }
1113
+ sendState(frame) {
1114
+ this.send(Topics.state, frame.payload);
1115
+ }
1116
+ sendAck(ack) {
1117
+ this.send(Topics.ack, ack);
1118
+ }
1119
+ send(topic, payload) {
1120
+ try {
1121
+ this.client.send(topic, encodeFrame(topic, payload));
1122
+ } catch (error) {
1123
+ this.logger.warning(`fleet:agent send failed topic=${topic}: ${describe(error)}`);
1124
+ }
1125
+ }
1126
+ // -----------------------------------------------------------------------
1127
+ // Teardown helpers
1128
+ // -----------------------------------------------------------------------
1129
+ /** Fatal connect failure (timeout or protocol mismatch): reject, close, stop retrying (§8). */
1130
+ failConnect(error) {
1131
+ this.closed = true;
1132
+ this.intentionalClose = true;
1133
+ this.clearAllTimers();
1134
+ this.rejectPendingStatus(error);
1135
+ try {
1136
+ this.client.disconnect();
1137
+ } catch (disconnectError) {
1138
+ this.logger.warning(`fleet:agent disconnect during failConnect: ${describe(disconnectError)}`);
1139
+ }
1140
+ this.detachListeners();
1141
+ this.lifecycle = "closed";
1142
+ if (this.connectReject !== null) {
1143
+ const reject = this.connectReject;
1144
+ this.connectResolve = null;
1145
+ this.connectReject = null;
1146
+ reject(error);
1147
+ }
1148
+ }
1149
+ rejectPendingStatus(error) {
1150
+ const pending = this.pendingStatus;
1151
+ this.pendingStatus = [];
1152
+ for (const entry of pending) {
1153
+ entry.reject(error);
1154
+ }
1155
+ }
1156
+ clearReconnect() {
1157
+ if (this.reconnectTimer !== null) {
1158
+ this.scheduler.clearTimeout(this.reconnectTimer);
1159
+ this.reconnectTimer = null;
1160
+ }
1161
+ }
1162
+ clearConnectDeadline() {
1163
+ if (this.connectDeadline !== null) {
1164
+ this.scheduler.clearTimeout(this.connectDeadline);
1165
+ this.connectDeadline = null;
1166
+ }
1167
+ }
1168
+ clearAllTimers() {
1169
+ this.clearReconnect();
1170
+ this.clearConnectDeadline();
1171
+ if (this.uninstallSignals !== null) {
1172
+ this.uninstallSignals();
1173
+ this.uninstallSignals = null;
1174
+ }
1175
+ }
1176
+ /** Run a transport/timer callback, swallowing+logging any throw (§8 host-safety contract). */
1177
+ guard(label, fn) {
1178
+ try {
1179
+ fn();
1180
+ } catch (error) {
1181
+ this.logger.error(`fleet:agent ${label} handler error: ${describe(error)}`);
1182
+ this.emit("error", error instanceof Error ? error : new Error(describe(error)));
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Decode an inbound binary frame for a non-hello topic (§7, task 005). Logs +
1187
+ * returns `null` on any failure — never throws into the host (§8). A
1188
+ * protocol-incompatible frame is logged as a version mismatch; a
1189
+ * malformed/truncated frame is logged and dropped. (`fleet/hello` handles a
1190
+ * version mismatch itself — a loud connect failure — so it does not use this.)
1191
+ */
1192
+ decodeInbound(topic, raw) {
1193
+ try {
1194
+ return decodeFrame(topic, toBytes(raw));
1195
+ } catch (error) {
1196
+ if (error instanceof WireVersionError) {
1197
+ this.logger.warning(`fleet:agent dropped protocol-incompatible ${topic} frame (peer major=${error.theirVersion}, agent=${PROTOCOL_VERSION})`);
1198
+ } else {
1199
+ this.logger.warning(`fleet:agent failed to decode ${topic}: ${describe(error)}`);
1200
+ }
1201
+ return null;
1202
+ }
1203
+ }
1204
+ };
1205
+ function toBytes(raw) {
1206
+ if (raw instanceof Uint8Array) {
1207
+ return raw;
1208
+ }
1209
+ if (typeof raw === "string") {
1210
+ return Buffer.from(raw, "utf-8");
1211
+ }
1212
+ return new Uint8Array(0);
1213
+ }
1214
+ // Annotate the CommonJS export names for ESM import in node:
1215
+ 0 && (module.exports = {
1216
+ FleetAgent
1217
+ });