@cydm/happy-elves 0.1.0-beta.45 → 0.1.0-beta.47
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/dist/session/directory.js +35 -9
- package/apps/daemon/dist/session/file-preview.js +1 -2
- package/apps/daemon/dist/session/workspace-paths.d.ts +0 -2
- package/apps/daemon/dist/session/workspace-paths.js +1 -13
- package/apps/daemon/package.json +1 -1
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
- package/packages/client/dist/live.d.ts +4 -0
- package/packages/client/dist/live.js +211 -146
- package/packages/client/dist/transport.js +4 -2
- package/packages/client/dist/types.d.ts +3 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { claimCommandRequest, sendCommandResponse } from "../relay/send.js";
|
|
4
|
-
import {
|
|
5
|
+
import { isUncPath, workspaceRealRoots } from "./workspace-paths.js";
|
|
5
6
|
const directoryEntryLimit = 500;
|
|
6
7
|
const directoryStatConcurrency = 16;
|
|
7
|
-
async function findRepoRoot(startPath
|
|
8
|
+
async function findRepoRoot(startPath) {
|
|
8
9
|
let current = startPath;
|
|
9
10
|
while (true) {
|
|
10
11
|
try {
|
|
@@ -15,16 +16,42 @@ async function findRepoRoot(startPath, workspaceRoots) {
|
|
|
15
16
|
const parent = path.dirname(current);
|
|
16
17
|
if (parent === current)
|
|
17
18
|
return null;
|
|
18
|
-
if (!workspaceRoots.some((root) => pathInsideRoot(root, parent)))
|
|
19
|
-
return null;
|
|
20
19
|
current = parent;
|
|
21
20
|
}
|
|
22
21
|
}
|
|
23
22
|
}
|
|
23
|
+
async function directoryRoots(workspaceRoots, currentPath) {
|
|
24
|
+
const roots = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
async function add(candidate, label) {
|
|
27
|
+
if (!candidate || isUncPath(candidate))
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const real = await fs.realpath(path.resolve(candidate));
|
|
31
|
+
if (seen.has(real))
|
|
32
|
+
return;
|
|
33
|
+
seen.add(real);
|
|
34
|
+
roots.push({ name: (label ?? path.basename(real)) || real, path: real });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Optional navigation shortcuts should not break directory listing.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const root of workspaceRoots)
|
|
41
|
+
await add(root);
|
|
42
|
+
await add(os.homedir(), "home");
|
|
43
|
+
await add(path.parse(currentPath).root);
|
|
44
|
+
const homeRoot = path.parse(os.homedir()).root;
|
|
45
|
+
if (homeRoot !== path.parse(currentPath).root)
|
|
46
|
+
await add(homeRoot);
|
|
47
|
+
return roots;
|
|
48
|
+
}
|
|
24
49
|
export async function buildDirectoryList(config, requestedPath) {
|
|
50
|
+
if (requestedPath && isUncPath(requestedPath)) {
|
|
51
|
+
throw Object.assign(new Error("UNC paths are not supported for directory browsing."), { code: "DIRECTORY_UNSUPPORTED_PATH" });
|
|
52
|
+
}
|
|
25
53
|
const resolvedPath = path.resolve(requestedPath || process.cwd());
|
|
26
54
|
const realPath = await fs.realpath(resolvedPath);
|
|
27
|
-
await assertPathInsideWorkspace(realPath);
|
|
28
55
|
const workspaceRoots = await workspaceRealRoots();
|
|
29
56
|
const entries = [];
|
|
30
57
|
let truncated = false;
|
|
@@ -66,13 +93,12 @@ export async function buildDirectoryList(config, requestedPath) {
|
|
|
66
93
|
return a.name.localeCompare(b.name);
|
|
67
94
|
});
|
|
68
95
|
const parentPath = path.dirname(realPath);
|
|
69
|
-
const parentInsideWorkspace = parentPath !== realPath && workspaceRoots.some((root) => pathInsideRoot(root, parentPath));
|
|
70
96
|
return {
|
|
71
97
|
machineId: config.machineId,
|
|
72
98
|
path: realPath,
|
|
73
|
-
parentPath:
|
|
74
|
-
repoRoot: await findRepoRoot(realPath
|
|
75
|
-
roots:
|
|
99
|
+
parentPath: parentPath !== realPath ? parentPath : null,
|
|
100
|
+
repoRoot: await findRepoRoot(realPath),
|
|
101
|
+
roots: await directoryRoots(workspaceRoots, realPath),
|
|
76
102
|
entries: sortedEntries,
|
|
77
103
|
truncated,
|
|
78
104
|
entryLimit: directoryEntryLimit,
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { appendAudit } from "../audit.js";
|
|
4
4
|
import { claimCommandRequest, sendCommandResponse } from "../relay/send.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isUncPath, pathInsideRoot } from "./workspace-paths.js";
|
|
6
6
|
const textLimitBytes = 256 * 1024;
|
|
7
7
|
const textHardLimitBytes = 1024 * 1024;
|
|
8
8
|
const imageLimitBytes = 16 * 1024 * 1024;
|
|
@@ -60,7 +60,6 @@ export async function buildFilePreview(config, rootPath, requestedPath, mode = "
|
|
|
60
60
|
? requestedPath
|
|
61
61
|
: path.resolve(rootAbsolutePath, requestedPath);
|
|
62
62
|
const rootRealPath = await fs.realpath(rootAbsolutePath);
|
|
63
|
-
await assertRootInsideWorkspace(rootRealPath);
|
|
64
63
|
const targetRealPath = await fs.realpath(targetPath);
|
|
65
64
|
assertInsideRoot(rootRealPath, targetRealPath);
|
|
66
65
|
const stat = await fs.stat(targetRealPath);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
1
|
export declare function workspaceRealRoots(): Promise<string[]>;
|
|
2
|
-
export declare function assertPathInsideWorkspace(realPath: string): Promise<void>;
|
|
3
|
-
export declare function assertRootInsideWorkspace(rootRealPath: string): Promise<void>;
|
|
4
2
|
export declare function pathInsideRoot(rootPath: string, targetPath: string): boolean;
|
|
5
3
|
export declare function isUncPath(input: string): boolean;
|
|
@@ -16,23 +16,11 @@ export async function workspaceRealRoots() {
|
|
|
16
16
|
roots.push(real);
|
|
17
17
|
}
|
|
18
18
|
catch {
|
|
19
|
-
// A stale session cwd should not
|
|
19
|
+
// A stale session cwd should not break directory root shortcuts.
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
return roots;
|
|
23
23
|
}
|
|
24
|
-
export async function assertPathInsideWorkspace(realPath) {
|
|
25
|
-
const roots = await workspaceRealRoots();
|
|
26
|
-
if (roots.some((root) => pathInsideRoot(root, realPath)))
|
|
27
|
-
return;
|
|
28
|
-
throw Object.assign(new Error("Path must stay inside a known workspace."), { code: "WORKSPACE_PATH_FORBIDDEN" });
|
|
29
|
-
}
|
|
30
|
-
export async function assertRootInsideWorkspace(rootRealPath) {
|
|
31
|
-
const roots = await workspaceRealRoots();
|
|
32
|
-
if (roots.some((root) => pathInsideRoot(root, rootRealPath)))
|
|
33
|
-
return;
|
|
34
|
-
throw Object.assign(new Error("Root path must stay inside a known workspace."), { code: "WORKSPACE_ROOT_FORBIDDEN" });
|
|
35
|
-
}
|
|
36
24
|
export function pathInsideRoot(rootPath, targetPath) {
|
|
37
25
|
const relative = path.relative(rootPath, targetPath);
|
|
38
26
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
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.47",
|
|
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.47",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@fastify/cors": "11.2.0",
|
|
@@ -799,9 +799,9 @@
|
|
|
799
799
|
}
|
|
800
800
|
},
|
|
801
801
|
"node_modules/bare-os": {
|
|
802
|
-
"version": "3.9.
|
|
803
|
-
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.
|
|
804
|
-
"integrity": "sha512-
|
|
802
|
+
"version": "3.9.3",
|
|
803
|
+
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz",
|
|
804
|
+
"integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==",
|
|
805
805
|
"license": "Apache-2.0",
|
|
806
806
|
"engines": {
|
|
807
807
|
"bare": ">=1.14.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"];
|