@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
package/lib/Poller.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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/Poller.ts
|
|
21
|
+
var Poller_exports = {};
|
|
22
|
+
__export(Poller_exports, {
|
|
23
|
+
Poller: () => Poller
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(Poller_exports);
|
|
26
|
+
var FORCE_FULL_EVERY_POLLS = 12;
|
|
27
|
+
var Poller = class {
|
|
28
|
+
constructor(scheduler, intervalMs, callbacks) {
|
|
29
|
+
this.scheduler = scheduler;
|
|
30
|
+
this.intervalMs = intervalMs;
|
|
31
|
+
this.callbacks = callbacks;
|
|
32
|
+
}
|
|
33
|
+
scheduler;
|
|
34
|
+
intervalMs;
|
|
35
|
+
callbacks;
|
|
36
|
+
entries = /* @__PURE__ */ new Map();
|
|
37
|
+
reqSeq = 0;
|
|
38
|
+
/** True while the instance is being polled (started and not yet forgotten). */
|
|
39
|
+
has(instanceId) {
|
|
40
|
+
return this.entries.has(instanceId);
|
|
41
|
+
}
|
|
42
|
+
/** Begin polling an instance: send the first poll now, then one every `intervalMs`. */
|
|
43
|
+
start(instanceId) {
|
|
44
|
+
this.entries.set(instanceId, { timer: null, outstandingReqId: null, missed: 0, pollCount: 0 });
|
|
45
|
+
this.poll(instanceId);
|
|
46
|
+
this.schedule(instanceId);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Consume the outstanding poll's reply (§7 enforcement). Returns `true` when
|
|
50
|
+
* `reqId` matches the in-flight poll (resets the missed counter); `false` when it
|
|
51
|
+
* matches no outstanding poll — an unsolicited / duplicate / post-settle
|
|
52
|
+
* `fleet/state`, which the caller turns into a kick.
|
|
53
|
+
*/
|
|
54
|
+
reply(instanceId, reqId) {
|
|
55
|
+
const entry = this.entries.get(instanceId);
|
|
56
|
+
if (entry === void 0 || entry.outstandingReqId === null || entry.outstandingReqId !== reqId) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
entry.outstandingReqId = null;
|
|
60
|
+
entry.missed = 0;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/** Stop polling an instance and cancel its timer (teardown). Idempotent. */
|
|
64
|
+
forget(instanceId) {
|
|
65
|
+
const entry = this.entries.get(instanceId);
|
|
66
|
+
if (entry !== void 0) {
|
|
67
|
+
this.scheduler.clearTimeout(entry.timer);
|
|
68
|
+
this.entries.delete(instanceId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
schedule(instanceId) {
|
|
72
|
+
const entry = this.entries.get(instanceId);
|
|
73
|
+
if (entry === void 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
entry.timer = this.scheduler.setTimeout(() => this.tick(instanceId), this.intervalMs);
|
|
77
|
+
}
|
|
78
|
+
tick(instanceId) {
|
|
79
|
+
const entry = this.entries.get(instanceId);
|
|
80
|
+
if (entry === void 0) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (entry.outstandingReqId !== null) {
|
|
84
|
+
entry.missed += 1;
|
|
85
|
+
if (entry.missed === 2) {
|
|
86
|
+
this.callbacks.onStale(instanceId);
|
|
87
|
+
}
|
|
88
|
+
if (entry.missed >= 3) {
|
|
89
|
+
this.callbacks.onEvict(instanceId);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.schedule(instanceId);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.poll(instanceId);
|
|
96
|
+
this.schedule(instanceId);
|
|
97
|
+
}
|
|
98
|
+
poll(instanceId) {
|
|
99
|
+
const entry = this.entries.get(instanceId);
|
|
100
|
+
if (entry === void 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const reqId = `poll_${++this.reqSeq}`;
|
|
104
|
+
const forceFull = entry.pollCount % FORCE_FULL_EVERY_POLLS === 0;
|
|
105
|
+
entry.pollCount += 1;
|
|
106
|
+
entry.outstandingReqId = reqId;
|
|
107
|
+
this.callbacks.sendPoll(instanceId, reqId, forceFull);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
111
|
+
0 && (module.exports = {
|
|
112
|
+
Poller
|
|
113
|
+
});
|
package/lib/Snapshot.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
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/Snapshot.ts
|
|
21
|
+
var Snapshot_exports = {};
|
|
22
|
+
__export(Snapshot_exports, {
|
|
23
|
+
MAX_SNAPSHOT_BYTES: () => MAX_SNAPSHOT_BYTES,
|
|
24
|
+
Snapshot: () => Snapshot
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(Snapshot_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/util/packageVersion.ts
|
|
99
|
+
var import_node_module = require("module");
|
|
100
|
+
var import_meta = {};
|
|
101
|
+
function packageVersion() {
|
|
102
|
+
try {
|
|
103
|
+
const metaUrl = import_meta.url;
|
|
104
|
+
const req = metaUrl ? (0, import_node_module.createRequire)(metaUrl) : require;
|
|
105
|
+
const pkg = req("../package.json");
|
|
106
|
+
return pkg.version ?? "0.0.0";
|
|
107
|
+
} catch {
|
|
108
|
+
return "0.0.0";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/wire/topics.ts
|
|
113
|
+
var PROTOCOL_VERSION = 3;
|
|
114
|
+
var Topics = {
|
|
115
|
+
/** orch → agent: assigns id + heartbeat (poll cadence) on join; followed by the first poll. */
|
|
116
|
+
hello: "fleet/hello",
|
|
117
|
+
/** orch → agent: state poll. Carries `knownHash` (dedup) + the last recorded `status` (echo). */
|
|
118
|
+
poll: "fleet/poll",
|
|
119
|
+
/** agent → orch: poll reply. Full snapshot when the hash differs from `knownHash`, hash-only otherwise. */
|
|
120
|
+
state: "fleet/state",
|
|
121
|
+
/** orch → agent: command push. */
|
|
122
|
+
cmd: "fleet/cmd",
|
|
123
|
+
/** agent → orch: command result. */
|
|
124
|
+
ack: "fleet/ack"
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/wire/serializer.ts
|
|
128
|
+
var import_node_module2 = require("module");
|
|
129
|
+
var import_meta2 = {};
|
|
130
|
+
var WIRE_MAJOR = PROTOCOL_VERSION;
|
|
131
|
+
var WIRE_MINOR = 0;
|
|
132
|
+
var HEADER_BYTES = 2;
|
|
133
|
+
var Type = {
|
|
134
|
+
Label: "Label",
|
|
135
|
+
SyncRoom: "SyncRoom",
|
|
136
|
+
Capacity: "Capacity",
|
|
137
|
+
AckRoom: "AckRoom",
|
|
138
|
+
Hello: "Hello",
|
|
139
|
+
Poll: "Poll",
|
|
140
|
+
State: "State",
|
|
141
|
+
Cmd: "Cmd",
|
|
142
|
+
Ack: "Ack"
|
|
143
|
+
};
|
|
144
|
+
var TOPIC_TYPE = {
|
|
145
|
+
[Topics.hello]: Type.Hello,
|
|
146
|
+
[Topics.poll]: Type.Poll,
|
|
147
|
+
[Topics.state]: Type.State,
|
|
148
|
+
[Topics.cmd]: Type.Cmd,
|
|
149
|
+
[Topics.ack]: Type.Ack
|
|
150
|
+
};
|
|
151
|
+
var serializer = null;
|
|
152
|
+
function getSerializer() {
|
|
153
|
+
if (serializer !== null) {
|
|
154
|
+
return serializer;
|
|
155
|
+
}
|
|
156
|
+
const metaUrl = import_meta2.url;
|
|
157
|
+
const req = metaUrl ? (0, import_node_module2.createRequire)(metaUrl) : require;
|
|
158
|
+
const mod = req("@toolcase/serializer");
|
|
159
|
+
const Serializer = mod.Serializer ?? mod.default;
|
|
160
|
+
const F = Serializer.FieldType;
|
|
161
|
+
const s = new Serializer("fleet");
|
|
162
|
+
s.define(Type.Label, [
|
|
163
|
+
{ key: "key", type: F.STRING, rule: "optional" },
|
|
164
|
+
{ key: "value", type: F.STRING, rule: "optional" }
|
|
165
|
+
]);
|
|
166
|
+
s.define(Type.SyncRoom, [
|
|
167
|
+
{ key: "id", type: F.STRING, rule: "optional" },
|
|
168
|
+
{ key: "type", type: F.STRING, rule: "optional" },
|
|
169
|
+
{ key: "connections", type: F.UINT32, rule: "optional" },
|
|
170
|
+
{ key: "origin", type: F.STRING, rule: "optional" }
|
|
171
|
+
]);
|
|
172
|
+
s.define(Type.Capacity, [
|
|
173
|
+
// null = unlimited (§6). Absent on the wire ⇒ null; an explicit 0 ⇒ 0.
|
|
174
|
+
{ key: "maxConnections", type: F.INT32, rule: "optional", default: null },
|
|
175
|
+
{ key: "maxRooms", type: F.INT32, rule: "optional", default: null }
|
|
176
|
+
]);
|
|
177
|
+
s.define(Type.AckRoom, [
|
|
178
|
+
{ key: "id", type: F.STRING, rule: "optional" },
|
|
179
|
+
{ key: "type", type: F.STRING, rule: "optional" }
|
|
180
|
+
]);
|
|
181
|
+
s.define(Type.Hello, [
|
|
182
|
+
{ key: "instanceId", type: F.STRING, rule: "optional" },
|
|
183
|
+
{ key: "protocolVersion", type: F.UINT32, rule: "optional" },
|
|
184
|
+
{ key: "heartbeatMs", type: F.UINT32, rule: "optional" }
|
|
185
|
+
]);
|
|
186
|
+
s.define(Type.Poll, [
|
|
187
|
+
{ key: "reqId", type: F.STRING, rule: "optional" },
|
|
188
|
+
// Absent ⇒ null (no prior state / forced full, subsumes the old fleet/resync).
|
|
189
|
+
{ key: "knownHash", type: F.STRING, rule: "optional" },
|
|
190
|
+
{ key: "status", type: F.STRING, rule: "optional" }
|
|
191
|
+
]);
|
|
192
|
+
s.define(Type.State, [
|
|
193
|
+
{ key: "reqId", type: F.STRING, rule: "optional" },
|
|
194
|
+
// full=false is a hash-only liveness reply: the snapshot fields below are
|
|
195
|
+
// omitted on the wire (preserving the old sync/ping dedup, orch-initiated).
|
|
196
|
+
{ key: "full", type: F.BOOL, rule: "optional" },
|
|
197
|
+
{ key: "seq", type: F.UINT32, rule: "optional" },
|
|
198
|
+
{ key: "hash", type: F.STRING, rule: "optional" },
|
|
199
|
+
{ key: "name", type: F.STRING, rule: "optional" },
|
|
200
|
+
{ key: "processUid", type: F.STRING, rule: "optional" },
|
|
201
|
+
{ key: "agentVersion", type: F.STRING, rule: "optional" },
|
|
202
|
+
{ key: "protocolVersion", type: F.UINT32, rule: "optional" },
|
|
203
|
+
{ key: "endpointUrl", type: F.STRING, rule: "optional" },
|
|
204
|
+
{ key: "labels", type: Type.Label, rule: "repeated" },
|
|
205
|
+
{ key: "capacity", type: Type.Capacity, rule: "optional" },
|
|
206
|
+
{ key: "autoCreate", type: F.BOOL, rule: "optional" },
|
|
207
|
+
{ key: "roomTypes", type: F.STRING, rule: "repeated" },
|
|
208
|
+
{ key: "rooms", type: Type.SyncRoom, rule: "repeated" },
|
|
209
|
+
{ key: "status", type: F.STRING, rule: "optional" }
|
|
210
|
+
]);
|
|
211
|
+
s.define(Type.Cmd, [
|
|
212
|
+
{ key: "cmdId", type: F.STRING, rule: "optional" },
|
|
213
|
+
{ key: "op", type: F.STRING, rule: "optional" },
|
|
214
|
+
{ key: "roomId", type: F.STRING, rule: "optional" },
|
|
215
|
+
{ key: "roomType", type: F.STRING, rule: "optional" }
|
|
216
|
+
]);
|
|
217
|
+
s.define(Type.Ack, [
|
|
218
|
+
{ key: "cmdId", type: F.STRING, rule: "optional" },
|
|
219
|
+
{ key: "ok", type: F.BOOL, rule: "optional" },
|
|
220
|
+
{ key: "error", type: F.STRING, rule: "optional" },
|
|
221
|
+
{ key: "alreadyGone", type: F.BOOL, rule: "optional" },
|
|
222
|
+
{ key: "room", type: Type.AckRoom, rule: "optional" },
|
|
223
|
+
// APPEND-ONLY (task 003): the room-already-exists signal must stay LAST so
|
|
224
|
+
// existing tags are unmoved (see the append-only tag rule in the file header).
|
|
225
|
+
{ key: "exists", type: F.BOOL, rule: "optional" }
|
|
226
|
+
]);
|
|
227
|
+
serializer = s;
|
|
228
|
+
return s;
|
|
229
|
+
}
|
|
230
|
+
function labelsToList(labels) {
|
|
231
|
+
return Object.entries(labels ?? {}).map(([key, value]) => ({ key, value }));
|
|
232
|
+
}
|
|
233
|
+
function capacityToMessage(capacity) {
|
|
234
|
+
return {
|
|
235
|
+
maxConnections: capacity?.maxConnections ?? null,
|
|
236
|
+
maxRooms: capacity?.maxRooms ?? null
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function stateToMessage(p) {
|
|
240
|
+
if (!p.full) {
|
|
241
|
+
return { reqId: p.reqId, full: false, seq: p.seq, hash: p.hash };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
reqId: p.reqId,
|
|
245
|
+
full: true,
|
|
246
|
+
seq: p.seq,
|
|
247
|
+
hash: p.hash,
|
|
248
|
+
name: p.name,
|
|
249
|
+
processUid: p.processUid,
|
|
250
|
+
agentVersion: p.agentVersion,
|
|
251
|
+
protocolVersion: p.protocolVersion,
|
|
252
|
+
endpointUrl: p.endpointUrl,
|
|
253
|
+
labels: labelsToList(p.labels),
|
|
254
|
+
capacity: capacityToMessage(p.capacity),
|
|
255
|
+
autoCreate: p.autoCreate,
|
|
256
|
+
roomTypes: p.roomTypes ?? [],
|
|
257
|
+
rooms: (p.rooms ?? []).map((r) => ({
|
|
258
|
+
id: r.id,
|
|
259
|
+
type: r.type,
|
|
260
|
+
connections: r.connections,
|
|
261
|
+
origin: r.origin
|
|
262
|
+
})),
|
|
263
|
+
status: p.status
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function toMessage(topic, payload) {
|
|
267
|
+
switch (topic) {
|
|
268
|
+
case Topics.state:
|
|
269
|
+
return stateToMessage(payload);
|
|
270
|
+
case Topics.poll: {
|
|
271
|
+
const p = payload;
|
|
272
|
+
const msg = { reqId: p.reqId, status: p.status };
|
|
273
|
+
if (p.knownHash !== null && p.knownHash !== void 0) {
|
|
274
|
+
msg.knownHash = p.knownHash;
|
|
275
|
+
}
|
|
276
|
+
return msg;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
return payload;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function encodeFrame(topic, payload) {
|
|
283
|
+
const type = TOPIC_TYPE[topic];
|
|
284
|
+
if (type === void 0) {
|
|
285
|
+
throw new Error(`fleet wire: no message type for topic=${topic}`);
|
|
286
|
+
}
|
|
287
|
+
const body = getSerializer().encode(type, toMessage(topic, payload));
|
|
288
|
+
const frame = new Uint8Array(HEADER_BYTES + body.length);
|
|
289
|
+
frame[0] = WIRE_MAJOR;
|
|
290
|
+
frame[1] = WIRE_MINOR;
|
|
291
|
+
frame.set(body, HEADER_BYTES);
|
|
292
|
+
return frame;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/agent/Snapshot.ts
|
|
296
|
+
var MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
297
|
+
var WARN_RATIO = 0.5;
|
|
298
|
+
var ERROR_RATIO = 0.9;
|
|
299
|
+
var MIN_CORE_VERSION = "6.1.0";
|
|
300
|
+
function generateProcessUid() {
|
|
301
|
+
return "p_" + (0, import_node_crypto2.randomBytes)(12).toString("hex");
|
|
302
|
+
}
|
|
303
|
+
var Snapshot = class {
|
|
304
|
+
/** Stable per-process id (§6) — constant across reconnects. */
|
|
305
|
+
processUid;
|
|
306
|
+
rivalis;
|
|
307
|
+
logger;
|
|
308
|
+
name;
|
|
309
|
+
endpointUrl;
|
|
310
|
+
labels;
|
|
311
|
+
capacity;
|
|
312
|
+
autoCreate;
|
|
313
|
+
agentVersion;
|
|
314
|
+
protocolVersion;
|
|
315
|
+
/** Room ids created in response to `fleet/cmd` → stamped `origin: 'fleet'`. */
|
|
316
|
+
fleetOrigins = /* @__PURE__ */ new Set();
|
|
317
|
+
/** Agent owns `status` (§7); flipped via `setStatus`. */
|
|
318
|
+
statusValue;
|
|
319
|
+
/** Per-connection monotonic frame counter — defensive hardening only (§7). */
|
|
320
|
+
seq = 0;
|
|
321
|
+
constructor(rivalis, options, logger) {
|
|
322
|
+
this.assertCoreSupport(rivalis);
|
|
323
|
+
this.rivalis = rivalis;
|
|
324
|
+
this.logger = logger ?? rivalis.logging?.getLogger?.("fleet:agent") ?? NOOP_LOGGER;
|
|
325
|
+
this.name = options.name;
|
|
326
|
+
this.endpointUrl = options.endpointUrl;
|
|
327
|
+
this.labels = options.labels ?? {};
|
|
328
|
+
this.capacity = {
|
|
329
|
+
maxConnections: options.capacity?.maxConnections ?? null,
|
|
330
|
+
maxRooms: options.capacity?.maxRooms ?? null
|
|
331
|
+
};
|
|
332
|
+
this.autoCreate = options.autoCreate ?? true;
|
|
333
|
+
this.agentVersion = options.agentVersion ?? packageVersion();
|
|
334
|
+
this.protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION;
|
|
335
|
+
this.processUid = options.processUid ?? generateProcessUid();
|
|
336
|
+
this.statusValue = options.status ?? "active";
|
|
337
|
+
}
|
|
338
|
+
get status() {
|
|
339
|
+
return this.statusValue;
|
|
340
|
+
}
|
|
341
|
+
/** Flip the agent-owned status (§7). The next snapshot carries the new value. */
|
|
342
|
+
setStatus(status) {
|
|
343
|
+
this.statusValue = status;
|
|
344
|
+
}
|
|
345
|
+
/** Stamp a room as fleet-created (`origin: 'fleet'`). Called on a `fleet/cmd` create. */
|
|
346
|
+
markFleetOrigin(roomId) {
|
|
347
|
+
this.fleetOrigins.add(roomId);
|
|
348
|
+
}
|
|
349
|
+
/** Drop provenance for a destroyed room so a future id reuse is not mis-stamped. */
|
|
350
|
+
forgetRoom(roomId) {
|
|
351
|
+
this.fleetOrigins.delete(roomId);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* New connection (reconnect): reset the `seq` counter. The reconnect assigns a
|
|
355
|
+
* fresh `instanceId`, so the orchestrator holds no prior hash and its first poll
|
|
356
|
+
* carries `knownHash: null` → the next reply is always a full snapshot (§7).
|
|
357
|
+
*/
|
|
358
|
+
resetConnection() {
|
|
359
|
+
this.seq = 0;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Rebuild the full semantic snapshot from live core state and hash it. Pure:
|
|
363
|
+
* no `seq`, no size guard, no dedup-state mutation — used for hash inspection
|
|
364
|
+
* and as the basis for {@link pollReply}.
|
|
365
|
+
*/
|
|
366
|
+
rebuild() {
|
|
367
|
+
const content = this.buildContent();
|
|
368
|
+
return { content, hash: hash64(content) };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Build a `fleet/state` reply to an orchestrator `fleet/poll` (§7, task 011).
|
|
372
|
+
* The orchestrator drives the dedup: a FULL snapshot when the rebuilt hash
|
|
373
|
+
* differs from the poll's `knownHash` (or `knownHash` is null — no prior state /
|
|
374
|
+
* forced full), a hash-only reply otherwise. Always advances `seq`.
|
|
375
|
+
*/
|
|
376
|
+
pollReply(reqId, knownHash) {
|
|
377
|
+
const { content, hash } = this.rebuild();
|
|
378
|
+
const seq = this.nextSeq();
|
|
379
|
+
if (knownHash !== null && hash === knownHash) {
|
|
380
|
+
const payload2 = { reqId, full: false, seq, hash, ...content };
|
|
381
|
+
return { kind: "state", full: false, hash, encodedBytes: 0, payload: payload2 };
|
|
382
|
+
}
|
|
383
|
+
const payload = { reqId, full: true, seq, hash, ...content };
|
|
384
|
+
const encodedBytes = encodeFrame(Topics.state, payload).length;
|
|
385
|
+
this.checkSize(encodedBytes, content.rooms.length);
|
|
386
|
+
return { kind: "state", full: true, hash, encodedBytes, payload };
|
|
387
|
+
}
|
|
388
|
+
nextSeq() {
|
|
389
|
+
this.seq += 1;
|
|
390
|
+
return this.seq;
|
|
391
|
+
}
|
|
392
|
+
buildContent() {
|
|
393
|
+
const manager = this.rivalis.rooms;
|
|
394
|
+
const roomTypes = [...manager.definitions()].sort();
|
|
395
|
+
const rooms = [];
|
|
396
|
+
for (const id of manager.keys()) {
|
|
397
|
+
const room = manager.get(id);
|
|
398
|
+
if (room === null) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (typeof room.type !== "string") {
|
|
402
|
+
throw new Error(this.coreSupportError(`room id=(${id}) has no string \`type\``));
|
|
403
|
+
}
|
|
404
|
+
rooms.push({
|
|
405
|
+
id,
|
|
406
|
+
type: room.type,
|
|
407
|
+
connections: room.actorCount,
|
|
408
|
+
origin: this.fleetOrigins.has(id) ? "fleet" : "local"
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
rooms.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
412
|
+
return {
|
|
413
|
+
name: this.name,
|
|
414
|
+
processUid: this.processUid,
|
|
415
|
+
agentVersion: this.agentVersion,
|
|
416
|
+
protocolVersion: this.protocolVersion,
|
|
417
|
+
endpointUrl: this.endpointUrl,
|
|
418
|
+
labels: this.labels,
|
|
419
|
+
capacity: this.capacity,
|
|
420
|
+
autoCreate: this.autoCreate,
|
|
421
|
+
roomTypes,
|
|
422
|
+
rooms,
|
|
423
|
+
status: this.statusValue
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
checkSize(bytes, roomCount) {
|
|
427
|
+
const pct = Math.round(bytes / MAX_SNAPSHOT_BYTES * 100);
|
|
428
|
+
if (bytes >= MAX_SNAPSHOT_BYTES * ERROR_RATIO) {
|
|
429
|
+
this.logger.error(
|
|
430
|
+
`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).`
|
|
431
|
+
);
|
|
432
|
+
} else if (bytes >= MAX_SNAPSHOT_BYTES * WARN_RATIO) {
|
|
433
|
+
this.logger.warning(
|
|
434
|
+
`fleet snapshot at ${pct}% of the 4 MiB transport frame limit (${bytes} bytes, ${roomCount} rooms) \u2014 approaching the size guard.`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Feature-detect the §4 core additions and throw an actionable, version-naming
|
|
440
|
+
* error when they are absent — a clean failure at startup instead of
|
|
441
|
+
* `undefined` types in snapshots at runtime. `Room.type` can only be checked
|
|
442
|
+
* against rooms that already exist; with zero rooms the `definitions()` gate
|
|
443
|
+
* is the primary guard (and `buildContent` re-checks each room defensively).
|
|
444
|
+
*/
|
|
445
|
+
assertCoreSupport(rivalis) {
|
|
446
|
+
const manager = rivalis?.rooms;
|
|
447
|
+
if (manager === void 0 || manager === null) {
|
|
448
|
+
throw new Error(this.coreSupportError("rivalis.rooms is not available"));
|
|
449
|
+
}
|
|
450
|
+
if (typeof manager.definitions !== "function") {
|
|
451
|
+
throw new Error(this.coreSupportError("rivalis.rooms.definitions() is not available"));
|
|
452
|
+
}
|
|
453
|
+
if (typeof manager.keys !== "function" || typeof manager.get !== "function") {
|
|
454
|
+
throw new Error(this.coreSupportError("rivalis.rooms.keys()/get() are not available"));
|
|
455
|
+
}
|
|
456
|
+
for (const id of manager.keys()) {
|
|
457
|
+
const room = manager.get(id);
|
|
458
|
+
if (room !== null && typeof room.type !== "string") {
|
|
459
|
+
throw new Error(this.coreSupportError(`Room.type is not available (room id=(${id}) has no string \`type\`)`));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
coreSupportError(detail) {
|
|
464
|
+
return `@rivalis/fleet requires @rivalis/core >= ${MIN_CORE_VERSION}: ${detail}. Upgrade @rivalis/core to >= ${MIN_CORE_VERSION} (the \xA74 additions: Room.type, RoomManager.definitions()).`;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
468
|
+
0 && (module.exports = {
|
|
469
|
+
MAX_SNAPSHOT_BYTES,
|
|
470
|
+
Snapshot
|
|
471
|
+
});
|
package/lib/canonical.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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/util/canonical.ts
|
|
21
|
+
var canonical_exports = {};
|
|
22
|
+
__export(canonical_exports, {
|
|
23
|
+
canonicalize: () => canonicalize,
|
|
24
|
+
hash64: () => hash64
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(canonical_exports);
|
|
27
|
+
var import_node_crypto = require("crypto");
|
|
28
|
+
function canonicalize(value) {
|
|
29
|
+
return encode(value);
|
|
30
|
+
}
|
|
31
|
+
function encode(value) {
|
|
32
|
+
if (value === null) {
|
|
33
|
+
return "null";
|
|
34
|
+
}
|
|
35
|
+
const type = typeof value;
|
|
36
|
+
if (type === "string") {
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
}
|
|
39
|
+
if (type === "number") {
|
|
40
|
+
return Number.isFinite(value) ? String(value) : "null";
|
|
41
|
+
}
|
|
42
|
+
if (type === "boolean") {
|
|
43
|
+
return value ? "true" : "false";
|
|
44
|
+
}
|
|
45
|
+
if (type === "bigint") {
|
|
46
|
+
return value.toString();
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
const items = value.map((item) => encodeArrayItem(item));
|
|
50
|
+
return "[" + items.join(",") + "]";
|
|
51
|
+
}
|
|
52
|
+
if (type === "object") {
|
|
53
|
+
const obj = value;
|
|
54
|
+
const keys = Object.keys(obj).sort();
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const child = obj[key];
|
|
58
|
+
if (isSkippable(child)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
parts.push(JSON.stringify(key) + ":" + encode(child));
|
|
62
|
+
}
|
|
63
|
+
return "{" + parts.join(",") + "}";
|
|
64
|
+
}
|
|
65
|
+
return "null";
|
|
66
|
+
}
|
|
67
|
+
function encodeArrayItem(item) {
|
|
68
|
+
return isSkippable(item) ? "null" : encode(item);
|
|
69
|
+
}
|
|
70
|
+
function isSkippable(value) {
|
|
71
|
+
const type = typeof value;
|
|
72
|
+
return value === void 0 || type === "function" || type === "symbol";
|
|
73
|
+
}
|
|
74
|
+
function hash64(value) {
|
|
75
|
+
const digest = (0, import_node_crypto.createHash)("sha256").update(canonicalize(value)).digest();
|
|
76
|
+
return digest.subarray(0, 8).toString("hex");
|
|
77
|
+
}
|
|
78
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
79
|
+
0 && (module.exports = {
|
|
80
|
+
canonicalize,
|
|
81
|
+
hash64
|
|
82
|
+
});
|