@flowy-agent-store/client 0.1.0-beta.2 → 0.1.0-beta.3
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/dist/index.cjs +140 -12
- package/dist/index.d.cts +68 -2
- package/dist/index.d.mts +68 -2
- package/dist/index.mjs +140 -12
- package/package.json +41 -32
package/dist/index.cjs
CHANGED
|
@@ -26,49 +26,74 @@ function isLoopbackUrl(raw) {
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/** How long an unopened `connect()` waits before it fails. */
|
|
30
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
29
31
|
var WebSocketTransport = class {
|
|
30
32
|
socket = null;
|
|
31
33
|
nextId = 1;
|
|
32
34
|
pending = /* @__PURE__ */ new Map();
|
|
33
35
|
listeners = /* @__PURE__ */ new Set();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
lifecycleListeners = /* @__PURE__ */ new Set();
|
|
37
|
+
lifecycle = null;
|
|
36
38
|
closedByCaller = false;
|
|
39
|
+
/** Single in-flight connect shared by concurrent callers; null once settled. */
|
|
40
|
+
connecting = null;
|
|
37
41
|
requestTimeoutMs;
|
|
42
|
+
connectTimeoutMs;
|
|
38
43
|
url;
|
|
39
44
|
token;
|
|
40
45
|
constructor(url, options = {}) {
|
|
41
46
|
this.url = url;
|
|
42
47
|
this.token = options.token;
|
|
43
48
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
49
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
44
50
|
}
|
|
45
51
|
get connected() {
|
|
46
52
|
return this.socket?.readyState === WebSocket.OPEN;
|
|
47
53
|
}
|
|
48
54
|
connect() {
|
|
49
55
|
if (this.connected) return Promise.resolve();
|
|
56
|
+
if (this.connecting) return this.connecting.promise;
|
|
50
57
|
this.closedByCaller = false;
|
|
51
58
|
const target = this.token ? `${this.url}${this.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.token)}` : this.url;
|
|
52
59
|
const socket = new WebSocket(target);
|
|
53
60
|
this.socket = socket;
|
|
61
|
+
let resolveConnect;
|
|
62
|
+
let rejectConnect;
|
|
63
|
+
const promise = new Promise((resolve, reject) => {
|
|
64
|
+
resolveConnect = resolve;
|
|
65
|
+
rejectConnect = reject;
|
|
66
|
+
});
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
this.detachSocket(socket);
|
|
69
|
+
this.rejectConnect(new _flowy_agent_store_protocol.TransportError("connect", `app-server connect timed out after ${this.connectTimeoutMs}ms`, { retryable: true }));
|
|
70
|
+
}, this.connectTimeoutMs);
|
|
71
|
+
this.connecting = {
|
|
72
|
+
promise,
|
|
73
|
+
resolve: resolveConnect,
|
|
74
|
+
reject: rejectConnect,
|
|
75
|
+
timer
|
|
76
|
+
};
|
|
54
77
|
socket.onopen = () => {
|
|
55
|
-
|
|
78
|
+
if (this.socket !== socket) return;
|
|
79
|
+
this.resolveConnect();
|
|
80
|
+
this.setLifecycle("open");
|
|
56
81
|
};
|
|
57
82
|
socket.onmessage = (event) => {
|
|
83
|
+
if (this.socket !== socket) return;
|
|
58
84
|
this.handleMessage(event.data);
|
|
59
85
|
};
|
|
60
86
|
socket.onclose = (event) => {
|
|
87
|
+
if (this.socket !== socket) return;
|
|
61
88
|
this.failAllPending(new _flowy_agent_store_protocol.TransportError("close", `app-server connection closed (code ${event.code})`, { retryable: !this.closedByCaller }));
|
|
62
89
|
this.socket = null;
|
|
63
|
-
|
|
90
|
+
this.setLifecycle("closed");
|
|
91
|
+
this.rejectConnect(new _flowy_agent_store_protocol.TransportError("connect", "app-server connection closed before opening"));
|
|
64
92
|
};
|
|
65
93
|
socket.onerror = () => {
|
|
66
|
-
this.socket
|
|
94
|
+
if (this.socket === socket) socket.close();
|
|
67
95
|
};
|
|
68
|
-
return
|
|
69
|
-
this.connectResolvers.push(resolve);
|
|
70
|
-
this.connectRejecters.push(reject);
|
|
71
|
-
});
|
|
96
|
+
return promise;
|
|
72
97
|
}
|
|
73
98
|
request(method, params) {
|
|
74
99
|
const socket = this.requireSocket();
|
|
@@ -118,15 +143,62 @@ var WebSocketTransport = class {
|
|
|
118
143
|
this.listeners.delete(listener);
|
|
119
144
|
};
|
|
120
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Observe the channel lifecycle. `closed` is only reported when an
|
|
148
|
+
* established connection is lost (a failed first dial is not a disconnect),
|
|
149
|
+
* and `open` follows it on the next successful dial — that pair is what a
|
|
150
|
+
* host should treat as a reconnect. Unlike `onNotification`, these listeners
|
|
151
|
+
* survive `close()`, since they exist to drive the reconnection itself.
|
|
152
|
+
*/
|
|
153
|
+
onLifecycle(listener) {
|
|
154
|
+
this.lifecycleListeners.add(listener);
|
|
155
|
+
return () => {
|
|
156
|
+
this.lifecycleListeners.delete(listener);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
121
159
|
close() {
|
|
122
160
|
this.closedByCaller = true;
|
|
123
|
-
this.socket
|
|
124
|
-
this.
|
|
161
|
+
this.detachSocket(this.socket);
|
|
162
|
+
this.rejectConnect(new _flowy_agent_store_protocol.TransportError("close", "app-server transport closed by caller"));
|
|
125
163
|
for (const [, pending] of this.pending) {
|
|
126
164
|
clearTimeout(pending.timer);
|
|
127
165
|
pending.reject(new _flowy_agent_store_protocol.TransportError("close", "app-server transport closed by caller"));
|
|
128
166
|
}
|
|
129
167
|
this.pending.clear();
|
|
168
|
+
this.listeners.clear();
|
|
169
|
+
this.lifecycle = null;
|
|
170
|
+
}
|
|
171
|
+
setLifecycle(state) {
|
|
172
|
+
if (state === "closed" && this.lifecycle !== "open") return;
|
|
173
|
+
if (this.lifecycle === state) return;
|
|
174
|
+
this.lifecycle = state;
|
|
175
|
+
for (const listener of [...this.lifecycleListeners]) try {
|
|
176
|
+
listener(state);
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
/** Detach the current socket (if any) and close it; its later events are ignored. */
|
|
180
|
+
detachSocket(socket) {
|
|
181
|
+
if (!socket) return;
|
|
182
|
+
if (this.socket === socket) this.socket = null;
|
|
183
|
+
try {
|
|
184
|
+
socket.close();
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
/** Settle the in-flight `connect()` successfully, when there is one. */
|
|
188
|
+
resolveConnect() {
|
|
189
|
+
const connecting = this.connecting;
|
|
190
|
+
if (!connecting) return;
|
|
191
|
+
this.connecting = null;
|
|
192
|
+
clearTimeout(connecting.timer);
|
|
193
|
+
connecting.resolve();
|
|
194
|
+
}
|
|
195
|
+
/** Fail the in-flight `connect()`, when there is one. */
|
|
196
|
+
rejectConnect(error) {
|
|
197
|
+
const connecting = this.connecting;
|
|
198
|
+
if (!connecting) return;
|
|
199
|
+
this.connecting = null;
|
|
200
|
+
clearTimeout(connecting.timer);
|
|
201
|
+
connecting.reject(error);
|
|
130
202
|
}
|
|
131
203
|
requireSocket() {
|
|
132
204
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) throw new _flowy_agent_store_protocol.TransportError("send", "app-server transport is not connected", { retryable: true });
|
|
@@ -281,6 +353,21 @@ var ConversationSubscription = class {
|
|
|
281
353
|
await this.transport.request("conversation/unsubscribe", { conversation_id: this.conversationId });
|
|
282
354
|
} catch {}
|
|
283
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
|
|
358
|
+
* notification listener, reset the cursor and re-issue
|
|
359
|
+
* `conversation/subscribe`. This subscription has no event-replay API, so the
|
|
360
|
+
* caller must still backfill the transcript for the outage window
|
|
361
|
+
* (`conversation/messages`). Call it once the handshake completed; a no-op
|
|
362
|
+
* once closed.
|
|
363
|
+
*/
|
|
364
|
+
async rearm() {
|
|
365
|
+
if (this.closed) return;
|
|
366
|
+
this.removeListener?.();
|
|
367
|
+
this.removeListener = this.transport.onNotification((notification) => this.dispatch(notification));
|
|
368
|
+
this.lastSeenSequence = 0;
|
|
369
|
+
await this.transport.request("conversation/subscribe", { conversation_id: this.conversationId });
|
|
370
|
+
}
|
|
284
371
|
dispatch(notification) {
|
|
285
372
|
if (notification.method === "conversation/event") {
|
|
286
373
|
const event = notification.params;
|
|
@@ -520,6 +607,33 @@ var EventSubscription = class {
|
|
|
520
607
|
if (this.resyncInFlight === task) this.resyncInFlight = null;
|
|
521
608
|
}
|
|
522
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Re-arm after the connection was re-established (docs/agent-store/16 T8).
|
|
612
|
+
* A new socket means the server dropped its subscriptions and the transport
|
|
613
|
+
* dropped its notification listeners, so this:
|
|
614
|
+
*
|
|
615
|
+
* 1. re-registers this subscription's notification listener,
|
|
616
|
+
* 2. resets the cursor to 0 and forgets the dedupe set, then
|
|
617
|
+
* 3. re-issues `run/subscribe` and replays **all** persisted events.
|
|
618
|
+
*
|
|
619
|
+
* The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
|
|
620
|
+
* consumer must dedupe by `sequence`, since events seen before the outage
|
|
621
|
+
* are delivered again. Call it only once the session handshake has completed
|
|
622
|
+
* again — the transport's `open` event fires before `initialize`. A no-op
|
|
623
|
+
* once closed.
|
|
624
|
+
*/
|
|
625
|
+
async rearm() {
|
|
626
|
+
if (this.closed || !this.fetchAfter) return [];
|
|
627
|
+
this.unsubscribeNotification?.();
|
|
628
|
+
this.unsubscribeNotification = this.transport.onNotification((notification) => {
|
|
629
|
+
this.dispatch(notification);
|
|
630
|
+
});
|
|
631
|
+
this.lastSeenSequence = 0;
|
|
632
|
+
this.seenSequences.clear();
|
|
633
|
+
this.resyncInFlight = null;
|
|
634
|
+
await this.transport.request("run/subscribe", { run_id: this.runId });
|
|
635
|
+
return this.resync();
|
|
636
|
+
}
|
|
523
637
|
markSeen(sequence) {
|
|
524
638
|
this.seenSequences.add(sequence);
|
|
525
639
|
if (sequence > this.lastSeenSequence) this.lastSeenSequence = sequence;
|
|
@@ -666,6 +780,8 @@ var AppServerClient = class {
|
|
|
666
780
|
capabilities;
|
|
667
781
|
initializeResult = null;
|
|
668
782
|
notificationListeners = /* @__PURE__ */ new Set();
|
|
783
|
+
/** Transport → client notification bridge; re-armed after a transport close. */
|
|
784
|
+
unbridgeNotifications = null;
|
|
669
785
|
constructor(options) {
|
|
670
786
|
this.clientInfo = options.client;
|
|
671
787
|
this.capabilities = options.capabilities;
|
|
@@ -678,7 +794,16 @@ var AppServerClient = class {
|
|
|
678
794
|
this.agents = new AgentClient(this.transport);
|
|
679
795
|
this.teams = new TeamClient(this.transport);
|
|
680
796
|
this.models = new ModelClient(this.transport);
|
|
681
|
-
this.
|
|
797
|
+
this.bridgeNotifications();
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Subscribe the client-level fan-out on the transport exactly once.
|
|
801
|
+
* `transport.close()` drops its listeners, so this is also called on
|
|
802
|
+
* `connect()`: otherwise a reconnect would leave notifications silent.
|
|
803
|
+
*/
|
|
804
|
+
bridgeNotifications() {
|
|
805
|
+
if (this.unbridgeNotifications) return;
|
|
806
|
+
this.unbridgeNotifications = this.transport.onNotification((notification) => {
|
|
682
807
|
for (const listener of [...this.notificationListeners]) try {
|
|
683
808
|
listener(notification);
|
|
684
809
|
} catch {}
|
|
@@ -692,6 +817,7 @@ var AppServerClient = class {
|
|
|
692
817
|
}
|
|
693
818
|
/** Connect and perform the initialize/initialized handshake. */
|
|
694
819
|
async connect() {
|
|
820
|
+
this.bridgeNotifications();
|
|
695
821
|
await this.transport.connect();
|
|
696
822
|
const request = {
|
|
697
823
|
protocol_version: _flowy_agent_store_protocol.APP_SERVER_PROTOCOL_VERSION,
|
|
@@ -712,6 +838,8 @@ var AppServerClient = class {
|
|
|
712
838
|
}
|
|
713
839
|
/** Close the transport; the server revokes the connection immediately. */
|
|
714
840
|
close() {
|
|
841
|
+
this.unbridgeNotifications?.();
|
|
842
|
+
this.unbridgeNotifications = null;
|
|
715
843
|
this.transport.close();
|
|
716
844
|
this.initializeResult = null;
|
|
717
845
|
this.notificationListeners.clear();
|
package/dist/index.d.cts
CHANGED
|
@@ -15,15 +15,30 @@ export declare function isLoopbackUrl(raw: string): boolean;
|
|
|
15
15
|
* so business code never knows whether it runs over WebSocket (browser),
|
|
16
16
|
* stdio (Node/CLI, future) or a one-shot HTTP binding.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* Lifecycle of the underlying channel. `open` is emitted on every successful
|
|
20
|
+
* dial (the first one included), `closed` only when an established connection
|
|
21
|
+
* is lost — so `closed` → `open` is exactly a reconnect.
|
|
22
|
+
*/
|
|
23
|
+
export type TransportLifecycle = "open" | "closed";
|
|
24
|
+
export type TransportLifecycleListener = (state: TransportLifecycle) => void;
|
|
18
25
|
export interface Transport {
|
|
19
26
|
connect(): Promise<void>;
|
|
20
27
|
request<T>(method: string, params: unknown): Promise<T>;
|
|
21
28
|
notify(method: string, params: unknown): void;
|
|
22
29
|
onNotification(listener: NotificationListener): () => void;
|
|
23
30
|
close(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Optional: observe the channel lifecycle so a host can show a disconnect
|
|
33
|
+
* banner and re-arm subscriptions on reconnect (docs/agent-store/16 T8/W8).
|
|
34
|
+
* Custom transports may omit it.
|
|
35
|
+
*/
|
|
36
|
+
onLifecycle?(listener: TransportLifecycleListener): () => void;
|
|
24
37
|
}
|
|
25
38
|
export interface TransportOptions {
|
|
26
39
|
requestTimeoutMs?: number;
|
|
40
|
+
/** Max time `connect()` may stay unopened before failing (default 10s). */
|
|
41
|
+
connectTimeoutMs?: number;
|
|
27
42
|
token?: string;
|
|
28
43
|
}
|
|
29
44
|
export declare class WebSocketTransport implements Transport {
|
|
@@ -31,10 +46,13 @@ export declare class WebSocketTransport implements Transport {
|
|
|
31
46
|
private nextId;
|
|
32
47
|
private pending;
|
|
33
48
|
private listeners;
|
|
34
|
-
private
|
|
35
|
-
private
|
|
49
|
+
private lifecycleListeners;
|
|
50
|
+
private lifecycle;
|
|
36
51
|
private closedByCaller;
|
|
52
|
+
/** Single in-flight connect shared by concurrent callers; null once settled. */
|
|
53
|
+
private connecting;
|
|
37
54
|
readonly requestTimeoutMs: number;
|
|
55
|
+
readonly connectTimeoutMs: number;
|
|
38
56
|
readonly url: string;
|
|
39
57
|
readonly token?: string;
|
|
40
58
|
constructor(url: string, options?: TransportOptions);
|
|
@@ -43,7 +61,22 @@ export declare class WebSocketTransport implements Transport {
|
|
|
43
61
|
request<T>(method: string, params: unknown): Promise<T>;
|
|
44
62
|
notify(method: string, params: unknown): void;
|
|
45
63
|
onNotification(listener: NotificationListener): () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Observe the channel lifecycle. `closed` is only reported when an
|
|
66
|
+
* established connection is lost (a failed first dial is not a disconnect),
|
|
67
|
+
* and `open` follows it on the next successful dial — that pair is what a
|
|
68
|
+
* host should treat as a reconnect. Unlike `onNotification`, these listeners
|
|
69
|
+
* survive `close()`, since they exist to drive the reconnection itself.
|
|
70
|
+
*/
|
|
71
|
+
onLifecycle(listener: TransportLifecycleListener): () => void;
|
|
46
72
|
close(): void;
|
|
73
|
+
private setLifecycle;
|
|
74
|
+
/** Detach the current socket (if any) and close it; its later events are ignored. */
|
|
75
|
+
private detachSocket;
|
|
76
|
+
/** Settle the in-flight `connect()` successfully, when there is one. */
|
|
77
|
+
private resolveConnect;
|
|
78
|
+
/** Fail the in-flight `connect()`, when there is one. */
|
|
79
|
+
private rejectConnect;
|
|
47
80
|
private requireSocket;
|
|
48
81
|
private handleMessage;
|
|
49
82
|
private failAllPending;
|
|
@@ -92,6 +125,15 @@ export declare class ConversationSubscription {
|
|
|
92
125
|
onEvent(listener: ConversationEventListener): () => void;
|
|
93
126
|
onResync(listener: ConversationResyncListener): () => void;
|
|
94
127
|
close(): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
|
|
130
|
+
* notification listener, reset the cursor and re-issue
|
|
131
|
+
* `conversation/subscribe`. This subscription has no event-replay API, so the
|
|
132
|
+
* caller must still backfill the transcript for the outage window
|
|
133
|
+
* (`conversation/messages`). Call it once the handshake completed; a no-op
|
|
134
|
+
* once closed.
|
|
135
|
+
*/
|
|
136
|
+
rearm(): Promise<void>;
|
|
95
137
|
private dispatch;
|
|
96
138
|
}
|
|
97
139
|
//#endregion
|
|
@@ -184,6 +226,22 @@ export declare class EventSubscription {
|
|
|
184
226
|
* Concurrent calls share one flight.
|
|
185
227
|
*/
|
|
186
228
|
resync(): Promise<RunEvent[]>;
|
|
229
|
+
/**
|
|
230
|
+
* Re-arm after the connection was re-established (docs/agent-store/16 T8).
|
|
231
|
+
* A new socket means the server dropped its subscriptions and the transport
|
|
232
|
+
* dropped its notification listeners, so this:
|
|
233
|
+
*
|
|
234
|
+
* 1. re-registers this subscription's notification listener,
|
|
235
|
+
* 2. resets the cursor to 0 and forgets the dedupe set, then
|
|
236
|
+
* 3. re-issues `run/subscribe` and replays **all** persisted events.
|
|
237
|
+
*
|
|
238
|
+
* The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
|
|
239
|
+
* consumer must dedupe by `sequence`, since events seen before the outage
|
|
240
|
+
* are delivered again. Call it only once the session handshake has completed
|
|
241
|
+
* again — the transport's `open` event fires before `initialize`. A no-op
|
|
242
|
+
* once closed.
|
|
243
|
+
*/
|
|
244
|
+
rearm(): Promise<RunEvent[]>;
|
|
187
245
|
private markSeen;
|
|
188
246
|
private emitEvent;
|
|
189
247
|
private emitError;
|
|
@@ -269,7 +327,15 @@ export declare class AppServerClient {
|
|
|
269
327
|
readonly capabilities?: ClientCapabilities;
|
|
270
328
|
private initializeResult;
|
|
271
329
|
private notificationListeners;
|
|
330
|
+
/** Transport → client notification bridge; re-armed after a transport close. */
|
|
331
|
+
private unbridgeNotifications;
|
|
272
332
|
constructor(options: AppServerClientOptions);
|
|
333
|
+
/**
|
|
334
|
+
* Subscribe the client-level fan-out on the transport exactly once.
|
|
335
|
+
* `transport.close()` drops its listeners, so this is also called on
|
|
336
|
+
* `connect()`: otherwise a reconnect would leave notifications silent.
|
|
337
|
+
*/
|
|
338
|
+
private bridgeNotifications;
|
|
273
339
|
get ready(): boolean;
|
|
274
340
|
get initializeInfo(): InitializeResult | null;
|
|
275
341
|
/** Connect and perform the initialize/initialized handshake. */
|
package/dist/index.d.mts
CHANGED
|
@@ -15,15 +15,30 @@ export declare function isLoopbackUrl(raw: string): boolean;
|
|
|
15
15
|
* so business code never knows whether it runs over WebSocket (browser),
|
|
16
16
|
* stdio (Node/CLI, future) or a one-shot HTTP binding.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* Lifecycle of the underlying channel. `open` is emitted on every successful
|
|
20
|
+
* dial (the first one included), `closed` only when an established connection
|
|
21
|
+
* is lost — so `closed` → `open` is exactly a reconnect.
|
|
22
|
+
*/
|
|
23
|
+
export type TransportLifecycle = "open" | "closed";
|
|
24
|
+
export type TransportLifecycleListener = (state: TransportLifecycle) => void;
|
|
18
25
|
export interface Transport {
|
|
19
26
|
connect(): Promise<void>;
|
|
20
27
|
request<T>(method: string, params: unknown): Promise<T>;
|
|
21
28
|
notify(method: string, params: unknown): void;
|
|
22
29
|
onNotification(listener: NotificationListener): () => void;
|
|
23
30
|
close(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Optional: observe the channel lifecycle so a host can show a disconnect
|
|
33
|
+
* banner and re-arm subscriptions on reconnect (docs/agent-store/16 T8/W8).
|
|
34
|
+
* Custom transports may omit it.
|
|
35
|
+
*/
|
|
36
|
+
onLifecycle?(listener: TransportLifecycleListener): () => void;
|
|
24
37
|
}
|
|
25
38
|
export interface TransportOptions {
|
|
26
39
|
requestTimeoutMs?: number;
|
|
40
|
+
/** Max time `connect()` may stay unopened before failing (default 10s). */
|
|
41
|
+
connectTimeoutMs?: number;
|
|
27
42
|
token?: string;
|
|
28
43
|
}
|
|
29
44
|
export declare class WebSocketTransport implements Transport {
|
|
@@ -31,10 +46,13 @@ export declare class WebSocketTransport implements Transport {
|
|
|
31
46
|
private nextId;
|
|
32
47
|
private pending;
|
|
33
48
|
private listeners;
|
|
34
|
-
private
|
|
35
|
-
private
|
|
49
|
+
private lifecycleListeners;
|
|
50
|
+
private lifecycle;
|
|
36
51
|
private closedByCaller;
|
|
52
|
+
/** Single in-flight connect shared by concurrent callers; null once settled. */
|
|
53
|
+
private connecting;
|
|
37
54
|
readonly requestTimeoutMs: number;
|
|
55
|
+
readonly connectTimeoutMs: number;
|
|
38
56
|
readonly url: string;
|
|
39
57
|
readonly token?: string;
|
|
40
58
|
constructor(url: string, options?: TransportOptions);
|
|
@@ -43,7 +61,22 @@ export declare class WebSocketTransport implements Transport {
|
|
|
43
61
|
request<T>(method: string, params: unknown): Promise<T>;
|
|
44
62
|
notify(method: string, params: unknown): void;
|
|
45
63
|
onNotification(listener: NotificationListener): () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Observe the channel lifecycle. `closed` is only reported when an
|
|
66
|
+
* established connection is lost (a failed first dial is not a disconnect),
|
|
67
|
+
* and `open` follows it on the next successful dial — that pair is what a
|
|
68
|
+
* host should treat as a reconnect. Unlike `onNotification`, these listeners
|
|
69
|
+
* survive `close()`, since they exist to drive the reconnection itself.
|
|
70
|
+
*/
|
|
71
|
+
onLifecycle(listener: TransportLifecycleListener): () => void;
|
|
46
72
|
close(): void;
|
|
73
|
+
private setLifecycle;
|
|
74
|
+
/** Detach the current socket (if any) and close it; its later events are ignored. */
|
|
75
|
+
private detachSocket;
|
|
76
|
+
/** Settle the in-flight `connect()` successfully, when there is one. */
|
|
77
|
+
private resolveConnect;
|
|
78
|
+
/** Fail the in-flight `connect()`, when there is one. */
|
|
79
|
+
private rejectConnect;
|
|
47
80
|
private requireSocket;
|
|
48
81
|
private handleMessage;
|
|
49
82
|
private failAllPending;
|
|
@@ -92,6 +125,15 @@ export declare class ConversationSubscription {
|
|
|
92
125
|
onEvent(listener: ConversationEventListener): () => void;
|
|
93
126
|
onResync(listener: ConversationResyncListener): () => void;
|
|
94
127
|
close(): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
|
|
130
|
+
* notification listener, reset the cursor and re-issue
|
|
131
|
+
* `conversation/subscribe`. This subscription has no event-replay API, so the
|
|
132
|
+
* caller must still backfill the transcript for the outage window
|
|
133
|
+
* (`conversation/messages`). Call it once the handshake completed; a no-op
|
|
134
|
+
* once closed.
|
|
135
|
+
*/
|
|
136
|
+
rearm(): Promise<void>;
|
|
95
137
|
private dispatch;
|
|
96
138
|
}
|
|
97
139
|
//#endregion
|
|
@@ -184,6 +226,22 @@ export declare class EventSubscription {
|
|
|
184
226
|
* Concurrent calls share one flight.
|
|
185
227
|
*/
|
|
186
228
|
resync(): Promise<RunEvent[]>;
|
|
229
|
+
/**
|
|
230
|
+
* Re-arm after the connection was re-established (docs/agent-store/16 T8).
|
|
231
|
+
* A new socket means the server dropped its subscriptions and the transport
|
|
232
|
+
* dropped its notification listeners, so this:
|
|
233
|
+
*
|
|
234
|
+
* 1. re-registers this subscription's notification listener,
|
|
235
|
+
* 2. resets the cursor to 0 and forgets the dedupe set, then
|
|
236
|
+
* 3. re-issues `run/subscribe` and replays **all** persisted events.
|
|
237
|
+
*
|
|
238
|
+
* The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
|
|
239
|
+
* consumer must dedupe by `sequence`, since events seen before the outage
|
|
240
|
+
* are delivered again. Call it only once the session handshake has completed
|
|
241
|
+
* again — the transport's `open` event fires before `initialize`. A no-op
|
|
242
|
+
* once closed.
|
|
243
|
+
*/
|
|
244
|
+
rearm(): Promise<RunEvent[]>;
|
|
187
245
|
private markSeen;
|
|
188
246
|
private emitEvent;
|
|
189
247
|
private emitError;
|
|
@@ -269,7 +327,15 @@ export declare class AppServerClient {
|
|
|
269
327
|
readonly capabilities?: ClientCapabilities;
|
|
270
328
|
private initializeResult;
|
|
271
329
|
private notificationListeners;
|
|
330
|
+
/** Transport → client notification bridge; re-armed after a transport close. */
|
|
331
|
+
private unbridgeNotifications;
|
|
272
332
|
constructor(options: AppServerClientOptions);
|
|
333
|
+
/**
|
|
334
|
+
* Subscribe the client-level fan-out on the transport exactly once.
|
|
335
|
+
* `transport.close()` drops its listeners, so this is also called on
|
|
336
|
+
* `connect()`: otherwise a reconnect would leave notifications silent.
|
|
337
|
+
*/
|
|
338
|
+
private bridgeNotifications;
|
|
273
339
|
get ready(): boolean;
|
|
274
340
|
get initializeInfo(): InitializeResult | null;
|
|
275
341
|
/** Connect and perform the initialize/initialized handshake. */
|
package/dist/index.mjs
CHANGED
|
@@ -25,49 +25,74 @@ function isLoopbackUrl(raw) {
|
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** How long an unopened `connect()` waits before it fails. */
|
|
29
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
28
30
|
var WebSocketTransport = class {
|
|
29
31
|
socket = null;
|
|
30
32
|
nextId = 1;
|
|
31
33
|
pending = /* @__PURE__ */ new Map();
|
|
32
34
|
listeners = /* @__PURE__ */ new Set();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
lifecycleListeners = /* @__PURE__ */ new Set();
|
|
36
|
+
lifecycle = null;
|
|
35
37
|
closedByCaller = false;
|
|
38
|
+
/** Single in-flight connect shared by concurrent callers; null once settled. */
|
|
39
|
+
connecting = null;
|
|
36
40
|
requestTimeoutMs;
|
|
41
|
+
connectTimeoutMs;
|
|
37
42
|
url;
|
|
38
43
|
token;
|
|
39
44
|
constructor(url, options = {}) {
|
|
40
45
|
this.url = url;
|
|
41
46
|
this.token = options.token;
|
|
42
47
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
48
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
43
49
|
}
|
|
44
50
|
get connected() {
|
|
45
51
|
return this.socket?.readyState === WebSocket.OPEN;
|
|
46
52
|
}
|
|
47
53
|
connect() {
|
|
48
54
|
if (this.connected) return Promise.resolve();
|
|
55
|
+
if (this.connecting) return this.connecting.promise;
|
|
49
56
|
this.closedByCaller = false;
|
|
50
57
|
const target = this.token ? `${this.url}${this.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.token)}` : this.url;
|
|
51
58
|
const socket = new WebSocket(target);
|
|
52
59
|
this.socket = socket;
|
|
60
|
+
let resolveConnect;
|
|
61
|
+
let rejectConnect;
|
|
62
|
+
const promise = new Promise((resolve, reject) => {
|
|
63
|
+
resolveConnect = resolve;
|
|
64
|
+
rejectConnect = reject;
|
|
65
|
+
});
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
this.detachSocket(socket);
|
|
68
|
+
this.rejectConnect(new TransportError("connect", `app-server connect timed out after ${this.connectTimeoutMs}ms`, { retryable: true }));
|
|
69
|
+
}, this.connectTimeoutMs);
|
|
70
|
+
this.connecting = {
|
|
71
|
+
promise,
|
|
72
|
+
resolve: resolveConnect,
|
|
73
|
+
reject: rejectConnect,
|
|
74
|
+
timer
|
|
75
|
+
};
|
|
53
76
|
socket.onopen = () => {
|
|
54
|
-
|
|
77
|
+
if (this.socket !== socket) return;
|
|
78
|
+
this.resolveConnect();
|
|
79
|
+
this.setLifecycle("open");
|
|
55
80
|
};
|
|
56
81
|
socket.onmessage = (event) => {
|
|
82
|
+
if (this.socket !== socket) return;
|
|
57
83
|
this.handleMessage(event.data);
|
|
58
84
|
};
|
|
59
85
|
socket.onclose = (event) => {
|
|
86
|
+
if (this.socket !== socket) return;
|
|
60
87
|
this.failAllPending(new TransportError("close", `app-server connection closed (code ${event.code})`, { retryable: !this.closedByCaller }));
|
|
61
88
|
this.socket = null;
|
|
62
|
-
|
|
89
|
+
this.setLifecycle("closed");
|
|
90
|
+
this.rejectConnect(new TransportError("connect", "app-server connection closed before opening"));
|
|
63
91
|
};
|
|
64
92
|
socket.onerror = () => {
|
|
65
|
-
this.socket
|
|
93
|
+
if (this.socket === socket) socket.close();
|
|
66
94
|
};
|
|
67
|
-
return
|
|
68
|
-
this.connectResolvers.push(resolve);
|
|
69
|
-
this.connectRejecters.push(reject);
|
|
70
|
-
});
|
|
95
|
+
return promise;
|
|
71
96
|
}
|
|
72
97
|
request(method, params) {
|
|
73
98
|
const socket = this.requireSocket();
|
|
@@ -117,15 +142,62 @@ var WebSocketTransport = class {
|
|
|
117
142
|
this.listeners.delete(listener);
|
|
118
143
|
};
|
|
119
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Observe the channel lifecycle. `closed` is only reported when an
|
|
147
|
+
* established connection is lost (a failed first dial is not a disconnect),
|
|
148
|
+
* and `open` follows it on the next successful dial — that pair is what a
|
|
149
|
+
* host should treat as a reconnect. Unlike `onNotification`, these listeners
|
|
150
|
+
* survive `close()`, since they exist to drive the reconnection itself.
|
|
151
|
+
*/
|
|
152
|
+
onLifecycle(listener) {
|
|
153
|
+
this.lifecycleListeners.add(listener);
|
|
154
|
+
return () => {
|
|
155
|
+
this.lifecycleListeners.delete(listener);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
120
158
|
close() {
|
|
121
159
|
this.closedByCaller = true;
|
|
122
|
-
this.socket
|
|
123
|
-
this.
|
|
160
|
+
this.detachSocket(this.socket);
|
|
161
|
+
this.rejectConnect(new TransportError("close", "app-server transport closed by caller"));
|
|
124
162
|
for (const [, pending] of this.pending) {
|
|
125
163
|
clearTimeout(pending.timer);
|
|
126
164
|
pending.reject(new TransportError("close", "app-server transport closed by caller"));
|
|
127
165
|
}
|
|
128
166
|
this.pending.clear();
|
|
167
|
+
this.listeners.clear();
|
|
168
|
+
this.lifecycle = null;
|
|
169
|
+
}
|
|
170
|
+
setLifecycle(state) {
|
|
171
|
+
if (state === "closed" && this.lifecycle !== "open") return;
|
|
172
|
+
if (this.lifecycle === state) return;
|
|
173
|
+
this.lifecycle = state;
|
|
174
|
+
for (const listener of [...this.lifecycleListeners]) try {
|
|
175
|
+
listener(state);
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
/** Detach the current socket (if any) and close it; its later events are ignored. */
|
|
179
|
+
detachSocket(socket) {
|
|
180
|
+
if (!socket) return;
|
|
181
|
+
if (this.socket === socket) this.socket = null;
|
|
182
|
+
try {
|
|
183
|
+
socket.close();
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
/** Settle the in-flight `connect()` successfully, when there is one. */
|
|
187
|
+
resolveConnect() {
|
|
188
|
+
const connecting = this.connecting;
|
|
189
|
+
if (!connecting) return;
|
|
190
|
+
this.connecting = null;
|
|
191
|
+
clearTimeout(connecting.timer);
|
|
192
|
+
connecting.resolve();
|
|
193
|
+
}
|
|
194
|
+
/** Fail the in-flight `connect()`, when there is one. */
|
|
195
|
+
rejectConnect(error) {
|
|
196
|
+
const connecting = this.connecting;
|
|
197
|
+
if (!connecting) return;
|
|
198
|
+
this.connecting = null;
|
|
199
|
+
clearTimeout(connecting.timer);
|
|
200
|
+
connecting.reject(error);
|
|
129
201
|
}
|
|
130
202
|
requireSocket() {
|
|
131
203
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) throw new TransportError("send", "app-server transport is not connected", { retryable: true });
|
|
@@ -280,6 +352,21 @@ var ConversationSubscription = class {
|
|
|
280
352
|
await this.transport.request("conversation/unsubscribe", { conversation_id: this.conversationId });
|
|
281
353
|
} catch {}
|
|
282
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
|
|
357
|
+
* notification listener, reset the cursor and re-issue
|
|
358
|
+
* `conversation/subscribe`. This subscription has no event-replay API, so the
|
|
359
|
+
* caller must still backfill the transcript for the outage window
|
|
360
|
+
* (`conversation/messages`). Call it once the handshake completed; a no-op
|
|
361
|
+
* once closed.
|
|
362
|
+
*/
|
|
363
|
+
async rearm() {
|
|
364
|
+
if (this.closed) return;
|
|
365
|
+
this.removeListener?.();
|
|
366
|
+
this.removeListener = this.transport.onNotification((notification) => this.dispatch(notification));
|
|
367
|
+
this.lastSeenSequence = 0;
|
|
368
|
+
await this.transport.request("conversation/subscribe", { conversation_id: this.conversationId });
|
|
369
|
+
}
|
|
283
370
|
dispatch(notification) {
|
|
284
371
|
if (notification.method === "conversation/event") {
|
|
285
372
|
const event = notification.params;
|
|
@@ -519,6 +606,33 @@ var EventSubscription = class {
|
|
|
519
606
|
if (this.resyncInFlight === task) this.resyncInFlight = null;
|
|
520
607
|
}
|
|
521
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* Re-arm after the connection was re-established (docs/agent-store/16 T8).
|
|
611
|
+
* A new socket means the server dropped its subscriptions and the transport
|
|
612
|
+
* dropped its notification listeners, so this:
|
|
613
|
+
*
|
|
614
|
+
* 1. re-registers this subscription's notification listener,
|
|
615
|
+
* 2. resets the cursor to 0 and forgets the dedupe set, then
|
|
616
|
+
* 3. re-issues `run/subscribe` and replays **all** persisted events.
|
|
617
|
+
*
|
|
618
|
+
* The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
|
|
619
|
+
* consumer must dedupe by `sequence`, since events seen before the outage
|
|
620
|
+
* are delivered again. Call it only once the session handshake has completed
|
|
621
|
+
* again — the transport's `open` event fires before `initialize`. A no-op
|
|
622
|
+
* once closed.
|
|
623
|
+
*/
|
|
624
|
+
async rearm() {
|
|
625
|
+
if (this.closed || !this.fetchAfter) return [];
|
|
626
|
+
this.unsubscribeNotification?.();
|
|
627
|
+
this.unsubscribeNotification = this.transport.onNotification((notification) => {
|
|
628
|
+
this.dispatch(notification);
|
|
629
|
+
});
|
|
630
|
+
this.lastSeenSequence = 0;
|
|
631
|
+
this.seenSequences.clear();
|
|
632
|
+
this.resyncInFlight = null;
|
|
633
|
+
await this.transport.request("run/subscribe", { run_id: this.runId });
|
|
634
|
+
return this.resync();
|
|
635
|
+
}
|
|
522
636
|
markSeen(sequence) {
|
|
523
637
|
this.seenSequences.add(sequence);
|
|
524
638
|
if (sequence > this.lastSeenSequence) this.lastSeenSequence = sequence;
|
|
@@ -665,6 +779,8 @@ var AppServerClient = class {
|
|
|
665
779
|
capabilities;
|
|
666
780
|
initializeResult = null;
|
|
667
781
|
notificationListeners = /* @__PURE__ */ new Set();
|
|
782
|
+
/** Transport → client notification bridge; re-armed after a transport close. */
|
|
783
|
+
unbridgeNotifications = null;
|
|
668
784
|
constructor(options) {
|
|
669
785
|
this.clientInfo = options.client;
|
|
670
786
|
this.capabilities = options.capabilities;
|
|
@@ -677,7 +793,16 @@ var AppServerClient = class {
|
|
|
677
793
|
this.agents = new AgentClient(this.transport);
|
|
678
794
|
this.teams = new TeamClient(this.transport);
|
|
679
795
|
this.models = new ModelClient(this.transport);
|
|
680
|
-
this.
|
|
796
|
+
this.bridgeNotifications();
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Subscribe the client-level fan-out on the transport exactly once.
|
|
800
|
+
* `transport.close()` drops its listeners, so this is also called on
|
|
801
|
+
* `connect()`: otherwise a reconnect would leave notifications silent.
|
|
802
|
+
*/
|
|
803
|
+
bridgeNotifications() {
|
|
804
|
+
if (this.unbridgeNotifications) return;
|
|
805
|
+
this.unbridgeNotifications = this.transport.onNotification((notification) => {
|
|
681
806
|
for (const listener of [...this.notificationListeners]) try {
|
|
682
807
|
listener(notification);
|
|
683
808
|
} catch {}
|
|
@@ -691,6 +816,7 @@ var AppServerClient = class {
|
|
|
691
816
|
}
|
|
692
817
|
/** Connect and perform the initialize/initialized handshake. */
|
|
693
818
|
async connect() {
|
|
819
|
+
this.bridgeNotifications();
|
|
694
820
|
await this.transport.connect();
|
|
695
821
|
const request = {
|
|
696
822
|
protocol_version: APP_SERVER_PROTOCOL_VERSION,
|
|
@@ -711,6 +837,8 @@ var AppServerClient = class {
|
|
|
711
837
|
}
|
|
712
838
|
/** Close the transport; the server revokes the connection immediately. */
|
|
713
839
|
close() {
|
|
840
|
+
this.unbridgeNotifications?.();
|
|
841
|
+
this.unbridgeNotifications = null;
|
|
714
842
|
this.transport.close();
|
|
715
843
|
this.initializeResult = null;
|
|
716
844
|
this.notificationListeners.clear();
|
package/package.json
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@flowy-agent-store/client",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "Transport-agnostic App Server client (docs/agent-store/07
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "Apache-2.0",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
},
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@flowy-agent-store/client",
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
|
+
"description": "Transport-agnostic App Server client (docs/agent-store/07 §2.2)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Michael-Lfx/allo.git",
|
|
13
|
+
"directory": "web/packages/client"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"import": "./dist/index.mjs",
|
|
20
|
+
"require": "./dist/index.cjs"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@flowy-agent-store/protocol": "0.1.0-beta.3"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsdown",
|
|
32
|
+
"prepack": "tsdown"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"main": "./dist/index.cjs",
|
|
39
|
+
"module": "./dist/index.mjs",
|
|
40
|
+
"types": "./dist/index.d.mts"
|
|
41
|
+
}
|