@cydm/happy-elves 0.1.0-beta.497 → 0.1.0-beta.498
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 +2 -10
- package/apps/cli/package.json +1 -1
- package/apps/daemon/package.json +1 -1
- package/apps/relay/dist/types.d.ts +1 -0
- package/apps/relay/dist/websocket.js +36 -9
- package/apps/updater/dist/paths.d.ts +1 -0
- package/apps/updater/dist/paths.js +1 -0
- package/apps/updater/dist/relay.js +11 -16
- package/apps/updater/dist/supervisor.js +15 -9
- package/package.json +1 -1
- package/packages/client/dist/update.js +4 -1
- package/packages/shared/dist/packet-relay.d.ts +2 -0
- package/packages/shared/dist/packet-relay.js +1 -0
package/README.md
CHANGED
|
@@ -2,14 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Public beta CLI for Happy Elves.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
First-time hosted attach (Windows):
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
happy-elves account create --relay https://relay.happyelves.ai --device-name "CLI" --json
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Self-host relay
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
happy-elves relay serve --host 0.0.0.0 --port 8787 --json
|
|
15
|
-
```
|
|
7
|
+
npm.cmd --yes --package @cydm/happy-elves@0.1.0-beta.498 happy-elves-updater install --autostart
|
package/apps/cli/package.json
CHANGED
package/apps/daemon/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RelayDataPlaneScope } from "../../../packages/shared/dist/index.js";
|
|
2
2
|
export type TokenKind = "controller" | "machine";
|
|
3
|
+
export type RelayReplayMode = "full" | "management";
|
|
3
4
|
export type ScopedTokenScope = RelayDataPlaneScope;
|
|
4
5
|
export type TokenRecord = {
|
|
5
6
|
token: string;
|
|
@@ -159,9 +159,11 @@ export function registerWebsocketRoute(app, context, originAllowed) {
|
|
|
159
159
|
});
|
|
160
160
|
await sendSnapshot(context, openedConnection, message.snapshotMode ?? "full");
|
|
161
161
|
broadcastPresence(context, openedConnection.spaceId, await presenceWithManifest(context, openedConnection.spaceId, presence));
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
if (openedConnection.replayMode === "full") {
|
|
163
|
+
void replayNodePackets(context, openedConnection, message.cursor).catch((error) => {
|
|
164
|
+
reportDetachedOperationFailure(openedConnection, error);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
165
167
|
}).catch((error) => {
|
|
166
168
|
app.log.warn({ error }, "failed to persist relay presence");
|
|
167
169
|
reportDetachedOperationFailure(openedConnection, error, true);
|
|
@@ -267,10 +269,11 @@ async function openConnection(context, socket, connectionId, message) {
|
|
|
267
269
|
id: connectionId,
|
|
268
270
|
nodeId,
|
|
269
271
|
nodeKind,
|
|
272
|
+
replayMode: message.replayMode ?? "full",
|
|
270
273
|
spaceId: token.account_id,
|
|
271
274
|
authToken: message.token,
|
|
272
275
|
socket,
|
|
273
|
-
subscriptions: [{ toNodeId: nodeId }],
|
|
276
|
+
subscriptions: message.replayMode === "management" ? [] : [{ toNodeId: nodeId }],
|
|
274
277
|
subscriptionReplays: new Map(),
|
|
275
278
|
pendingSubscriptionReplayKeys: [],
|
|
276
279
|
activeSubscriptionReplayCount: 0,
|
|
@@ -295,6 +298,15 @@ async function handlePacketMessage(context, connection, message) {
|
|
|
295
298
|
return;
|
|
296
299
|
}
|
|
297
300
|
if (message.type === "subscribe") {
|
|
301
|
+
const wantsManagement = isManagementChannel(message.channelId);
|
|
302
|
+
if ((connection.replayMode === "management" && !wantsManagement) || (connection.replayMode === "full" && wantsManagement)) {
|
|
303
|
+
send(connection.socket, {
|
|
304
|
+
type: "server:error",
|
|
305
|
+
code: wantsManagement ? "MANAGEMENT_CONNECTION_REQUIRED" : "BUSINESS_CONNECTION_REQUIRED",
|
|
306
|
+
message: wantsManagement ? "Updater channels require a management connection" : "Business connections may not subscribe to updater channels",
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
298
310
|
const authorization = await authorizeMessage(context, connection, message);
|
|
299
311
|
if (!authorization.ok) {
|
|
300
312
|
sendAuthorizationError(connection, authorization);
|
|
@@ -331,7 +343,7 @@ async function handlePacketMessage(context, connection, message) {
|
|
|
331
343
|
if (message.type === "ackPacket") {
|
|
332
344
|
const packet = await context.getPacketById({ spaceId: connection.spaceId, packetId: message.packetId });
|
|
333
345
|
const authorization = packet
|
|
334
|
-
? await context.checkDataPlaneTokenAsync(connection.authToken, "packet:read", packetReadResourceForReader(packet, connection.nodeId))
|
|
346
|
+
? await context.checkDataPlaneTokenAsync(connection.authToken, isManagementChannel(packet.channelId) ? "management:read" : "packet:read", packetReadResourceForReader(packet, connection.nodeId))
|
|
335
347
|
: await context.checkDataPlaneTokenAsync(connection.authToken, "packet:read", { allowScopedOmittedResource: true });
|
|
336
348
|
if (!authorization.ok) {
|
|
337
349
|
sendAuthorizationError(connection, authorization);
|
|
@@ -346,7 +358,8 @@ async function handlePacketMessage(context, connection, message) {
|
|
|
346
358
|
now: context.now(),
|
|
347
359
|
});
|
|
348
360
|
releasePendingAckBudget(connection, message.packetId);
|
|
349
|
-
|
|
361
|
+
if (connection.replayMode === "full" && !isManagementChannel(packet.channelId))
|
|
362
|
+
queueCursorUpdate(context, connection, packet.serverSeq);
|
|
350
363
|
}
|
|
351
364
|
send(connection.socket, {
|
|
352
365
|
type: "server:ack",
|
|
@@ -434,7 +447,7 @@ async function authorizeMessage(context, connection, message) {
|
|
|
434
447
|
message: "Scoped token subscriptions must name an allowed node, channel, or stream",
|
|
435
448
|
};
|
|
436
449
|
}
|
|
437
|
-
const managementChannel =
|
|
450
|
+
const managementChannel = isManagementChannel(message.channelId);
|
|
438
451
|
return await context.checkDataPlaneTokenAsync(connection.authToken, managementChannel ? "management:read" : "packet:read", {
|
|
439
452
|
...(message.toNodeId ? { nodeId: message.toNodeId } : {}),
|
|
440
453
|
...(message.channelId ? { channelId: message.channelId } : {}),
|
|
@@ -454,7 +467,7 @@ async function authorizeMessage(context, connection, message) {
|
|
|
454
467
|
if (message.packet.channelId?.startsWith("he:update:") && connection.nodeKind !== "controller") {
|
|
455
468
|
return { ok: false, code: "MANAGEMENT_CONTROLLER_REQUIRED", message: "Only a controller may publish updater management commands" };
|
|
456
469
|
}
|
|
457
|
-
const managementChannel =
|
|
470
|
+
const managementChannel = isManagementChannel(message.packet.channelId);
|
|
458
471
|
return await context.checkDataPlaneTokenAsync(connection.authToken, managementChannel ? "management:write" : "packet:write", {
|
|
459
472
|
...(message.packet.toNodeId ? { nodeId: message.packet.toNodeId } : {}),
|
|
460
473
|
...(message.packet.channelId ? { channelId: message.packet.channelId } : {}),
|
|
@@ -632,6 +645,8 @@ function deliverPacket(context, packet) {
|
|
|
632
645
|
if (packet.toNodeId) {
|
|
633
646
|
const connections = connectionsByNode.get(nodeKey(packet.spaceId, packet.toNodeId));
|
|
634
647
|
for (const connection of connections ?? []) {
|
|
648
|
+
if (!connectionAcceptsPacket(connection, packet))
|
|
649
|
+
continue;
|
|
635
650
|
void sendPacket(context, connection, packet, "live").catch((error) => {
|
|
636
651
|
reportDetachedOperationFailure(connection, error);
|
|
637
652
|
});
|
|
@@ -642,6 +657,8 @@ function deliverPacket(context, packet) {
|
|
|
642
657
|
for (const connection of spaceConnections ?? []) {
|
|
643
658
|
if (delivered.has(connection))
|
|
644
659
|
continue;
|
|
660
|
+
if (!connectionAcceptsPacket(connection, packet))
|
|
661
|
+
continue;
|
|
645
662
|
if (!connection.subscriptions.some((subscription) => subscriptionMatches(connection, subscription, packet)))
|
|
646
663
|
continue;
|
|
647
664
|
void sendPacket(context, connection, packet, "live").catch((error) => {
|
|
@@ -650,6 +667,8 @@ function deliverPacket(context, packet) {
|
|
|
650
667
|
}
|
|
651
668
|
}
|
|
652
669
|
async function sendPacket(context, connection, packet, source) {
|
|
670
|
+
if (!connectionAcceptsPacket(connection, packet))
|
|
671
|
+
return false;
|
|
653
672
|
const authorization = await context.checkDataPlaneTokenAsync(connection.authToken, "packet:read", packetReadResourceForReader(packet, connection.nodeId));
|
|
654
673
|
if (!authorization.ok) {
|
|
655
674
|
if (authorization.close)
|
|
@@ -685,7 +704,8 @@ async function sendPacket(context, connection, packet, source) {
|
|
|
685
704
|
closeSlowReceiver(connection);
|
|
686
705
|
return false;
|
|
687
706
|
}
|
|
688
|
-
|
|
707
|
+
if (connection.replayMode === "full" && !isManagementChannel(packet.channelId))
|
|
708
|
+
queueCursorUpdate(context, connection, packet.serverSeq);
|
|
689
709
|
return true;
|
|
690
710
|
}
|
|
691
711
|
function queueCursorUpdate(context, connection, serverSeq) {
|
|
@@ -818,6 +838,13 @@ function subscriptionMatches(connection, subscription, packet) {
|
|
|
818
838
|
return false;
|
|
819
839
|
return Boolean(packet.channelId || packet.toNodeId);
|
|
820
840
|
}
|
|
841
|
+
function isManagementChannel(channelId) {
|
|
842
|
+
return channelId?.startsWith("he:update:") === true || channelId?.startsWith("he:update-response:") === true;
|
|
843
|
+
}
|
|
844
|
+
function connectionAcceptsPacket(connection, packet) {
|
|
845
|
+
const management = isManagementChannel(packet.channelId);
|
|
846
|
+
return connection.replayMode === "management" ? management : !management;
|
|
847
|
+
}
|
|
821
848
|
function registerConnection(connection) {
|
|
822
849
|
diagnostics.openedConnections += 1;
|
|
823
850
|
diagnostics.activeConnections += 1;
|
|
@@ -16,6 +16,7 @@ export declare function updaterPaths(stateRoot: string): {
|
|
|
16
16
|
daemonStdoutPath: string;
|
|
17
17
|
daemonStderrPath: string;
|
|
18
18
|
relayCursorPath: string;
|
|
19
|
+
managementCursorPath: string;
|
|
19
20
|
relayOutboxPath: string;
|
|
20
21
|
relayRequestIndexPath: string;
|
|
21
22
|
launcherDir: string;
|
|
@@ -26,6 +26,7 @@ export function updaterPaths(stateRoot) {
|
|
|
26
26
|
daemonStdoutPath: path.join(updaterRoot, "logs", "daemon.stdout.log"),
|
|
27
27
|
daemonStderrPath: path.join(updaterRoot, "logs", "daemon.stderr.log"),
|
|
28
28
|
relayCursorPath: path.join(updaterRoot, "relay-cursor.json"),
|
|
29
|
+
managementCursorPath: path.join(updaterRoot, "management-cursor.json"),
|
|
29
30
|
relayOutboxPath: path.join(updaterRoot, "relay-outbox.json"),
|
|
30
31
|
relayRequestIndexPath: path.join(updaterRoot, "relay-request-index.json"),
|
|
31
32
|
launcherDir: path.join(updaterRoot, "bin"),
|
|
@@ -8,7 +8,7 @@ const updateManifestKey = (machineId) => `node:${machineId}:updater-manifest`;
|
|
|
8
8
|
const responseRetentionMs = 24 * 60 * 60 * 1000;
|
|
9
9
|
/** The updater remains online while the daemon is stopped, so its reconnect and result delivery are durable here. */
|
|
10
10
|
export async function startUpdaterRelay(input) {
|
|
11
|
-
let
|
|
11
|
+
let persistedManagementCursor = await readManagementCursor(input.stateRoot);
|
|
12
12
|
let ws;
|
|
13
13
|
let closed = false;
|
|
14
14
|
let subscribed = false;
|
|
@@ -35,7 +35,7 @@ export async function startUpdaterRelay(input) {
|
|
|
35
35
|
subscribed = false;
|
|
36
36
|
next.addEventListener("open", () => {
|
|
37
37
|
reconnectDelayMs = 500;
|
|
38
|
-
send({ type: "hello", token: input.config.machineToken, nodeKind: "machine", nodeId: input.config.machineId,
|
|
38
|
+
send({ type: "hello", token: input.config.machineToken, nodeKind: "machine", nodeId: input.config.machineId, snapshotMode: "routing", replayMode: "management" });
|
|
39
39
|
});
|
|
40
40
|
next.addEventListener("message", (event) => { void handleRelayMessage(String(event.data), next).catch(() => undefined); });
|
|
41
41
|
next.addEventListener("close", () => { if (ws === next && !closed) {
|
|
@@ -55,11 +55,9 @@ export async function startUpdaterRelay(input) {
|
|
|
55
55
|
const delay = reconnectDelayMs;
|
|
56
56
|
reconnectDelayMs = Math.min(30_000, reconnectDelayMs * 2);
|
|
57
57
|
reconnectTimer = setTimeout(() => { reconnectTimer = undefined; void connect().catch(() => scheduleReconnect()); }, delay);
|
|
58
|
-
reconnectTimer.unref?.();
|
|
59
58
|
}
|
|
60
59
|
await connect(true);
|
|
61
60
|
const manifestTimer = setInterval(() => { void publishManifest(latestManifest).catch(() => undefined); }, 30_000);
|
|
62
|
-
manifestTimer.unref?.();
|
|
63
61
|
await publishManifest(input.initialManifest);
|
|
64
62
|
await flushOutbox();
|
|
65
63
|
return {
|
|
@@ -91,7 +89,7 @@ export async function startUpdaterRelay(input) {
|
|
|
91
89
|
if (wire.type === "server:snapshot") {
|
|
92
90
|
if (!subscribed) {
|
|
93
91
|
subscribed = true;
|
|
94
|
-
send({ type: "subscribe", channelId: updateChannel(input.config.machineId), cursor:
|
|
92
|
+
send({ type: "subscribe", channelId: updateChannel(input.config.machineId), cursor: persistedManagementCursor });
|
|
95
93
|
}
|
|
96
94
|
await publishManifest(latestManifest);
|
|
97
95
|
await flushOutbox();
|
|
@@ -99,12 +97,9 @@ export async function startUpdaterRelay(input) {
|
|
|
99
97
|
}
|
|
100
98
|
if (wire.type !== "server:packet")
|
|
101
99
|
return;
|
|
102
|
-
|
|
103
|
-
if (wire.packet.channelId !== updateChannel(input.config.machineId)) {
|
|
104
|
-
send({ type: "ackPacket", packetId: wire.packet.packetId, serverSeq: wire.packet.serverSeq });
|
|
105
|
-
await commitPacket(wire.packet.serverSeq);
|
|
100
|
+
if (wire.packet.channelId !== updateChannel(input.config.machineId))
|
|
106
101
|
return;
|
|
107
|
-
|
|
102
|
+
packetCompletion.set(wire.packet.serverSeq, false);
|
|
108
103
|
const decoded = await decryptJson(input.config.accountSecret, wire.packet.encryptedPayload);
|
|
109
104
|
const packet = parseUpdatePacket(decoded);
|
|
110
105
|
if (!packet.type.startsWith("controller:update.") && !packet.type.startsWith("controller:host.") && !packet.type.startsWith("controller:daemon.")) {
|
|
@@ -223,9 +218,9 @@ export async function startUpdaterRelay(input) {
|
|
|
223
218
|
if (pending === undefined || !packetCompletion.get(pending))
|
|
224
219
|
return;
|
|
225
220
|
packetCompletion.delete(pending);
|
|
226
|
-
if (pending >
|
|
227
|
-
|
|
228
|
-
await
|
|
221
|
+
if (pending > persistedManagementCursor) {
|
|
222
|
+
persistedManagementCursor = pending;
|
|
223
|
+
await writeManagementCursor(input.stateRoot, persistedManagementCursor);
|
|
229
224
|
}
|
|
230
225
|
}
|
|
231
226
|
}
|
|
@@ -248,14 +243,14 @@ async function waitForOpen(ws) {
|
|
|
248
243
|
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("Timed out connecting updater to Relay")), 10_000); ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("Updater Relay websocket failed")); }, { once: true }); });
|
|
249
244
|
}
|
|
250
245
|
function wsUrl(relayUrl) { const parsed = new URL(relayUrl); parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; parsed.pathname = "/ws"; parsed.search = ""; return parsed.toString(); }
|
|
251
|
-
async function
|
|
252
|
-
const value = JSON.parse(await fs.readFile(updaterPaths(stateRoot).
|
|
246
|
+
async function readManagementCursor(stateRoot) { try {
|
|
247
|
+
const value = JSON.parse(await fs.readFile(updaterPaths(stateRoot).managementCursorPath, "utf8"));
|
|
253
248
|
return typeof value.cursor === "number" && Number.isSafeInteger(value.cursor) ? value.cursor : 0;
|
|
254
249
|
}
|
|
255
250
|
catch {
|
|
256
251
|
return 0;
|
|
257
252
|
} }
|
|
258
|
-
async function
|
|
253
|
+
async function writeManagementCursor(stateRoot, cursor) { await fs.mkdir(updaterPaths(stateRoot).updaterRoot, { recursive: true, mode: 0o700 }); await atomicWriteJson(updaterPaths(stateRoot).managementCursorPath, { cursor }); }
|
|
259
254
|
async function readOutbox(stateRoot) {
|
|
260
255
|
try {
|
|
261
256
|
const value = JSON.parse(await fs.readFile(updaterPaths(stateRoot).relayOutboxPath, "utf8"));
|
|
@@ -101,9 +101,12 @@ export async function installCurrentPackage(input) {
|
|
|
101
101
|
throw Object.assign(new Error("Installed package is missing a version"), { code: "UPDATE_PACKAGE_INVALID" });
|
|
102
102
|
const current = await readCurrentSlot(stateRoot);
|
|
103
103
|
if (current?.version === version) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
const existing = await readOperation(stateRoot);
|
|
105
|
+
const completed = existing && !["succeeded", "failed", "recovered", "manual_recovery_required"].includes(existing.phase)
|
|
106
|
+
? await updateOperation(stateRoot, { phase: "succeeded", machineId: config.machineId, targetVersion: version })
|
|
107
|
+
: existing;
|
|
108
|
+
await startUpdater({ stateRoot, nodeExecutable: node.executable, updaterEntrypoint: updaterEntrypoint(current.slotPath) });
|
|
109
|
+
return completed ?? { schema: 1, operationId: randomId("upd_attach"), machineId: config.machineId, targetVersion: version, phase: "succeeded", attempt: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), daemonWasRunning: true };
|
|
107
110
|
}
|
|
108
111
|
const operationId = randomId("upd_attach");
|
|
109
112
|
const unlock = await acquireOperationLock(stateRoot, operationId);
|
|
@@ -115,10 +118,12 @@ export async function installCurrentPackage(input) {
|
|
|
115
118
|
await writeStableLaunchers(stateRoot, node.executable);
|
|
116
119
|
if (input.installAutostart)
|
|
117
120
|
await installAutostart({ stateRoot, nodeExecutable: node.executable, updaterEntrypoint: updaterEntrypoint(slotPath(stateRoot, version)) });
|
|
121
|
+
const daemonWasRunning = (await localDaemonStatus(stateRoot)).running;
|
|
122
|
+
const completed = await updateOperation(stateRoot, { phase: "succeeded", machineId: config.machineId, daemonWasRunning, targetVersion: version, ownerPid: undefined, ownerLeaseExpiresAt: undefined });
|
|
118
123
|
await startUpdater({ stateRoot, nodeExecutable: node.executable, updaterEntrypoint: updaterEntrypoint(slotPath(stateRoot, version)) });
|
|
119
124
|
if (!(await waitForFile(updaterPaths(stateRoot).updaterPidPath, 10_000)))
|
|
120
125
|
throw Object.assign(new Error("Attached updater did not become ready"), { code: "UPDATER_START_FAILED" });
|
|
121
|
-
return
|
|
126
|
+
return completed;
|
|
122
127
|
}
|
|
123
128
|
catch (error) {
|
|
124
129
|
await updateOperation(stateRoot, { phase: "failed", errorCode: errorCode(error) ?? "ATTACH_FAILED", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
|
|
@@ -128,10 +133,6 @@ export async function installCurrentPackage(input) {
|
|
|
128
133
|
await unlock();
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
|
-
async function updateOperationIfPresent(stateRoot, patch) {
|
|
132
|
-
if (await readOperation(stateRoot))
|
|
133
|
-
await updateOperation(stateRoot, patch);
|
|
134
|
-
}
|
|
135
136
|
async function waitForFile(file, timeoutMs) {
|
|
136
137
|
const deadline = Date.now() + timeoutMs;
|
|
137
138
|
while (Date.now() < deadline) {
|
|
@@ -269,6 +270,8 @@ async function prepareOperation(stateRoot, input) {
|
|
|
269
270
|
attempt: 1,
|
|
270
271
|
createdAt: now,
|
|
271
272
|
updatedAt: now,
|
|
273
|
+
ownerPid: process.pid,
|
|
274
|
+
ownerLeaseExpiresAt: Date.now() + 30_000,
|
|
272
275
|
...(input.targetDigest ? { targetDigest: input.targetDigest } : {}),
|
|
273
276
|
};
|
|
274
277
|
await writeOperation(stateRoot, operation);
|
|
@@ -277,8 +280,11 @@ async function prepareOperation(stateRoot, input) {
|
|
|
277
280
|
async function waitForDaemonIdle(input) {
|
|
278
281
|
const deadline = Date.now() + input.maxWaitMs;
|
|
279
282
|
while (true) {
|
|
280
|
-
|
|
283
|
+
const daemon = await localDaemonStatus(input.stateRoot);
|
|
284
|
+
if (!daemon.running)
|
|
281
285
|
return;
|
|
286
|
+
if (daemon.identityVerified !== true)
|
|
287
|
+
throw Object.assign(new Error("The existing daemon identity cannot be verified; update lifecycle is disabled until the daemon is reinstalled once"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
|
|
282
288
|
const hostJobs = await activeHostJobCount(input.stateRoot);
|
|
283
289
|
if (hostJobs > 0) {
|
|
284
290
|
if (input.busyPolicy === "reject")
|
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ async function sendManagementCommand(config, command, timeoutMs, expectedType) {
|
|
|
14
14
|
return await new Promise((resolve, reject) => {
|
|
15
15
|
const ws = new WebSocket(wsUrl(config.relayUrl));
|
|
16
16
|
let controllerId;
|
|
17
|
+
let snapshotCursor = 0;
|
|
17
18
|
let published = false;
|
|
18
19
|
let commandPacketId;
|
|
19
20
|
let settled = false;
|
|
@@ -30,7 +31,7 @@ async function sendManagementCommand(config, command, timeoutMs, expectedType) {
|
|
|
30
31
|
resolve(value);
|
|
31
32
|
};
|
|
32
33
|
ws.addEventListener("open", () => {
|
|
33
|
-
ws.send(JSON.stringify({ type: "hello", token: config.controllerToken, nodeKind: "controller", snapshotMode: "routing" }));
|
|
34
|
+
ws.send(JSON.stringify({ type: "hello", token: config.controllerToken, nodeKind: "controller", snapshotMode: "routing", replayMode: "management" }));
|
|
34
35
|
});
|
|
35
36
|
ws.addEventListener("error", () => finish(undefined, new ControllerClientError("Relay websocket failed", "RELAY_WEBSOCKET_ERROR")));
|
|
36
37
|
ws.addEventListener("message", (event) => {
|
|
@@ -49,6 +50,7 @@ async function sendManagementCommand(config, command, timeoutMs, expectedType) {
|
|
|
49
50
|
if (wire.type === "server:snapshot") {
|
|
50
51
|
if (!controllerId || published)
|
|
51
52
|
return;
|
|
53
|
+
snapshotCursor = wire.cursor;
|
|
52
54
|
published = true;
|
|
53
55
|
void publish().catch((error) => finish(undefined, error instanceof Error ? error : new ControllerClientError("Failed to publish update command", "UPDATE_PUBLISH_FAILED")));
|
|
54
56
|
return;
|
|
@@ -77,6 +79,7 @@ async function sendManagementCommand(config, command, timeoutMs, expectedType) {
|
|
|
77
79
|
ws.send(JSON.stringify({
|
|
78
80
|
type: "subscribe",
|
|
79
81
|
channelId: updateResponseChannel(controllerId),
|
|
82
|
+
cursor: snapshotCursor,
|
|
80
83
|
}));
|
|
81
84
|
commandPacketId = randomId("pkt_update_command");
|
|
82
85
|
ws.send(JSON.stringify({
|
|
@@ -34,6 +34,8 @@ export type RelayWireMessage = {
|
|
|
34
34
|
cursor?: number;
|
|
35
35
|
/** Request a compact session-routing snapshot for cold command RPCs. */
|
|
36
36
|
snapshotMode?: "full" | "routing";
|
|
37
|
+
/** Restrict this connection to the encrypted updater management plane. */
|
|
38
|
+
replayMode?: "full" | "management";
|
|
37
39
|
} | {
|
|
38
40
|
type: "subscribe";
|
|
39
41
|
toNodeId?: string;
|
|
@@ -41,6 +41,7 @@ const relayWireMessageSchema = z.discriminatedUnion("type", [
|
|
|
41
41
|
nodeKind: relayNodeKindSchema.optional(),
|
|
42
42
|
cursor: z.number().int().nonnegative().optional(),
|
|
43
43
|
snapshotMode: z.enum(["full", "routing"]).optional(),
|
|
44
|
+
replayMode: z.enum(["full", "management"]).optional(),
|
|
44
45
|
}),
|
|
45
46
|
z.object({
|
|
46
47
|
type: z.literal("subscribe"),
|