@robota-sdk/agent-transport-ws 3.0.0-beta.82 → 3.0.0-beta.84
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/CHANGELOG.md +384 -0
- package/README.md +76 -12
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +20 -1
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +20 -1
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +14 -11
- package/src/__tests__/ws-session-binder.test.ts +271 -0
- package/src/__tests__/ws-session-directory.test.ts +97 -0
- package/src/__tests__/ws-transport-auth.test.ts +33 -0
- package/src/ws-transport-config.ts +22 -3
- package/src/ws-transport-configurable.ts +39 -7
- package/src/ws-transport.ts +4 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: the host hands its session directory to the WebSocket carrier once, and every connection can
|
|
3
|
+
* list, start and switch the host's sessions through it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createWsTransport } from '../ws-transport.js';
|
|
11
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
12
|
+
|
|
13
|
+
import type { ISessionDirectory, ISessionListing } from '@robota-sdk/agent-interface-session';
|
|
14
|
+
import type { TServerMessage } from '@robota-sdk/agent-transport';
|
|
15
|
+
|
|
16
|
+
const listing: ISessionListing = {
|
|
17
|
+
currentSessionId: 'session-1',
|
|
18
|
+
sessions: [],
|
|
19
|
+
unreadableSessionIds: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function createDirectory(): ISessionDirectory {
|
|
23
|
+
return {
|
|
24
|
+
listSessions: vi.fn(() => listing),
|
|
25
|
+
switchSession: vi.fn(async () => undefined),
|
|
26
|
+
newSession: vi.fn(async () => undefined),
|
|
27
|
+
renameSession: vi.fn(async () => undefined),
|
|
28
|
+
deleteSession: vi.fn(async () => undefined),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mockSession(): ReturnType<typeof createTestInteractiveSession> {
|
|
33
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
34
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
35
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
36
|
+
on: vi.fn(),
|
|
37
|
+
off: vi.fn(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const started: WsTransport[] = [];
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
while (started.length) await started.pop()!.stop();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('WsTransport session directory (#3189)', () => {
|
|
47
|
+
it('lets every connection list and switch the host sessions', async () => {
|
|
48
|
+
const directory = createDirectory();
|
|
49
|
+
const transport = new WsTransport({
|
|
50
|
+
port: 17720,
|
|
51
|
+
maxRetries: 30,
|
|
52
|
+
open: true,
|
|
53
|
+
openReason: 'session-directory regression test',
|
|
54
|
+
sessionDirectory: directory,
|
|
55
|
+
});
|
|
56
|
+
transport.attach(mockSession());
|
|
57
|
+
await transport.start();
|
|
58
|
+
started.push(transport);
|
|
59
|
+
|
|
60
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
61
|
+
const received: TServerMessage[] = [];
|
|
62
|
+
ws.on('message', (data) => received.push(JSON.parse(String(data)) as TServerMessage));
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
ws.on('open', resolve);
|
|
65
|
+
ws.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ws.send(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
|
|
69
|
+
ws.send(JSON.stringify({ type: 'switch-session', sessionId: 'session-2' }));
|
|
70
|
+
await vi.waitFor(() => expect(directory.switchSession).toHaveBeenCalledWith('session-2'));
|
|
71
|
+
await vi.waitFor(() =>
|
|
72
|
+
expect(received).toContainEqual({ type: 'sessions', requestId: 'r1', listing }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
76
|
+
ws.close();
|
|
77
|
+
await closed;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('passes the directory through the adapter form as well', async () => {
|
|
81
|
+
const directory = createDirectory();
|
|
82
|
+
const sent: TServerMessage[] = [];
|
|
83
|
+
const transport = createWsTransport({
|
|
84
|
+
send: (message) => sent.push(message),
|
|
85
|
+
sessionDirectory: directory,
|
|
86
|
+
});
|
|
87
|
+
transport.attach(mockSession());
|
|
88
|
+
await transport.start();
|
|
89
|
+
|
|
90
|
+
transport.onMessage?.(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
|
|
91
|
+
transport.onMessage?.(JSON.stringify({ type: 'new-session' }));
|
|
92
|
+
|
|
93
|
+
expect(sent).toEqual([{ type: 'sessions', requestId: 'r1', listing }]);
|
|
94
|
+
await vi.waitFor(() => expect(directory.newSession).toHaveBeenCalledTimes(1));
|
|
95
|
+
await transport.stop();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -133,6 +133,39 @@ describe('WsTransport loopback auth (GUI-002 TC-03)', () => {
|
|
|
133
133
|
await closed;
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
it('carries the driver id on the connection-open messages frame, so a client can learn its own id (#3289 §3)', async () => {
|
|
137
|
+
// The crux of #3289 §3's own-driver-id feature: a client's first `messages` frame is where it
|
|
138
|
+
// learns which of every subsequent `authored` message is its own. This is the one place that
|
|
139
|
+
// sets it — decode-shape validation and the client reducer are covered elsewhere, but neither
|
|
140
|
+
// would catch this specific line being broken or reverted.
|
|
141
|
+
const port = 17660;
|
|
142
|
+
const transport = new WsTransport({
|
|
143
|
+
port,
|
|
144
|
+
maxRetries: 30,
|
|
145
|
+
open: true,
|
|
146
|
+
openReason: 'own-driver-id regression test',
|
|
147
|
+
driverId: 'browser',
|
|
148
|
+
surface: 'browser',
|
|
149
|
+
});
|
|
150
|
+
transport.attach(mockSession());
|
|
151
|
+
await transport.start();
|
|
152
|
+
started.push(transport);
|
|
153
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
154
|
+
const firstMessagesFrame = await new Promise<{ type: string; driverId?: string }>(
|
|
155
|
+
(resolve, reject) => {
|
|
156
|
+
ws.on('error', reject);
|
|
157
|
+
ws.on('message', (data) => {
|
|
158
|
+
const msg = JSON.parse(String(data)) as { type: string; driverId?: string };
|
|
159
|
+
if (msg.type === 'messages') resolve(msg);
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
expect(firstMessagesFrame.driverId).toBe('browser');
|
|
164
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
165
|
+
ws.close();
|
|
166
|
+
await closed;
|
|
167
|
+
});
|
|
168
|
+
|
|
136
169
|
it('accepts a connection presenting the correct token via query param', async () => {
|
|
137
170
|
const { port } = await startOn({ token: 'secret-nonce' });
|
|
138
171
|
expect(await probe(port, { token: 'secret-nonce' })).toBe('messages');
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
|
|
11
|
-
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
11
|
+
import type { ISessionBinder, TDriverId } from '@robota-sdk/agent-interface-session';
|
|
12
12
|
import type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';
|
|
13
|
-
import type { ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';
|
|
13
|
+
import type { IProtocolSession, ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';
|
|
14
14
|
|
|
15
15
|
export const DEFAULT_PORT = 7070;
|
|
16
16
|
export const DEFAULT_MAX_RETRIES = 20;
|
|
@@ -26,6 +26,17 @@ export interface IWsTransportConfig {
|
|
|
26
26
|
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
27
27
|
/** Host-owned stored-session trace/cost producer for personal-usage drill-down. */
|
|
28
28
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
29
|
+
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
30
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
31
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
32
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
33
|
+
/**
|
|
34
|
+
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
35
|
+
* so one connection's switch moves only that connection. Released when the connection closes. When
|
|
36
|
+
* set, a connection uses its binding's session and directory instead of the attached session and
|
|
37
|
+
* `sessionDirectory`; without it every connection shares the attached session.
|
|
38
|
+
*/
|
|
39
|
+
sessionBinder?: ISessionBinder<IProtocolSession>;
|
|
29
40
|
port?: number;
|
|
30
41
|
maxRetries?: number;
|
|
31
42
|
/**
|
|
@@ -84,7 +95,13 @@ function configuredUsageReporters(config: IWsTransportConfig): TUsageReporters {
|
|
|
84
95
|
|
|
85
96
|
type TConfiguredHandlerOptions = Pick<
|
|
86
97
|
ISessionMessageHandlerOptions,
|
|
87
|
-
|
|
98
|
+
| 'driverId'
|
|
99
|
+
| 'surface'
|
|
100
|
+
| 'personalUsageReporter'
|
|
101
|
+
| 'usageReporter'
|
|
102
|
+
| 'storedSessionUsageReporter'
|
|
103
|
+
| 'sessionDirectory'
|
|
104
|
+
| 'settingsReporter'
|
|
88
105
|
>;
|
|
89
106
|
|
|
90
107
|
/** Project immutable transport attribution/reporters into each protocol handler. */
|
|
@@ -93,6 +110,8 @@ export function configuredWsHandlerOptions(config: IWsTransportConfig): TConfigu
|
|
|
93
110
|
...(config.driverId ? { driverId: config.driverId } : {}),
|
|
94
111
|
...(config.surface ? { surface: config.surface } : {}),
|
|
95
112
|
...configuredUsageReporters(config),
|
|
113
|
+
...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),
|
|
114
|
+
...(config.settingsReporter ? { settingsReporter: config.settingsReporter } : {}),
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
import type { IWsTransportConfig } from './ws-transport-config.js';
|
|
30
30
|
import type { TUniversalValue } from '@robota-sdk/agent-core';
|
|
31
|
+
import type { ISessionBinder, ISessionBinding } from '@robota-sdk/agent-interface-session';
|
|
31
32
|
import type {
|
|
32
33
|
IChannelDescriptor,
|
|
33
34
|
IConfigurableTransport,
|
|
@@ -51,9 +52,7 @@ const WS_STOP_TERMINATE_DEADLINE_MS = 5000;
|
|
|
51
52
|
* TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the
|
|
52
53
|
* consumer-declared channels. The two profiles share one connection and never constrain each other.
|
|
53
54
|
*/
|
|
54
|
-
export class WsTransport
|
|
55
|
-
implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost
|
|
56
|
-
{
|
|
55
|
+
export class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {
|
|
57
56
|
readonly name = 'ws';
|
|
58
57
|
readonly lifecycle = Object.freeze({ kind: 'service' as const });
|
|
59
58
|
readonly defaultEnabled = true;
|
|
@@ -77,6 +76,7 @@ export class WsTransport
|
|
|
77
76
|
private readonly allowedHosts: ReadonlySet<string>;
|
|
78
77
|
private readonly allowedOrigins: ReadonlySet<string>;
|
|
79
78
|
private readonly handlerOptions: ReturnType<typeof configuredWsHandlerOptions>;
|
|
79
|
+
private readonly sessionBinder: ISessionBinder<IProtocolSession> | undefined;
|
|
80
80
|
private readonly channels = new PayloadChannelRegistry();
|
|
81
81
|
private resolvedPort?: number;
|
|
82
82
|
|
|
@@ -90,6 +90,7 @@ export class WsTransport
|
|
|
90
90
|
this.allowedHosts = new Set(config.allowedHosts ?? []);
|
|
91
91
|
this.allowedOrigins = new Set(config.allowedOrigins ?? []);
|
|
92
92
|
this.handlerOptions = configuredWsHandlerOptions(config);
|
|
93
|
+
this.sessionBinder = config.sessionBinder;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
attach(session: IProtocolSession): void {
|
|
@@ -187,6 +188,22 @@ export class WsTransport
|
|
|
187
188
|
return validTransportOptions(options);
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host
|
|
193
|
+
* would not bind it (shutting down, say): the socket is then closed before any session data.
|
|
194
|
+
*/
|
|
195
|
+
private bindConnection(
|
|
196
|
+
ws: WebSocket,
|
|
197
|
+
): { binding: ISessionBinding<IProtocolSession> | undefined } | null {
|
|
198
|
+
if (!this.sessionBinder) return { binding: undefined };
|
|
199
|
+
try {
|
|
200
|
+
return { binding: this.sessionBinder.bind('drive') };
|
|
201
|
+
} catch {
|
|
202
|
+
ws.close(1013, 'session unavailable');
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
190
207
|
private bindWithRetry(
|
|
191
208
|
session: IProtocolSession,
|
|
192
209
|
port: number,
|
|
@@ -244,13 +261,24 @@ export class WsTransport
|
|
|
244
261
|
return;
|
|
245
262
|
}
|
|
246
263
|
|
|
264
|
+
// #3189: with a binder, this connection gets its own session and directory, so its
|
|
265
|
+
// switch (and the `session_switched` that follows) stays on this connection. Every WS
|
|
266
|
+
// connection drives: this carrier has no observe role.
|
|
267
|
+
const bound = this.bindConnection(ws);
|
|
268
|
+
if (bound === null) return;
|
|
269
|
+
const { binding } = bound;
|
|
270
|
+
const connectionSession = binding?.session ?? session;
|
|
247
271
|
const delivery = new WsSessionDelivery(ws);
|
|
248
272
|
const handler = createSessionMessageHandler({
|
|
249
|
-
session,
|
|
273
|
+
session: connectionSession,
|
|
250
274
|
deliver: delivery.deliver,
|
|
251
275
|
...this.handlerOptions,
|
|
276
|
+
...(binding ? { sessionDirectory: binding.directory } : {}),
|
|
277
|
+
});
|
|
278
|
+
delivery.bindProtocolCleanup(() => {
|
|
279
|
+
handler.cleanup();
|
|
280
|
+
binding?.release();
|
|
252
281
|
});
|
|
253
|
-
delivery.bindProtocolCleanup(handler.cleanup);
|
|
254
282
|
|
|
255
283
|
delivery.bindSinkDetach(
|
|
256
284
|
// ARCH-030: through the connection's own delivery, so payload frames share the text
|
|
@@ -274,10 +302,14 @@ export class WsTransport
|
|
|
274
302
|
ws.on('close', delivery.close);
|
|
275
303
|
ws.on('error', delivery.close);
|
|
276
304
|
|
|
277
|
-
delivery.deliver({
|
|
305
|
+
delivery.deliver({
|
|
306
|
+
type: 'messages',
|
|
307
|
+
messages: connectionSession.getMessages(),
|
|
308
|
+
...(this.handlerOptions.driverId ? { driverId: this.handlerOptions.driverId } : {}),
|
|
309
|
+
});
|
|
278
310
|
delivery.deliver({
|
|
279
311
|
type: 'execution_workspace_event',
|
|
280
|
-
snapshot:
|
|
312
|
+
snapshot: connectionSession.getExecutionWorkspaceSnapshot(),
|
|
281
313
|
});
|
|
282
314
|
});
|
|
283
315
|
|
package/src/ws-transport.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface IWsTransportOptions {
|
|
|
27
27
|
personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
28
28
|
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
29
29
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
30
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
31
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
30
32
|
driverId?: TDriverId;
|
|
31
33
|
surface?: TUsageSurface;
|
|
32
34
|
}
|
|
@@ -75,6 +77,8 @@ export function createWsTransport(options: IWsTransportOptions): IWsTransport {
|
|
|
75
77
|
...(options.storedSessionUsageReporter
|
|
76
78
|
? { storedSessionUsageReporter: options.storedSessionUsageReporter }
|
|
77
79
|
: {}),
|
|
80
|
+
...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),
|
|
81
|
+
...(options.settingsReporter ? { settingsReporter: options.settingsReporter } : {}),
|
|
78
82
|
});
|
|
79
83
|
cleanup = handler.cleanup;
|
|
80
84
|
this.onMessage = handler.onMessage;
|