@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 CHANGED
@@ -2,14 +2,6 @@
2
2
 
3
3
  Public beta CLI for Happy Elves.
4
4
 
5
- ## Hosted relay
5
+ First-time hosted attach (Windows):
6
6
 
7
- ```bash
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.497",
3
+ "version": "0.1.0-beta.498",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.497",
3
+ "version": "0.1.0-beta.498",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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
- void replayNodePackets(context, openedConnection, message.cursor).catch((error) => {
163
- reportDetachedOperationFailure(openedConnection, error);
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
- queueCursorUpdate(context, connection, packet.serverSeq);
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 = message.channelId?.startsWith("he:update-") || message.channelId?.startsWith("he:update-response:");
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 = message.packet.channelId?.startsWith("he:update:") || message.packet.channelId?.startsWith("he:update-response:");
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
- queueCursorUpdate(context, connection, packet.serverSeq);
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 persistedCursor = await readCursor(input.stateRoot);
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, cursor: persistedCursor, snapshotMode: "routing" });
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: persistedCursor });
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
- packetCompletion.set(wire.packet.serverSeq, false);
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 > persistedCursor) {
227
- persistedCursor = pending;
228
- await writeCursor(input.stateRoot, persistedCursor);
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 readCursor(stateRoot) { try {
252
- const value = JSON.parse(await fs.readFile(updaterPaths(stateRoot).relayCursorPath, "utf8"));
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 writeCursor(stateRoot, cursor) { await fs.mkdir(updaterPaths(stateRoot).updaterRoot, { recursive: true, mode: 0o700 }); await atomicWriteJson(updaterPaths(stateRoot).relayCursorPath, { cursor }); }
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 updaterPid = await startUpdater({ stateRoot, nodeExecutable: node.executable, updaterEntrypoint: updaterEntrypoint(current.slotPath) });
105
- await updateOperationIfPresent(stateRoot, { phase: "succeeded", machineId: config.machineId });
106
- return await readOperation(stateRoot) ?? { 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, ownerPid: updaterPid };
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 await updateOperation(stateRoot, { phase: "succeeded", machineId: config.machineId, daemonWasRunning: (await localDaemonStatus(stateRoot)).running, targetVersion: version, ownerPid: process.pid }) ?? operation;
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
- if (!(await localDaemonStatus(input.stateRoot)).running)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.497",
3
+ "version": "0.1.0-beta.498",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"),