@cydm/happy-elves 0.1.0-beta.45 → 0.1.0-beta.46

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.46",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.46",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@cydm/happy-elves",
9
- "version": "0.1.0-beta.45",
9
+ "version": "0.1.0-beta.46",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@fastify/cors": "11.2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.46",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,10 @@ export type ControllerLiveEvent = {
38
38
  } | {
39
39
  type: "connection";
40
40
  state: ControllerLiveState["connectionState"];
41
+ } | {
42
+ type: "diagnostic";
43
+ kind: "snapshot" | "websocket";
44
+ detail: Record<string, string | number | boolean>;
41
45
  } | {
42
46
  type: "snapshot";
43
47
  source: string;
@@ -3,6 +3,7 @@ import { ControllerClientError, ControllerCommandError } from "./errors.js";
3
3
  import { oldestSessionEventId, shouldHoldHistoricalEventForOlderPage } from "./session-event-order.js";
4
4
  import { exactReplaceSessionEvents, mergeAuthoritativeSessionEventsByIdentity, mergeLatestPagePreservingLoadedOlderEvents, mergeSessionEventsById, } from "./session-event-window.js";
5
5
  import { subscribe as subscribeToRelay } from "./transport.js";
6
+ import { wsUrl } from "./http.js";
6
7
  const defaultOptions = {
7
8
  olderSessionEventPageSize: 96,
8
9
  promptAckTimeoutMs: 12_000,
@@ -78,13 +79,42 @@ export class ControllerLiveProjectionImpl {
78
79
  async refreshSnapshot(reason) {
79
80
  if (this.disposed)
80
81
  return;
82
+ const startedAt = Date.now();
83
+ const url = `${this.client.config.relayUrl}/api/bootstrap`;
81
84
  this.setState({ snapshotHydrating: true });
85
+ this.emit({ type: "diagnostic", kind: "snapshot", detail: { phase: "start", reason, url } });
82
86
  try {
83
87
  const snapshot = await this.client.snapshot();
88
+ this.emit({
89
+ type: "diagnostic",
90
+ kind: "snapshot",
91
+ detail: {
92
+ durationMs: Date.now() - startedAt,
93
+ events: snapshot.events.length,
94
+ machines: snapshot.machines.length,
95
+ phase: "success",
96
+ reason,
97
+ sessions: snapshot.sessions.length,
98
+ url,
99
+ },
100
+ });
84
101
  await this.applySnapshot(snapshot, `bootstrap:${reason}`);
85
102
  this.emit({ type: "snapshot", source: `bootstrap:${reason}`, snapshot });
86
103
  }
87
104
  catch (error) {
105
+ this.emit({
106
+ type: "diagnostic",
107
+ kind: "snapshot",
108
+ detail: {
109
+ code: error instanceof ControllerClientError ? error.code : "",
110
+ durationMs: Date.now() - startedAt,
111
+ message: errorMessage(error),
112
+ name: error instanceof Error ? error.name : typeof error,
113
+ phase: "error",
114
+ reason,
115
+ url,
116
+ },
117
+ });
88
118
  this.emitError(error, undefined);
89
119
  this.setState({ lastError: errorMessage(error) });
90
120
  }
@@ -240,153 +270,188 @@ export class ControllerLiveProjectionImpl {
240
270
  if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING))
241
271
  return;
242
272
  this.setState({ connectionState: "connecting" });
243
- const ws = subscribeToRelay(this.client.config, {
244
- onHello: () => {
245
- if (this.socket !== ws)
246
- return;
247
- this.setState({ connectionState: "online" });
248
- for (const resolve of this.onlineWaiters.splice(0))
249
- resolve();
250
- },
251
- onSnapshot: async (message) => {
252
- if (!this.isCurrentSocket(ws))
253
- return;
254
- await this.applySnapshot(message, "ws:snapshot", () => this.isCurrentSocket(ws));
255
- if (!this.isCurrentSocket(ws))
256
- return;
257
- this.emit({ type: "snapshot", source: "ws:snapshot", snapshot: message });
258
- },
259
- onMachine: async (message) => {
260
- if (!this.isCurrentSocket(ws))
261
- return;
262
- const metadata = await this.readMachineMetadata(message.machine);
263
- if (!this.isCurrentSocket(ws))
264
- return;
265
- this.upsertMachine(message.machine);
266
- if (metadata)
267
- this.setMachineMetadata(message.machine.id, metadata);
268
- this.emit({ type: "machine", machine: message.machine });
269
- this.emitChange();
270
- },
271
- onSession: async (message) => {
272
- if (!this.isCurrentSocket(ws))
273
- return;
274
- const previousHead = this.selectedSessionEventHistoryHeads.get(message.session.id);
275
- if (this.authoritativeProjectionHeadChanged(message.session))
273
+ const url = wsUrl(this.client.config.relayUrl);
274
+ this.emit({ type: "diagnostic", kind: "websocket", detail: { phase: "connect", url } });
275
+ let ws;
276
+ try {
277
+ ws = subscribeToRelay(this.client.config, {
278
+ onOpen: () => {
279
+ if (this.socket !== ws)
280
+ return;
281
+ this.emit({ type: "diagnostic", kind: "websocket", detail: { phase: "open", url } });
282
+ },
283
+ onHello: () => {
284
+ if (this.socket !== ws)
285
+ return;
286
+ this.setState({ connectionState: "online" });
287
+ this.emit({ type: "diagnostic", kind: "websocket", detail: { phase: "hello", url } });
288
+ for (const resolve of this.onlineWaiters.splice(0))
289
+ resolve();
290
+ },
291
+ onSnapshot: async (message) => {
292
+ if (!this.isCurrentSocket(ws))
293
+ return;
294
+ await this.applySnapshot(message, "ws:snapshot", () => this.isCurrentSocket(ws));
295
+ if (!this.isCurrentSocket(ws))
296
+ return;
297
+ this.emit({ type: "snapshot", source: "ws:snapshot", snapshot: message });
298
+ },
299
+ onMachine: async (message) => {
300
+ if (!this.isCurrentSocket(ws))
301
+ return;
302
+ const metadata = await this.readMachineMetadata(message.machine);
303
+ if (!this.isCurrentSocket(ws))
304
+ return;
305
+ this.upsertMachine(message.machine);
306
+ if (metadata)
307
+ this.setMachineMetadata(message.machine.id, metadata);
308
+ this.emit({ type: "machine", machine: message.machine });
309
+ this.emitChange();
310
+ },
311
+ onSession: async (message) => {
312
+ if (!this.isCurrentSocket(ws))
313
+ return;
314
+ const previousHead = this.selectedSessionEventHistoryHeads.get(message.session.id);
315
+ if (this.authoritativeProjectionHeadChanged(message.session))
316
+ this.bumpSessionProjectionGeneration(message.session.id);
317
+ this.upsertSession(message.session);
318
+ const requestFence = this.captureSessionHistoryFence(message.session.id, message.session);
319
+ const metadata = await this.readSessionMetadata(message.session);
320
+ if (!this.isCurrentSocket(ws))
321
+ return;
322
+ if (!this.isSessionHistoryFenceCurrent(requestFence))
323
+ return;
324
+ if (metadata)
325
+ this.setSessionMetadata(message.session.id, metadata);
326
+ this.emit({ type: "session", session: message.session });
327
+ this.refreshSelectedSessionHeadIfNeeded(message.session, previousHead);
328
+ this.emitChange();
329
+ },
330
+ onEvent: async (message) => {
331
+ if (!this.isCurrentSocket(ws))
332
+ return;
333
+ if (this.isHistoricalBackfillEvent(message.event) && this.markHistoricalBackfillAvailable(message.event))
334
+ return;
335
+ const requestFence = this.captureSessionHistoryFence(message.event.sessionId);
336
+ const event = await this.decodeEvent(message.event);
337
+ if (!this.isCurrentSocket(ws))
338
+ return;
339
+ if (!this.isSessionHistoryFenceCurrent(requestFence))
340
+ return;
341
+ this.upsertDecodedEvent(event);
342
+ this.bumpSessionLiveEventEpoch(event.sessionId);
343
+ this.emit({ type: "event", event });
344
+ this.emitChange();
345
+ },
346
+ onSessionTranscriptReplaced: async (message) => {
347
+ if (!this.isCurrentSocket(ws))
348
+ return;
276
349
  this.bumpSessionProjectionGeneration(message.session.id);
277
- this.upsertSession(message.session);
278
- const requestFence = this.captureSessionHistoryFence(message.session.id, message.session);
279
- const metadata = await this.readSessionMetadata(message.session);
280
- if (!this.isCurrentSocket(ws))
281
- return;
282
- if (!this.isSessionHistoryFenceCurrent(requestFence))
283
- return;
284
- if (metadata)
285
- this.setSessionMetadata(message.session.id, metadata);
286
- this.emit({ type: "session", session: message.session });
287
- this.refreshSelectedSessionHeadIfNeeded(message.session, previousHead);
288
- this.emitChange();
289
- },
290
- onEvent: async (message) => {
291
- if (!this.isCurrentSocket(ws))
292
- return;
293
- if (this.isHistoricalBackfillEvent(message.event) && this.markHistoricalBackfillAvailable(message.event))
294
- return;
295
- const requestFence = this.captureSessionHistoryFence(message.event.sessionId);
296
- const event = await this.decodeEvent(message.event);
297
- if (!this.isCurrentSocket(ws))
298
- return;
299
- if (!this.isSessionHistoryFenceCurrent(requestFence))
300
- return;
301
- this.upsertDecodedEvent(event);
302
- this.bumpSessionLiveEventEpoch(event.sessionId);
303
- this.emit({ type: "event", event });
304
- this.emitChange();
305
- },
306
- onSessionTranscriptReplaced: async (message) => {
307
- if (!this.isCurrentSocket(ws))
308
- return;
309
- this.bumpSessionProjectionGeneration(message.session.id);
310
- const requestFence = this.captureSessionHistoryFence(message.session.id, undefined, { includeLiveEventEpoch: true });
311
- const metadata = await this.readSessionMetadata(message.session);
312
- const decodedEvents = await this.client.decodeEvents(message.events);
313
- if (!this.isCurrentSocket(ws))
314
- return;
315
- if (!this.isSessionHistoryFenceCurrent(requestFence))
316
- return;
317
- this.upsertSession(message.session);
318
- if (metadata)
319
- this.setSessionMetadata(message.session.id, metadata);
320
- this.applyAuthoritativeReplacement(message.session, decodedEvents, { hasMoreBefore: message.hasMoreBefore, source: "transcript" });
321
- this.emit({ type: "session", session: message.session });
322
- this.emitChange();
323
- },
324
- onSessionTranscriptPrepended: async (message) => {
325
- if (!this.isCurrentSocket(ws))
326
- return;
327
- const requestFence = this.captureSessionHistoryFence(message.session.id, message.session);
328
- const metadata = await this.readSessionMetadata(message.session);
329
- const decodedEvents = await this.client.decodeEvents(message.events);
330
- if (!this.isCurrentSocket(ws))
331
- return;
332
- if (!this.isSessionHistoryFenceCurrent(requestFence))
333
- return;
334
- this.upsertSession(message.session);
335
- if (metadata)
336
- this.setSessionMetadata(message.session.id, metadata);
337
- this.mergeSessionEvents(message.session.id, decodedEvents);
338
- const pageState = this.stateValue.sessionPages[message.session.id];
339
- this.updateSessionPage(message.session.id, {
340
- error: undefined,
341
- hasOlderEvents: message.events.length > 0 || pageState?.hasOlderEvents === true,
342
- hasOlderRelayEvents: message.events.length > 0 || pageState?.hasOlderRelayEvents === true,
343
- initialized: true,
344
- loadingOlderEvents: false,
345
- olderCursor: pageState?.olderCursor,
346
- olderRelayVersion: (pageState?.olderRelayVersion ?? 0) + (message.events.length > 0 ? 1 : 0),
347
- runtimeHistoryChecked: message.hasMoreBefore !== true,
348
- oldestEventId: pageState?.oldestEventId ?? this.oldestEventIdForSession(message.session.id),
349
- });
350
- this.emit({ type: "session", session: message.session });
351
- this.emitChange();
352
- },
353
- onAck: (message) => {
354
- if (!this.isCurrentSocket(ws))
355
- return;
356
- const pending = this.pendingPrompts.get(message.requestId);
357
- if (pending) {
358
- this.pendingPrompts.delete(message.requestId);
359
- clearTimeout(pending.timer);
360
- pending.resolve({ requestId: message.requestId, turnId: pending.turnId });
361
- }
362
- this.emit({ type: "ack", requestId: message.requestId, sessionId: message.sessionId });
363
- },
364
- onError: (message) => {
365
- if (!this.isCurrentSocket(ws))
366
- return;
367
- const pending = message.requestId ? this.pendingPrompts.get(message.requestId) : undefined;
368
- if (pending) {
369
- this.pendingPrompts.delete(message.requestId);
370
- clearTimeout(pending.timer);
371
- pending.reject(new ControllerCommandError(message.message, message.code, message.capability, message.requestId));
372
- }
373
- this.setState({ lastError: message.message });
374
- this.emit({ type: "error", message: message.message, code: message.code, requestId: message.requestId });
375
- },
376
- onClose: () => {
377
- if (this.socket !== ws)
378
- return;
379
- this.setState({ connectionState: "offline" });
380
- if (this.disposed)
381
- return;
382
- if (typeof document !== "undefined" && document.visibilityState === "hidden")
383
- return;
384
- this.reconnectTimer = setTimeout(() => {
385
- this.reconnectTimer = undefined;
386
- this.connect();
387
- }, this.options.reconnectMs);
388
- },
389
- });
350
+ const requestFence = this.captureSessionHistoryFence(message.session.id, undefined, { includeLiveEventEpoch: true });
351
+ const metadata = await this.readSessionMetadata(message.session);
352
+ const decodedEvents = await this.client.decodeEvents(message.events);
353
+ if (!this.isCurrentSocket(ws))
354
+ return;
355
+ if (!this.isSessionHistoryFenceCurrent(requestFence))
356
+ return;
357
+ this.upsertSession(message.session);
358
+ if (metadata)
359
+ this.setSessionMetadata(message.session.id, metadata);
360
+ this.applyAuthoritativeReplacement(message.session, decodedEvents, { hasMoreBefore: message.hasMoreBefore, source: "transcript" });
361
+ this.emit({ type: "session", session: message.session });
362
+ this.emitChange();
363
+ },
364
+ onSessionTranscriptPrepended: async (message) => {
365
+ if (!this.isCurrentSocket(ws))
366
+ return;
367
+ const requestFence = this.captureSessionHistoryFence(message.session.id, message.session);
368
+ const metadata = await this.readSessionMetadata(message.session);
369
+ const decodedEvents = await this.client.decodeEvents(message.events);
370
+ if (!this.isCurrentSocket(ws))
371
+ return;
372
+ if (!this.isSessionHistoryFenceCurrent(requestFence))
373
+ return;
374
+ this.upsertSession(message.session);
375
+ if (metadata)
376
+ this.setSessionMetadata(message.session.id, metadata);
377
+ this.mergeSessionEvents(message.session.id, decodedEvents);
378
+ const pageState = this.stateValue.sessionPages[message.session.id];
379
+ this.updateSessionPage(message.session.id, {
380
+ error: undefined,
381
+ hasOlderEvents: message.events.length > 0 || pageState?.hasOlderEvents === true,
382
+ hasOlderRelayEvents: message.events.length > 0 || pageState?.hasOlderRelayEvents === true,
383
+ initialized: true,
384
+ loadingOlderEvents: false,
385
+ olderCursor: pageState?.olderCursor,
386
+ olderRelayVersion: (pageState?.olderRelayVersion ?? 0) + (message.events.length > 0 ? 1 : 0),
387
+ runtimeHistoryChecked: message.hasMoreBefore !== true,
388
+ oldestEventId: pageState?.oldestEventId ?? this.oldestEventIdForSession(message.session.id),
389
+ });
390
+ this.emit({ type: "session", session: message.session });
391
+ this.emitChange();
392
+ },
393
+ onAck: (message) => {
394
+ if (!this.isCurrentSocket(ws))
395
+ return;
396
+ const pending = this.pendingPrompts.get(message.requestId);
397
+ if (pending) {
398
+ this.pendingPrompts.delete(message.requestId);
399
+ clearTimeout(pending.timer);
400
+ pending.resolve({ requestId: message.requestId, turnId: pending.turnId });
401
+ }
402
+ this.emit({ type: "ack", requestId: message.requestId, sessionId: message.sessionId });
403
+ },
404
+ onError: (message) => {
405
+ if (!this.isCurrentSocket(ws))
406
+ return;
407
+ this.emit({
408
+ type: "diagnostic",
409
+ kind: "websocket",
410
+ detail: { code: message.code ?? "", message: message.message, phase: "server-error", requestId: message.requestId ?? "", url },
411
+ });
412
+ const pending = message.requestId ? this.pendingPrompts.get(message.requestId) : undefined;
413
+ if (pending) {
414
+ this.pendingPrompts.delete(message.requestId);
415
+ clearTimeout(pending.timer);
416
+ pending.reject(new ControllerCommandError(message.message, message.code, message.capability, message.requestId));
417
+ }
418
+ this.setState({ lastError: message.message });
419
+ this.emit({ type: "error", message: message.message, code: message.code, requestId: message.requestId });
420
+ },
421
+ onTransportError: () => {
422
+ if (this.socket !== ws)
423
+ return;
424
+ this.emit({ type: "diagnostic", kind: "websocket", detail: { phase: "error", url } });
425
+ },
426
+ onClose: (event) => {
427
+ if (this.socket !== ws)
428
+ return;
429
+ this.emit({
430
+ type: "diagnostic",
431
+ kind: "websocket",
432
+ detail: { code: event.code, phase: "close", reason: event.reason, url, wasClean: event.wasClean },
433
+ });
434
+ this.setState({ connectionState: "offline" });
435
+ if (this.disposed)
436
+ return;
437
+ if (typeof document !== "undefined" && document.visibilityState === "hidden")
438
+ return;
439
+ this.reconnectTimer = setTimeout(() => {
440
+ this.reconnectTimer = undefined;
441
+ this.connect();
442
+ }, this.options.reconnectMs);
443
+ },
444
+ });
445
+ }
446
+ catch (error) {
447
+ this.emit({
448
+ type: "diagnostic",
449
+ kind: "websocket",
450
+ detail: { message: errorMessage(error), name: error instanceof Error ? error.name : typeof error, phase: "create-error", url },
451
+ });
452
+ this.setState({ connectionState: "offline", lastError: errorMessage(error) });
453
+ return;
454
+ }
390
455
  this.socket = ws;
391
456
  }
392
457
  isCurrentSocket(ws) {
@@ -12,6 +12,7 @@ function parseRelayMessage(raw) {
12
12
  export function subscribe(config, handlers) {
13
13
  const ws = new WebSocket(wsUrl(config.relayUrl));
14
14
  ws.addEventListener("open", () => {
15
+ handlers.onOpen?.();
15
16
  const hello = {
16
17
  type: "hello",
17
18
  clientType: "controller",
@@ -72,8 +73,9 @@ export function subscribe(config, handlers) {
72
73
  handlers.onError?.(message);
73
74
  }
74
75
  });
75
- ws.addEventListener("close", () => handlers.onClose?.());
76
- ws.addEventListener("error", () => {
76
+ ws.addEventListener("close", (event) => handlers.onClose?.(event));
77
+ ws.addEventListener("error", (event) => {
78
+ handlers.onTransportError?.(event);
77
79
  handlers.onError?.({ type: "server:error", message: "Relay websocket error", code: "RELAY_WEBSOCKET_ERROR" });
78
80
  });
79
81
  return ws;
@@ -46,7 +46,9 @@ export type ControllerSubscriptionHandlers = {
46
46
  onError?: (message: Extract<ServerMessage, {
47
47
  type: "server:error";
48
48
  }>) => void;
49
- onClose?: () => void;
49
+ onOpen?: () => void;
50
+ onClose?: (event: CloseEvent) => void;
51
+ onTransportError?: (event: Event) => void;
50
52
  };
51
53
  export type EventCursor = number | `cursor_${number}`;
52
54
  export type WaitCondition = "idle" | SessionSnapshot["status"];