@cydm/happy-elves 0.1.0-beta.44 → 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.
package/apps/daemon/package.json
CHANGED
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cydm/happy-elves",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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.
|
|
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
|
@@ -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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
this.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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"];
|