@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.
- package/README.md +417 -0
- package/bin/rivalis-fleet.js +10 -0
- package/lib/AgentAuthenticator.js +56 -0
- package/lib/CommandEngine.js +258 -0
- package/lib/EventReconciler.js +90 -0
- package/lib/FleetAgent.js +1217 -0
- package/lib/FleetControl.js +139 -0
- package/lib/FleetState.js +865 -0
- package/lib/Orchestrator.js +2834 -0
- package/lib/Poller.js +113 -0
- package/lib/Snapshot.js +471 -0
- package/lib/canonical.js +82 -0
- package/lib/cli.js +3076 -0
- package/lib/domain.js +97 -0
- package/lib/env.js +99 -0
- package/lib/main.d.ts +592 -0
- package/lib/main.js +3618 -0
- package/lib/module.js +3582 -0
- package/lib/routers.js +598 -0
- package/lib/wire.js +507 -0
- package/package.json +78 -0
|
@@ -0,0 +1,865 @@
|
|
|
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/orchestrator/FleetState.ts
|
|
21
|
+
var FleetState_exports = {};
|
|
22
|
+
__export(FleetState_exports, {
|
|
23
|
+
FleetError: () => FleetError,
|
|
24
|
+
FleetState: () => FleetState
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(FleetState_exports);
|
|
27
|
+
var import_node_crypto2 = require("crypto");
|
|
28
|
+
|
|
29
|
+
// src/util/canonical.ts
|
|
30
|
+
var import_node_crypto = require("crypto");
|
|
31
|
+
function canonicalize(value) {
|
|
32
|
+
return encode(value);
|
|
33
|
+
}
|
|
34
|
+
function encode(value) {
|
|
35
|
+
if (value === null) {
|
|
36
|
+
return "null";
|
|
37
|
+
}
|
|
38
|
+
const type = typeof value;
|
|
39
|
+
if (type === "string") {
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
42
|
+
if (type === "number") {
|
|
43
|
+
return Number.isFinite(value) ? String(value) : "null";
|
|
44
|
+
}
|
|
45
|
+
if (type === "boolean") {
|
|
46
|
+
return value ? "true" : "false";
|
|
47
|
+
}
|
|
48
|
+
if (type === "bigint") {
|
|
49
|
+
return value.toString();
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
const items = value.map((item) => encodeArrayItem(item));
|
|
53
|
+
return "[" + items.join(",") + "]";
|
|
54
|
+
}
|
|
55
|
+
if (type === "object") {
|
|
56
|
+
const obj = value;
|
|
57
|
+
const keys = Object.keys(obj).sort();
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const child = obj[key];
|
|
61
|
+
if (isSkippable(child)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
parts.push(JSON.stringify(key) + ":" + encode(child));
|
|
65
|
+
}
|
|
66
|
+
return "{" + parts.join(",") + "}";
|
|
67
|
+
}
|
|
68
|
+
return "null";
|
|
69
|
+
}
|
|
70
|
+
function encodeArrayItem(item) {
|
|
71
|
+
return isSkippable(item) ? "null" : encode(item);
|
|
72
|
+
}
|
|
73
|
+
function isSkippable(value) {
|
|
74
|
+
const type = typeof value;
|
|
75
|
+
return value === void 0 || type === "function" || type === "symbol";
|
|
76
|
+
}
|
|
77
|
+
function hash64(value) {
|
|
78
|
+
const digest = (0, import_node_crypto.createHash)("sha256").update(canonicalize(value)).digest();
|
|
79
|
+
return digest.subarray(0, 8).toString("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/util/logger.ts
|
|
83
|
+
var NOOP_LOGGER = {
|
|
84
|
+
error() {
|
|
85
|
+
},
|
|
86
|
+
warning() {
|
|
87
|
+
},
|
|
88
|
+
info() {
|
|
89
|
+
},
|
|
90
|
+
debug() {
|
|
91
|
+
},
|
|
92
|
+
verbose() {
|
|
93
|
+
},
|
|
94
|
+
log() {
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/domain/roomId.ts
|
|
99
|
+
var ROOM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
100
|
+
var NAMESPACE_SEPARATOR = "~";
|
|
101
|
+
var ROOM_ID_CHAR = /[A-Za-z0-9_-]/;
|
|
102
|
+
function isValidRoomId(id) {
|
|
103
|
+
return ROOM_ID_PATTERN.test(id);
|
|
104
|
+
}
|
|
105
|
+
function encodeRoomId(id) {
|
|
106
|
+
if (isValidRoomId(id)) {
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
let out = "";
|
|
110
|
+
for (const byte of Buffer.from(id, "utf8")) {
|
|
111
|
+
const ch = String.fromCharCode(byte);
|
|
112
|
+
out += ROOM_ID_CHAR.test(ch) ? ch : "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
function namespaceRoomId(processUid, encodedRoomId) {
|
|
117
|
+
return processUid + NAMESPACE_SEPARATOR + encodedRoomId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/domain/roomCreate.ts
|
|
121
|
+
var roomCreateSchema = {
|
|
122
|
+
type: { type: "string", required: true, min: 1 },
|
|
123
|
+
roomId: { type: "string", pattern: ROOM_ID_PATTERN.source },
|
|
124
|
+
placement: { type: "object" }
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/domain/errors.ts
|
|
128
|
+
var import_node = require("@toolcase/node");
|
|
129
|
+
var CODE_TO_STATUS = {
|
|
130
|
+
VALIDATION: 400,
|
|
131
|
+
UNAUTHORIZED: 401,
|
|
132
|
+
INSTANCE_NOT_FOUND: 404,
|
|
133
|
+
ROOM_NOT_FOUND: 404,
|
|
134
|
+
NO_CANDIDATE: 409,
|
|
135
|
+
ROOM_EXISTS: 409,
|
|
136
|
+
INSTANCE_DRAINING: 409,
|
|
137
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
138
|
+
INSTANCE_BUSY: 429,
|
|
139
|
+
AUTH_THROTTLED: 429,
|
|
140
|
+
SSE_LIMIT: 429,
|
|
141
|
+
COMMAND_FAILED: 502,
|
|
142
|
+
INSTANCE_DISCONNECTED: 502,
|
|
143
|
+
COMMAND_TIMEOUT: 504
|
|
144
|
+
};
|
|
145
|
+
var FleetError = class extends import_node.EndpointError {
|
|
146
|
+
constructor(code, message) {
|
|
147
|
+
super(CODE_TO_STATUS[code], code, message);
|
|
148
|
+
this.name = "FleetError";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/orchestrator/FleetState.ts
|
|
153
|
+
var FleetState = class {
|
|
154
|
+
logger;
|
|
155
|
+
random;
|
|
156
|
+
/** Read model, keyed by connection-scoped `instanceId` (a reconnect is a new key). */
|
|
157
|
+
records = /* @__PURE__ */ new Map();
|
|
158
|
+
/** Live capacity reservations: token → instanceId. */
|
|
159
|
+
reservations = /* @__PURE__ */ new Map();
|
|
160
|
+
/** Reserved room slots per instance, derived from `reservations` for O(1) headroom checks. */
|
|
161
|
+
reservedByInstance = /* @__PURE__ */ new Map();
|
|
162
|
+
reservationSeq = 0;
|
|
163
|
+
/** Room ids reserved by in-flight creates (§11) — held until ack/timeout/rejection. */
|
|
164
|
+
reservedRoomIds = /* @__PURE__ */ new Set();
|
|
165
|
+
/**
|
|
166
|
+
* Room ids whose create has settled (acked OK or timed out) but whose room has
|
|
167
|
+
* not yet appeared in an applied snapshot from the owning instance (task 003).
|
|
168
|
+
* The id reservation is held *past* the command settle: releasing it on ack/timeout
|
|
169
|
+
* would free the id for up to one `heartbeatMs` before the room reconciles into the
|
|
170
|
+
* read model — the window in which the §10 retry-after-504 (or an immediate
|
|
171
|
+
* re-create) re-reserves the id and double-creates on a *different* instance, the
|
|
172
|
+
* exact cross-instance duplicate §11 exists to prevent. Keyed `roomId → owning
|
|
173
|
+
* instanceId`; cleared when the owning instance's next snapshot/poll reconciles
|
|
174
|
+
* (the read model takes over) or it is evicted. Held entries count toward both
|
|
175
|
+
* id-uniqueness ({@link isRoomIdTaken}) and `maxRooms` headroom ({@link hasHeadroom}).
|
|
176
|
+
*/
|
|
177
|
+
pendingRoomIds = /* @__PURE__ */ new Map();
|
|
178
|
+
/** Pending-visibility room count per instance, for O(1) `maxRooms` headroom (task 003). */
|
|
179
|
+
pendingByInstance = /* @__PURE__ */ new Map();
|
|
180
|
+
/** Monotonic join counter — assigns each instance its tie-break order (§11). */
|
|
181
|
+
joinCounter = 0;
|
|
182
|
+
/**
|
|
183
|
+
* Instances the orchestrator has marked stale (wedged: connected but silent
|
|
184
|
+
* past 2×heartbeat — §7). Liveness bookkeeping, not snapshot-derived semantic
|
|
185
|
+
* state: it is **excluded from `stateHash`** (like `lastSyncAt`) but **excludes
|
|
186
|
+
* the instance from auto-placement**, so a wedged-yet-least-loaded node cannot
|
|
187
|
+
* keep winning placement until it is evicted at 3×heartbeat.
|
|
188
|
+
*/
|
|
189
|
+
staleInstances = /* @__PURE__ */ new Set();
|
|
190
|
+
/**
|
|
191
|
+
* Agent-acked-but-not-yet-snapshotted status, kept for PLACEMENT candidacy only
|
|
192
|
+
* (task 004). On a `drain`/`undrain` ack the agent has already flipped its
|
|
193
|
+
* agent-owned status (§7), but the read-model `status` only catches up at the
|
|
194
|
+
* instance's next poll reply — up to one `heartbeatMs` later. Until then
|
|
195
|
+
* `place()` would still see the stale value and keep selecting a just-drained
|
|
196
|
+
* node (or keep excluding a just-undrained one). Like {@link staleInstances},
|
|
197
|
+
* this is a placement-only override: it is **excluded from `stateHash`** and the
|
|
198
|
+
* read model, and it **never writes the agent-owned `status`** (§7 status
|
|
199
|
+
* ownership stays intact) — it only shifts what {@link place} treats as the
|
|
200
|
+
* instance's effective status. Keyed `instanceId → effective status`; cleared the
|
|
201
|
+
* moment a snapshot/poll reconciles the matching status into the read model (the
|
|
202
|
+
* override has done its job) or the instance is removed.
|
|
203
|
+
*/
|
|
204
|
+
pendingStatus = /* @__PURE__ */ new Map();
|
|
205
|
+
/**
|
|
206
|
+
* Memoized id-resolution pass ({@link resolve}) and {@link computeStateHash}
|
|
207
|
+
* result. The resolution is O(rooms) — flatten every room, group by base id,
|
|
208
|
+
* sort the collision buckets, build the public-id index, clone every instance —
|
|
209
|
+
* and was previously rebuilt on **every** read-model query (`stats`/`instances`/
|
|
210
|
+
* `rooms`/`getRoom`/…); one `GET /v1/stats` alone resolves ≥2×. Both are now
|
|
211
|
+
* computed lazily and held until the next SEMANTIC mutation.
|
|
212
|
+
*
|
|
213
|
+
* Invalidated by exactly the two mutations that change semantic state:
|
|
214
|
+
* {@link applySnapshot} (when it actually applies) and {@link removeInstance}.
|
|
215
|
+
* {@link touch} (advances `lastSyncAt`) and {@link setStale} are non-semantic —
|
|
216
|
+
* both are excluded from `stateHash` (§6) and from the resolution — so neither
|
|
217
|
+
* invalidates; `touch` instead keeps the cached `InstanceInfo.lastSyncAt` in step
|
|
218
|
+
* in place (see below). `null` ⇒ dirty, rebuild on next read.
|
|
219
|
+
*
|
|
220
|
+
* Read-only contract: the cached `InstanceInfo` / `RoomInfo` objects are now
|
|
221
|
+
* SHARED across callers and across queries (a query no longer clones afresh).
|
|
222
|
+
* They must be treated as immutable by consumers; the only sanctioned in-place
|
|
223
|
+
* write is `touch`'s `lastSyncAt` refresh, which is liveness bookkeeping outside
|
|
224
|
+
* both the resolution and the hash. The `instances`/`rooms`/`findRooms` getters
|
|
225
|
+
* still hand back a fresh array container so a caller's `sort()`/`push()` cannot
|
|
226
|
+
* corrupt the memo — only the element objects are shared.
|
|
227
|
+
*/
|
|
228
|
+
resolvedView = null;
|
|
229
|
+
cachedStateHash = null;
|
|
230
|
+
constructor(options = {}) {
|
|
231
|
+
this.logger = options.logger ?? NOOP_LOGGER;
|
|
232
|
+
this.random = options.random ?? Math.random;
|
|
233
|
+
}
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
// Read model mutation (driven by the fleet room — task 009)
|
|
236
|
+
// -----------------------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* Apply a validated full `fleet/state` snapshot to the read model. Returns `true`
|
|
239
|
+
* when applied, `false` when dropped as an out-of-order/duplicate frame.
|
|
240
|
+
*
|
|
241
|
+
* `seq` is per-connection monotonic (§7); a frame whose `seq` does not
|
|
242
|
+
* strictly exceed the last applied one is **dropped, never applied** — this
|
|
243
|
+
* turns a hypothetical agent-side send-queue bug into a lost frame instead of
|
|
244
|
+
* read-model corruption (§7, §14). Field validation (§13) happens upstream;
|
|
245
|
+
* this method trusts the payload's shape.
|
|
246
|
+
*/
|
|
247
|
+
applySnapshot(instanceId, payload, lastSyncAt) {
|
|
248
|
+
const existing = this.records.get(instanceId);
|
|
249
|
+
if (existing !== void 0 && payload.seq <= existing.lastSeq) {
|
|
250
|
+
this.logger.warning(
|
|
251
|
+
`fleet: dropped out-of-order snapshot from instance=${instanceId} (seq=${payload.seq} <= last=${existing.lastSeq})`
|
|
252
|
+
);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const info = buildInstanceInfo(instanceId, payload, lastSyncAt);
|
|
256
|
+
const joinSeq = existing?.joinSeq ?? ++this.joinCounter;
|
|
257
|
+
this.records.set(instanceId, { info, lastSeq: payload.seq, lastHash: payload.hash, joinSeq });
|
|
258
|
+
this.invalidate();
|
|
259
|
+
this.clearPendingVisibility(instanceId);
|
|
260
|
+
this.reconcilePendingStatus(instanceId);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Bump an instance's `lastSyncAt` without touching semantic state (used on
|
|
265
|
+
* a hash-only `fleet/state` reply). Deliberately does **not** affect `stateHash` — liveness
|
|
266
|
+
* bookkeeping is excluded so a quiet fleet still produces ETag 304s (§6, §10).
|
|
267
|
+
*/
|
|
268
|
+
touch(instanceId, lastSyncAt) {
|
|
269
|
+
const record = this.records.get(instanceId);
|
|
270
|
+
if (record === void 0) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
record.info.lastSyncAt = lastSyncAt;
|
|
274
|
+
this.clearPendingVisibility(instanceId);
|
|
275
|
+
this.reconcilePendingStatus(instanceId);
|
|
276
|
+
const cached = this.resolvedView?.byId.get(instanceId);
|
|
277
|
+
if (cached !== void 0) {
|
|
278
|
+
cached.lastSyncAt = lastSyncAt;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Remove an instance from the read model (socket close or eviction, §7). */
|
|
282
|
+
removeInstance(instanceId) {
|
|
283
|
+
const record = this.records.get(instanceId);
|
|
284
|
+
if (record === void 0) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
this.records.delete(instanceId);
|
|
288
|
+
this.staleInstances.delete(instanceId);
|
|
289
|
+
this.pendingStatus.delete(instanceId);
|
|
290
|
+
this.clearPendingVisibility(instanceId);
|
|
291
|
+
this.invalidate();
|
|
292
|
+
return record.info;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Mark/unmark an instance stale (orchestrator liveness — §7). A stale instance
|
|
296
|
+
* stays in the read model and the `stateHash` (so dashboards keep seeing it
|
|
297
|
+
* until eviction) but is dropped from auto-placement candidacy. Cleared
|
|
298
|
+
* automatically on {@link removeInstance}.
|
|
299
|
+
*/
|
|
300
|
+
setStale(instanceId, stale) {
|
|
301
|
+
if (stale) {
|
|
302
|
+
this.staleInstances.add(instanceId);
|
|
303
|
+
} else {
|
|
304
|
+
this.staleInstances.delete(instanceId);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Record an agent-acked-but-not-yet-snapshotted status for PLACEMENT only
|
|
309
|
+
* (task 004) — called on a `drain`/`undrain` ack, where the agent has already
|
|
310
|
+
* flipped its status (§7) but the read model lags by up to one poll. `place()`
|
|
311
|
+
* reads this through {@link effectiveStatus} so candidacy converges at ack time
|
|
312
|
+
* (`drain` excludes the node, `undrain` re-includes it) instead of one poll
|
|
313
|
+
* interval later. Never writes the agent-owned read-model `status` and is absent
|
|
314
|
+
* from `stateHash`, so §7 status ownership and the §10 ETag are untouched. The
|
|
315
|
+
* override clears itself once a snapshot reconciles the matching status (see
|
|
316
|
+
* {@link reconcilePendingStatus}). No-op on an unknown instance — there is nothing
|
|
317
|
+
* to place onto, and a later join starts clean.
|
|
318
|
+
*/
|
|
319
|
+
setPendingStatus(instanceId, status) {
|
|
320
|
+
if (!this.records.has(instanceId)) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.pendingStatus.set(instanceId, status);
|
|
324
|
+
}
|
|
325
|
+
/** Hash of the last applied snapshot for an instance (sent as the poll `knownHash` for dedup, §7). */
|
|
326
|
+
lastHashOf(instanceId) {
|
|
327
|
+
return this.records.get(instanceId)?.lastHash ?? null;
|
|
328
|
+
}
|
|
329
|
+
// -----------------------------------------------------------------------
|
|
330
|
+
// Read model queries (§9)
|
|
331
|
+
// -----------------------------------------------------------------------
|
|
332
|
+
get instances() {
|
|
333
|
+
return [...this.resolve().instances];
|
|
334
|
+
}
|
|
335
|
+
get rooms() {
|
|
336
|
+
const rooms = [];
|
|
337
|
+
for (const instance of this.resolve().instances) {
|
|
338
|
+
rooms.push(...instance.rooms);
|
|
339
|
+
}
|
|
340
|
+
return rooms;
|
|
341
|
+
}
|
|
342
|
+
get stats() {
|
|
343
|
+
const instances = this.resolve().instances;
|
|
344
|
+
let connections = 0;
|
|
345
|
+
let rooms = 0;
|
|
346
|
+
const roomTypes = /* @__PURE__ */ new Set();
|
|
347
|
+
for (const instance of instances) {
|
|
348
|
+
connections += instance.connections;
|
|
349
|
+
rooms += instance.rooms.length;
|
|
350
|
+
for (const type of instance.roomTypes) {
|
|
351
|
+
roomTypes.add(type);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (this.cachedStateHash === null) {
|
|
355
|
+
this.cachedStateHash = this.computeStateHash(instances);
|
|
356
|
+
this.logger.debug("fleet: computed semantic state hash");
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
instances: instances.length,
|
|
360
|
+
rooms,
|
|
361
|
+
connections,
|
|
362
|
+
roomTypes: [...roomTypes].sort(),
|
|
363
|
+
stateHash: this.cachedStateHash
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
getInstance(id) {
|
|
367
|
+
return this.resolve().byId.get(id) ?? null;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Resolve an instance by its stable `processUid` (§6 pinning) to the **most
|
|
371
|
+
* recent connection** — the record with the highest `joinSeq` (task 011). During
|
|
372
|
+
* a reconnect overlap two records share a `processUid` (the live new connection
|
|
373
|
+
* plus the old wedged one not yet evicted, up to 3 poll intervals); `processUid`
|
|
374
|
+
* is the documented *stable* handle across reconnects, so it must resolve to the
|
|
375
|
+
* live connection. First-match (map insertion order) would pick the OLDEST — the
|
|
376
|
+
* dead connection in exactly the scenario `processUid` pinning exists for.
|
|
377
|
+
*/
|
|
378
|
+
getInstanceByProcessUid(processUid) {
|
|
379
|
+
const record = this.latestRecordByProcessUid(processUid);
|
|
380
|
+
return record === null ? null : this.resolve().byId.get(record.info.id) ?? null;
|
|
381
|
+
}
|
|
382
|
+
/** Look up a room by its PUBLIC id (canonical, namespaced, or percent-encoded — §11). */
|
|
383
|
+
getRoom(roomId) {
|
|
384
|
+
return this.resolve().byPublicId.get(roomId)?.room ?? null;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Map a public room id (possibly namespaced or percent-encoded) back to its
|
|
388
|
+
* owning instance and the RAW id the agent knows it by — what a `fleet/cmd`
|
|
389
|
+
* `destroy` must carry, since the agent never sees the public id (§11). Returns
|
|
390
|
+
* null when no room has that public id.
|
|
391
|
+
*/
|
|
392
|
+
resolveRoom(roomId) {
|
|
393
|
+
const locator = this.resolve().byPublicId.get(roomId);
|
|
394
|
+
if (locator === void 0) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return { instanceId: locator.instanceId, rawRoomId: locator.rawRoomId };
|
|
398
|
+
}
|
|
399
|
+
/** Rooms cluster-wide, filtered by type / owning instance / owning-instance labels (§9). */
|
|
400
|
+
findRooms(filter = {}) {
|
|
401
|
+
const result = [];
|
|
402
|
+
for (const instance of this.resolve().instances) {
|
|
403
|
+
if (filter.instanceId !== void 0 && instance.id !== filter.instanceId) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (filter.labels !== void 0 && !matchesLabels(instance.labels, filter.labels)) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
for (const room of instance.rooms) {
|
|
410
|
+
if (filter.type !== void 0 && room.type !== filter.type) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
result.push(room);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
// -----------------------------------------------------------------------
|
|
419
|
+
// Placement (§9)
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
421
|
+
/**
|
|
422
|
+
* Select an instance for a new room and reserve a capacity slot on it,
|
|
423
|
+
* atomically (§9). Throws a coded {@link FleetError} on validation /
|
|
424
|
+
* no-candidate / draining-pin. The reservation must be released by the
|
|
425
|
+
* caller on ack, timeout, or rejection.
|
|
426
|
+
*/
|
|
427
|
+
place(request) {
|
|
428
|
+
if (request.instanceId !== void 0 && request.processUid !== void 0) {
|
|
429
|
+
throw new FleetError("VALIDATION", "specify at most one of placement.instanceId or placement.processUid");
|
|
430
|
+
}
|
|
431
|
+
if (request.instanceId !== void 0 || request.processUid !== void 0) {
|
|
432
|
+
const instance2 = request.instanceId !== void 0 ? this.rawInstanceById(request.instanceId) : this.rawInstanceByProcessUid(request.processUid);
|
|
433
|
+
if (instance2 === null) {
|
|
434
|
+
const which = request.instanceId !== void 0 ? `instanceId=${request.instanceId}` : `processUid=${request.processUid}`;
|
|
435
|
+
throw new FleetError("INSTANCE_NOT_FOUND", `no instance matches ${which}`);
|
|
436
|
+
}
|
|
437
|
+
if (this.effectiveStatus(instance2) === "draining" && request.force !== true) {
|
|
438
|
+
throw new FleetError(
|
|
439
|
+
"INSTANCE_DRAINING",
|
|
440
|
+
`instance ${instance2.id} is draining; pin requires force: true`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (this.staleInstances.has(instance2.id) && request.force !== true) {
|
|
444
|
+
throw new FleetError(
|
|
445
|
+
"INSTANCE_DISCONNECTED",
|
|
446
|
+
`instance ${instance2.id} is stale (missed poll replies); pin requires force: true`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (!instance2.autoCreate) {
|
|
450
|
+
throw new FleetError("NO_CANDIDATE", `instance ${instance2.id} has autoCreate disabled`);
|
|
451
|
+
}
|
|
452
|
+
return { instance: instance2, reservation: this.reserve(instance2.id) };
|
|
453
|
+
}
|
|
454
|
+
const candidates = this.rawInstances().filter(
|
|
455
|
+
(instance2) => this.effectiveStatus(instance2) === "active" && !this.staleInstances.has(instance2.id) && instance2.autoCreate === true && instance2.roomTypes.includes(request.type) && (request.labels === void 0 || matchesLabels(instance2.labels, request.labels)) && this.hasHeadroom(instance2)
|
|
456
|
+
);
|
|
457
|
+
if (candidates.length === 0) {
|
|
458
|
+
throw new FleetError("NO_CANDIDATE", `no active instance can host room type "${request.type}"`);
|
|
459
|
+
}
|
|
460
|
+
const instance = this.pick(candidates, request.strategy ?? "least-loaded");
|
|
461
|
+
return { instance, reservation: this.reserve(instance.id) };
|
|
462
|
+
}
|
|
463
|
+
/** Release a capacity reservation (on ack, timeout, or rejection — §9). Idempotent. */
|
|
464
|
+
release(reservation) {
|
|
465
|
+
if (!this.reservations.delete(reservation.id)) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const count = this.reservedByInstance.get(reservation.instanceId) ?? 0;
|
|
469
|
+
if (count <= 1) {
|
|
470
|
+
this.reservedByInstance.delete(reservation.instanceId);
|
|
471
|
+
} else {
|
|
472
|
+
this.reservedByInstance.set(reservation.instanceId, count - 1);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Reserved (in-flight) room slots currently held against an instance. */
|
|
476
|
+
reservedRooms(instanceId) {
|
|
477
|
+
return this.reservedByInstance.get(instanceId) ?? 0;
|
|
478
|
+
}
|
|
479
|
+
// -----------------------------------------------------------------------
|
|
480
|
+
// Room-id uniqueness & reservation (§11)
|
|
481
|
+
// -----------------------------------------------------------------------
|
|
482
|
+
/**
|
|
483
|
+
* Validate, uniqueness-check, and reserve a room id for an in-flight create
|
|
484
|
+
* (§11). When `roomId` is omitted a collision-free `r_<id>` within the charset
|
|
485
|
+
* is generated. Throws {@link FleetError} `VALIDATION` (explicit id outside the
|
|
486
|
+
* charset) or `ROOM_EXISTS` (id already in the fleet or already reserved). The
|
|
487
|
+
* reservation closes the race window: two concurrent creates with the same
|
|
488
|
+
* explicit id cannot both pass — exactly one reserves, the rest fail fast. The
|
|
489
|
+
* caller must `releaseRoomId` on ack, timeout, or rejection.
|
|
490
|
+
*/
|
|
491
|
+
reserveRoomId(roomId) {
|
|
492
|
+
if (roomId === void 0) {
|
|
493
|
+
const generated = this.generateFreeRoomId();
|
|
494
|
+
this.reservedRoomIds.add(generated);
|
|
495
|
+
return { roomId: generated };
|
|
496
|
+
}
|
|
497
|
+
if (!isValidRoomId(roomId)) {
|
|
498
|
+
throw new FleetError("VALIDATION", `roomId "${roomId}" must match ${ROOM_ID_PATTERN.source}`);
|
|
499
|
+
}
|
|
500
|
+
if (this.isRoomIdTaken(roomId)) {
|
|
501
|
+
throw new FleetError("ROOM_EXISTS", `room id "${roomId}" already exists or is reserved`);
|
|
502
|
+
}
|
|
503
|
+
this.reservedRoomIds.add(roomId);
|
|
504
|
+
return { roomId };
|
|
505
|
+
}
|
|
506
|
+
/** Release a room-id reservation (on ack, timeout, or rejection — §11). Idempotent. */
|
|
507
|
+
releaseRoomId(reservation) {
|
|
508
|
+
this.reservedRoomIds.delete(reservation.roomId);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Transition a create's reservations from *in-flight* to *pending-visibility*
|
|
512
|
+
* (task 003) — called by the command engine when a create **acks OK or times
|
|
513
|
+
* out**, instead of releasing. The room id stays reserved and one `maxRooms` slot
|
|
514
|
+
* stays counted until the owning instance's next snapshot/poll reconciles the room
|
|
515
|
+
* into the read model (or it is evicted). This closes the §11 window where a
|
|
516
|
+
* `504`-then-retry (§10) or an ack-then-immediate re-create would re-reserve the id
|
|
517
|
+
* after the command settled but before the room was visible, and double-create it on
|
|
518
|
+
* another instance. The original capacity reservation token is released and both
|
|
519
|
+
* holds collapse into one pending-visibility entry (still one id, one room slot).
|
|
520
|
+
*/
|
|
521
|
+
holdUntilVisible(roomIdReservation, reservation) {
|
|
522
|
+
this.release(reservation);
|
|
523
|
+
this.reservedRoomIds.delete(roomIdReservation.roomId);
|
|
524
|
+
if (this.pendingRoomIds.has(roomIdReservation.roomId)) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.pendingRoomIds.set(roomIdReservation.roomId, reservation.instanceId);
|
|
528
|
+
this.pendingByInstance.set(
|
|
529
|
+
reservation.instanceId,
|
|
530
|
+
(this.pendingByInstance.get(reservation.instanceId) ?? 0) + 1
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
/** Acked-but-not-yet-visible room slots held against an instance (task 003). */
|
|
534
|
+
pendingRooms(instanceId) {
|
|
535
|
+
return this.pendingByInstance.get(instanceId) ?? 0;
|
|
536
|
+
}
|
|
537
|
+
// -----------------------------------------------------------------------
|
|
538
|
+
// Internals
|
|
539
|
+
// -----------------------------------------------------------------------
|
|
540
|
+
/**
|
|
541
|
+
* A public id is taken when an in-flight reservation holds it, a settled-but-not-
|
|
542
|
+
* yet-visible create holds it (task 003), or a live room already uses it.
|
|
543
|
+
*/
|
|
544
|
+
isRoomIdTaken(roomId) {
|
|
545
|
+
return this.reservedRoomIds.has(roomId) || this.pendingRoomIds.has(roomId) || this.getRoom(roomId) !== null;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Clear every pending-visibility hold for an instance (task 003) — called when the
|
|
549
|
+
* instance's snapshot/poll reconciles its room set (the read model now holds
|
|
550
|
+
* whatever rooms truly exist) or when it is evicted (its rooms vanish). Either way
|
|
551
|
+
* the in-flight hold has done its job: a present room is taken via the read model,
|
|
552
|
+
* an absent one is genuinely free. Idempotent.
|
|
553
|
+
*/
|
|
554
|
+
clearPendingVisibility(instanceId) {
|
|
555
|
+
if (this.pendingByInstance.get(instanceId) === void 0) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
for (const [roomId, owner] of [...this.pendingRoomIds]) {
|
|
559
|
+
if (owner === instanceId) {
|
|
560
|
+
this.pendingRoomIds.delete(roomId);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
this.pendingByInstance.delete(instanceId);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The status `place()` should treat the instance as having (task 004): the
|
|
567
|
+
* pending placement override when one is held, else the snapshot-derived
|
|
568
|
+
* read-model `status`. The override exists only between a `drain`/`undrain` ack
|
|
569
|
+
* and the snapshot that confirms it.
|
|
570
|
+
*/
|
|
571
|
+
effectiveStatus(instance) {
|
|
572
|
+
return this.pendingStatus.get(instance.id) ?? instance.status;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Drop the placement override once the read model has caught up (task 004) — i.e.
|
|
576
|
+
* the last-applied snapshot's status now equals the pending value. Called on every
|
|
577
|
+
* snapshot apply and hash-only poll reply. Idempotent; no-op when no override is held.
|
|
578
|
+
*/
|
|
579
|
+
reconcilePendingStatus(instanceId) {
|
|
580
|
+
const pending = this.pendingStatus.get(instanceId);
|
|
581
|
+
if (pending !== void 0 && this.records.get(instanceId)?.info.status === pending) {
|
|
582
|
+
this.pendingStatus.delete(instanceId);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Generate a `r_<id>` not currently reserved or in use; near-certain on the first try. */
|
|
586
|
+
generateFreeRoomId() {
|
|
587
|
+
for (let attempt = 0; attempt < 1e3; attempt++) {
|
|
588
|
+
const candidate = generateRoomId();
|
|
589
|
+
if (!this.isRoomIdTaken(candidate)) {
|
|
590
|
+
return candidate;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
throw new FleetError("ROOM_EXISTS", "could not generate a unique room id after 1000 attempts");
|
|
594
|
+
}
|
|
595
|
+
/** Raw read-model rows (raw room ids) — the placement candidate source. */
|
|
596
|
+
rawInstances() {
|
|
597
|
+
return [...this.records.values()].map((record) => record.info);
|
|
598
|
+
}
|
|
599
|
+
rawInstanceById(id) {
|
|
600
|
+
return this.records.get(id)?.info ?? null;
|
|
601
|
+
}
|
|
602
|
+
rawInstanceByProcessUid(processUid) {
|
|
603
|
+
return this.latestRecordByProcessUid(processUid)?.info ?? null;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* The record for `processUid` with the highest `joinSeq` — the most recent
|
|
607
|
+
* connection (task 011). Shared by {@link getInstanceByProcessUid} (read API)
|
|
608
|
+
* and the pinned {@link place} path so both resolve a reconnect-overlapped
|
|
609
|
+
* `processUid` to the live connection, never the wedged old one.
|
|
610
|
+
*/
|
|
611
|
+
latestRecordByProcessUid(processUid) {
|
|
612
|
+
let latest = null;
|
|
613
|
+
for (const record of this.records.values()) {
|
|
614
|
+
if (record.info.processUid === processUid && (latest === null || record.joinSeq > latest.joinSeq)) {
|
|
615
|
+
latest = record;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return latest;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Resolve raw agent-reported room ids into the fleet-unique PUBLIC id space
|
|
622
|
+
* (§11). Pure function of the current read model — derivable from snapshots
|
|
623
|
+
* alone (§3), so it survives an orchestrator restart. Rules:
|
|
624
|
+
* - Local ids outside the charset are percent-encoded ({@link encodeRoomId}).
|
|
625
|
+
* - When several rooms map to the same base id, exactly one keeps it: a
|
|
626
|
+
* `fleet` room beats a `local` one, then the earliest joiner wins, then the
|
|
627
|
+
* lower instance id (deterministic — never map-iteration order). The losers
|
|
628
|
+
* surface namespaced as `<processUid>~<base>`, flagged `local` per their own
|
|
629
|
+
* origin. Two `fleet` rooms colliding can only happen across a restart, so
|
|
630
|
+
* that case is logged naming both instances (post-restart tie-break, §11).
|
|
631
|
+
*/
|
|
632
|
+
resolve() {
|
|
633
|
+
if (this.resolvedView !== null) {
|
|
634
|
+
return this.resolvedView;
|
|
635
|
+
}
|
|
636
|
+
const entries = [];
|
|
637
|
+
for (const record of this.records.values()) {
|
|
638
|
+
for (const room of record.info.rooms) {
|
|
639
|
+
entries.push({
|
|
640
|
+
instanceId: record.info.id,
|
|
641
|
+
processUid: record.info.processUid,
|
|
642
|
+
joinSeq: record.joinSeq,
|
|
643
|
+
rawId: room.id,
|
|
644
|
+
base: encodeRoomId(room.id),
|
|
645
|
+
origin: room.local ? "local" : "fleet",
|
|
646
|
+
room,
|
|
647
|
+
publicId: ""
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const groups = /* @__PURE__ */ new Map();
|
|
652
|
+
for (const entry of entries) {
|
|
653
|
+
const bucket = groups.get(entry.base);
|
|
654
|
+
if (bucket === void 0) {
|
|
655
|
+
groups.set(entry.base, [entry]);
|
|
656
|
+
} else {
|
|
657
|
+
bucket.push(entry);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const [base, bucket] of groups) {
|
|
661
|
+
if (bucket.length === 1) {
|
|
662
|
+
bucket[0].publicId = base;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const ordered = [...bucket].sort(compareForCanonical);
|
|
666
|
+
const keeper = ordered[0];
|
|
667
|
+
keeper.publicId = base;
|
|
668
|
+
for (const entry of ordered) {
|
|
669
|
+
if (entry !== keeper) {
|
|
670
|
+
entry.publicId = namespaceRoomId(entry.processUid, base);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const fleetDuplicates = ordered.filter((entry) => entry.origin === "fleet");
|
|
674
|
+
if (fleetDuplicates.length > 1) {
|
|
675
|
+
for (const loser of fleetDuplicates) {
|
|
676
|
+
if (loser !== keeper) {
|
|
677
|
+
this.logger.warning(
|
|
678
|
+
`fleet: duplicate room id "${base}" reported by instance ${keeper.instanceId} (joined earliest, keeps the canonical id) and instance ${loser.instanceId} (surfaced as "${loser.publicId}") \u2014 \xA711 post-restart tie-break, no room hidden or destroyed`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const roomsByInstance = /* @__PURE__ */ new Map();
|
|
685
|
+
const byPublicId = /* @__PURE__ */ new Map();
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
const room = { ...entry.room, id: entry.publicId };
|
|
688
|
+
const list = roomsByInstance.get(entry.instanceId);
|
|
689
|
+
if (list === void 0) {
|
|
690
|
+
roomsByInstance.set(entry.instanceId, [room]);
|
|
691
|
+
} else {
|
|
692
|
+
list.push(room);
|
|
693
|
+
}
|
|
694
|
+
byPublicId.set(entry.publicId, { room, instanceId: entry.instanceId, rawRoomId: entry.rawId });
|
|
695
|
+
}
|
|
696
|
+
const instances = [];
|
|
697
|
+
const byId = /* @__PURE__ */ new Map();
|
|
698
|
+
for (const record of this.records.values()) {
|
|
699
|
+
const instance = { ...record.info, rooms: roomsByInstance.get(record.info.id) ?? [] };
|
|
700
|
+
instances.push(instance);
|
|
701
|
+
byId.set(instance.id, instance);
|
|
702
|
+
}
|
|
703
|
+
this.logger.debug("fleet: rebuilt id-resolution view");
|
|
704
|
+
this.resolvedView = { instances, byId, byPublicId };
|
|
705
|
+
return this.resolvedView;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Drop the memoized resolution + state hash. Called by the two SEMANTIC
|
|
709
|
+
* mutations only ({@link applySnapshot}, {@link removeInstance}); the next read
|
|
710
|
+
* rebuilds. Non-semantic mutations ({@link touch}, {@link setStale}) never call
|
|
711
|
+
* this — see {@link resolvedView}.
|
|
712
|
+
*/
|
|
713
|
+
invalidate() {
|
|
714
|
+
this.resolvedView = null;
|
|
715
|
+
this.cachedStateHash = null;
|
|
716
|
+
}
|
|
717
|
+
reserve(instanceId) {
|
|
718
|
+
const id = `res_${++this.reservationSeq}`;
|
|
719
|
+
this.reservations.set(id, instanceId);
|
|
720
|
+
this.reservedByInstance.set(instanceId, (this.reservedByInstance.get(instanceId) ?? 0) + 1);
|
|
721
|
+
return { id, instanceId };
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Headroom against capacity, counting in-flight reservations as rooms (§9).
|
|
725
|
+
* A pending create occupies a room slot but contributes no connections (the
|
|
726
|
+
* room is empty until clients join), so reservations gate `maxRooms` only;
|
|
727
|
+
* `maxConnections` is gated by the real connection count.
|
|
728
|
+
*/
|
|
729
|
+
hasHeadroom(instance) {
|
|
730
|
+
const capacity = instance.capacity;
|
|
731
|
+
if (capacity.maxRooms !== null) {
|
|
732
|
+
const projected = instance.rooms.length + this.reservedRooms(instance.id) + this.pendingRooms(instance.id);
|
|
733
|
+
if (projected >= capacity.maxRooms) {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (capacity.maxConnections !== null && instance.connections >= capacity.maxConnections) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Pick among filtered candidates (§9 steps 2–3). `least-loaded`/`most-loaded`
|
|
744
|
+
* score by `connections / maxConnections` only when *every* candidate
|
|
745
|
+
* declares `maxConnections`; if any leaves it undeclared, all are scored by
|
|
746
|
+
* raw `connections` (a normalized 0.93 and a raw 1500 are not comparable).
|
|
747
|
+
* Ties are broken randomly; `random` ignores load entirely.
|
|
748
|
+
*/
|
|
749
|
+
pick(candidates, strategy) {
|
|
750
|
+
if (strategy === "random") {
|
|
751
|
+
return this.choose(candidates);
|
|
752
|
+
}
|
|
753
|
+
const allDeclare = candidates.every((instance) => instance.capacity.maxConnections !== null);
|
|
754
|
+
const scoreOf = (instance) => allDeclare ? instance.connections / instance.capacity.maxConnections : instance.connections;
|
|
755
|
+
let best = scoreOf(candidates[0]);
|
|
756
|
+
for (const instance of candidates) {
|
|
757
|
+
const score = scoreOf(instance);
|
|
758
|
+
best = strategy === "most-loaded" ? Math.max(best, score) : Math.min(best, score);
|
|
759
|
+
}
|
|
760
|
+
const tied = candidates.filter((instance) => scoreOf(instance) === best);
|
|
761
|
+
return this.choose(tied);
|
|
762
|
+
}
|
|
763
|
+
/** Uniform random choice from a non-empty list (placement tie-break / `random` strategy). */
|
|
764
|
+
choose(list) {
|
|
765
|
+
const index = Math.floor(this.random() * list.length);
|
|
766
|
+
return list[Math.min(index, list.length - 1)];
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Hash of SEMANTIC fleet state only (§6): instances, rooms, counts, statuses,
|
|
770
|
+
* capacities, versions — explicitly EXCLUDING `lastSyncAt` and all liveness
|
|
771
|
+
* bookkeeping, so the §10 ETag does not churn on every heartbeat. Order-
|
|
772
|
+
* independent: instances are sorted by id before encoding.
|
|
773
|
+
*/
|
|
774
|
+
computeStateHash(instances) {
|
|
775
|
+
const projection = instances.map((instance) => ({
|
|
776
|
+
id: instance.id,
|
|
777
|
+
name: instance.name,
|
|
778
|
+
processUid: instance.processUid,
|
|
779
|
+
endpointUrl: instance.endpointUrl,
|
|
780
|
+
labels: instance.labels,
|
|
781
|
+
roomTypes: instance.roomTypes,
|
|
782
|
+
connections: instance.connections,
|
|
783
|
+
capacity: instance.capacity,
|
|
784
|
+
autoCreate: instance.autoCreate,
|
|
785
|
+
status: instance.status,
|
|
786
|
+
agentVersion: instance.agentVersion,
|
|
787
|
+
protocolVersion: instance.protocolVersion,
|
|
788
|
+
rooms: instance.rooms.map((room) => ({
|
|
789
|
+
id: room.id,
|
|
790
|
+
type: room.type,
|
|
791
|
+
connections: room.connections,
|
|
792
|
+
instanceId: room.instanceId,
|
|
793
|
+
endpointUrl: room.endpointUrl,
|
|
794
|
+
local: room.local
|
|
795
|
+
}))
|
|
796
|
+
})).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
797
|
+
return hash64(projection);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
function buildInstanceInfo(instanceId, payload, lastSyncAt) {
|
|
801
|
+
const rooms = payload.rooms.map((room) => ({
|
|
802
|
+
id: room.id,
|
|
803
|
+
type: room.type,
|
|
804
|
+
connections: room.connections,
|
|
805
|
+
instanceId,
|
|
806
|
+
// Denormalized from the owning instance so room lookups carry the URL (§6).
|
|
807
|
+
endpointUrl: payload.endpointUrl,
|
|
808
|
+
// Provenance is the agent's call, never inferred here (§6).
|
|
809
|
+
local: room.origin === "local"
|
|
810
|
+
}));
|
|
811
|
+
let connections = 0;
|
|
812
|
+
for (const room of rooms) {
|
|
813
|
+
connections += room.connections;
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
id: instanceId,
|
|
817
|
+
name: payload.name,
|
|
818
|
+
processUid: payload.processUid,
|
|
819
|
+
endpointUrl: payload.endpointUrl,
|
|
820
|
+
labels: payload.labels,
|
|
821
|
+
roomTypes: payload.roomTypes,
|
|
822
|
+
rooms,
|
|
823
|
+
connections,
|
|
824
|
+
capacity: payload.capacity,
|
|
825
|
+
autoCreate: payload.autoCreate,
|
|
826
|
+
status: payload.status,
|
|
827
|
+
lastSyncAt,
|
|
828
|
+
agentVersion: payload.agentVersion,
|
|
829
|
+
protocolVersion: payload.protocolVersion
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function matchesLabels(instanceLabels, required) {
|
|
833
|
+
for (const key of Object.keys(required)) {
|
|
834
|
+
if (instanceLabels[key] !== required[key]) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
function compareForCanonical(a, b) {
|
|
841
|
+
const rankA = a.origin === "fleet" ? 0 : 1;
|
|
842
|
+
const rankB = b.origin === "fleet" ? 0 : 1;
|
|
843
|
+
if (rankA !== rankB) {
|
|
844
|
+
return rankA - rankB;
|
|
845
|
+
}
|
|
846
|
+
if (a.joinSeq !== b.joinSeq) {
|
|
847
|
+
return a.joinSeq - b.joinSeq;
|
|
848
|
+
}
|
|
849
|
+
return a.instanceId < b.instanceId ? -1 : a.instanceId > b.instanceId ? 1 : 0;
|
|
850
|
+
}
|
|
851
|
+
var ROOM_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
852
|
+
var ROOM_ID_RANDOM_LENGTH = 21;
|
|
853
|
+
function generateRoomId() {
|
|
854
|
+
const bytes = (0, import_node_crypto2.randomBytes)(ROOM_ID_RANDOM_LENGTH);
|
|
855
|
+
let id = "r_";
|
|
856
|
+
for (let i = 0; i < ROOM_ID_RANDOM_LENGTH; i++) {
|
|
857
|
+
id += ROOM_ID_ALPHABET[bytes[i] & 63];
|
|
858
|
+
}
|
|
859
|
+
return id;
|
|
860
|
+
}
|
|
861
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
862
|
+
0 && (module.exports = {
|
|
863
|
+
FleetError,
|
|
864
|
+
FleetState
|
|
865
|
+
});
|