@flowy-agent-store/client 0.1.0-beta.2 → 0.1.0-beta.4
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 +1206 -25
- package/dist/index.d.cts +500 -7
- package/dist/index.d.mts +500 -7
- package/dist/index.mjs +1202 -26
- 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 });
|
|
@@ -166,6 +238,532 @@ var WebSocketTransport = class {
|
|
|
166
238
|
}
|
|
167
239
|
};
|
|
168
240
|
//#endregion
|
|
241
|
+
//#region src/http-transport.ts
|
|
242
|
+
/**
|
|
243
|
+
* HTTP transport binding (doc `16` R2 / `05` §2.1.1).
|
|
244
|
+
*
|
|
245
|
+
* ## What this is
|
|
246
|
+
*
|
|
247
|
+
* The App Server exposes one method semantics over two bindings. This class is
|
|
248
|
+
* the **request/response** binding: it maps a protocol method name onto the
|
|
249
|
+
* server's hand-written REST route and returns the same result body the
|
|
250
|
+
* WebSocket binding would.
|
|
251
|
+
*
|
|
252
|
+
* ## What this is not
|
|
253
|
+
*
|
|
254
|
+
* - **No server push.** HTTP has no notification channel, so `onNotification`
|
|
255
|
+
* hands back a no-op unsubscribe and `notify` throws. Live conversation / run
|
|
256
|
+
* events and subscription methods (`conversation/subscribe`, `run/subscribe`)
|
|
257
|
+
* exist **only** over `WebSocketTransport`; an `HttpTransport` is not a
|
|
258
|
+
* drop-in replacement for it. Consumers that need events must connect over
|
|
259
|
+
* WS (or run both).
|
|
260
|
+
* - **One-shot handshake per call.** `05` §2.1.1: *each* HTTP call performs its
|
|
261
|
+
* own `POST /initialize` → `POST /initialized` → business call, after which
|
|
262
|
+
* the connection id expires. `connect()` therefore has nothing to hold open
|
|
263
|
+
* and is a no-op; it does not warm anything up.
|
|
264
|
+
* - **Host file services stay out.** `/api/fs/*` (browse / list / read /
|
|
265
|
+
* metadata) is a host file service on the server *root*, explicitly not a
|
|
266
|
+
* protocol method (`05` §2.1.1), so it is not reachable through this class.
|
|
267
|
+
*
|
|
268
|
+
* ## The route table
|
|
269
|
+
*
|
|
270
|
+
* Every entry was read out of the handler bodies in
|
|
271
|
+
* `crates/backend/nomifun-app-server/src/lib.rs` — the `source` field records
|
|
272
|
+
* which handler (and, where one exists, which shared `*_impl`) proves the two
|
|
273
|
+
* bindings run the same operation. Methods with no entry throw
|
|
274
|
+
* `TransportError("send", …)` instead of silently degrading.
|
|
275
|
+
*/
|
|
276
|
+
/** Header the server mints the per-call connection id under. */
|
|
277
|
+
const CONNECTION_HEADER = "x-app-server-connection-id";
|
|
278
|
+
/** Protocol version this binding announces on the one-shot handshake. */
|
|
279
|
+
const PROTOCOL_VERSION = "fp-1";
|
|
280
|
+
/**
|
|
281
|
+
* Protocol method → REST route. Verified from handler bodies, not from the
|
|
282
|
+
* route list (a route existing does not prove which method it mirrors).
|
|
283
|
+
*/
|
|
284
|
+
const HTTP_ROUTES = {
|
|
285
|
+
ping: {
|
|
286
|
+
verb: "POST",
|
|
287
|
+
path: "/ping",
|
|
288
|
+
source: "ping() -> registry.require_ready"
|
|
289
|
+
},
|
|
290
|
+
"workspace/list": {
|
|
291
|
+
verb: "GET",
|
|
292
|
+
path: "/workspaces",
|
|
293
|
+
source: "workspace_list() -> owner-scoped active rows"
|
|
294
|
+
},
|
|
295
|
+
"workspace/revoke": {
|
|
296
|
+
verb: "DELETE",
|
|
297
|
+
path: "/workspaces/:workspace_id",
|
|
298
|
+
source: "workspace_delete() -> workspace_revoke_impl (shared with the WS arm)"
|
|
299
|
+
},
|
|
300
|
+
"conversation/create": {
|
|
301
|
+
verb: "POST",
|
|
302
|
+
path: "/conversations",
|
|
303
|
+
source: "conversation_create() -> create_conversation_for_user (shared)"
|
|
304
|
+
},
|
|
305
|
+
"conversation/list": {
|
|
306
|
+
verb: "GET",
|
|
307
|
+
path: "/conversations",
|
|
308
|
+
query: ["limit"],
|
|
309
|
+
source: "conversation_list() -> list_app_server_chats (shared); Query<limit>"
|
|
310
|
+
},
|
|
311
|
+
"conversation/get": {
|
|
312
|
+
verb: "GET",
|
|
313
|
+
path: "/conversations/:conversation_id",
|
|
314
|
+
source: "conversation_get() -> project_conversation_view (shared)"
|
|
315
|
+
},
|
|
316
|
+
"conversation/delete": {
|
|
317
|
+
verb: "DELETE",
|
|
318
|
+
path: "/conversations/:conversation_id",
|
|
319
|
+
source: "conversation_delete() -> conversation/delete arm semantics"
|
|
320
|
+
},
|
|
321
|
+
"conversation/messages": {
|
|
322
|
+
verb: "GET",
|
|
323
|
+
path: "/conversations/:conversation_id/messages",
|
|
324
|
+
query: [
|
|
325
|
+
"page",
|
|
326
|
+
"page_size",
|
|
327
|
+
"cursor"
|
|
328
|
+
],
|
|
329
|
+
source: "conversation_messages() -> list_conversation_messages_for_user (shared)"
|
|
330
|
+
},
|
|
331
|
+
"conversation/send": {
|
|
332
|
+
verb: "POST",
|
|
333
|
+
path: "/conversations/:conversation_id/messages",
|
|
334
|
+
body: [
|
|
335
|
+
"content",
|
|
336
|
+
"idempotency_key",
|
|
337
|
+
"attachments"
|
|
338
|
+
],
|
|
339
|
+
source: "conversation_send() -> send_conversation_message_for_user (shared with the WS arm)"
|
|
340
|
+
},
|
|
341
|
+
"conversation/cancel": {
|
|
342
|
+
verb: "POST",
|
|
343
|
+
path: "/conversations/:conversation_id/cancel",
|
|
344
|
+
source: "conversation_cancel() -> cancel arm semantics"
|
|
345
|
+
},
|
|
346
|
+
"agent/run": {
|
|
347
|
+
verb: "POST",
|
|
348
|
+
path: "/agent/run",
|
|
349
|
+
source: "agent_run() -> same request/receipt as the `agent/run` arm"
|
|
350
|
+
},
|
|
351
|
+
"team/run": {
|
|
352
|
+
verb: "POST",
|
|
353
|
+
path: "/team/run",
|
|
354
|
+
source: "team_run() -> execute_team_run() (same as the WS arm); Leader Conversation + planned delegation, `TeamRunReceipt`"
|
|
355
|
+
},
|
|
356
|
+
"run/get": {
|
|
357
|
+
verb: "GET",
|
|
358
|
+
path: "/run/:run_id",
|
|
359
|
+
source: "run_get()"
|
|
360
|
+
},
|
|
361
|
+
"run/result": {
|
|
362
|
+
verb: "GET",
|
|
363
|
+
path: "/run/:run_id/result",
|
|
364
|
+
source: "run_result()"
|
|
365
|
+
},
|
|
366
|
+
"run/plan": {
|
|
367
|
+
verb: "GET",
|
|
368
|
+
path: "/run/:run_id/plan",
|
|
369
|
+
source: "run_plan() -> get_run_plan_for_user() (same as the WS arm)"
|
|
370
|
+
},
|
|
371
|
+
"run/events": {
|
|
372
|
+
verb: "GET",
|
|
373
|
+
path: "/run/:run_id/events",
|
|
374
|
+
query: ["after_sequence", "limit"],
|
|
375
|
+
source: "run_events() -> runtime.list_events(after_sequence, limit) (same as the WS arm)"
|
|
376
|
+
},
|
|
377
|
+
"run/cancel": {
|
|
378
|
+
verb: "POST",
|
|
379
|
+
path: "/run/:run_id/cancel",
|
|
380
|
+
body: [
|
|
381
|
+
"expected_version",
|
|
382
|
+
"reason",
|
|
383
|
+
"idempotency_key"
|
|
384
|
+
],
|
|
385
|
+
source: "run_cancel() -> same CancelRunRequest as the WS arm"
|
|
386
|
+
},
|
|
387
|
+
"run/steer": {
|
|
388
|
+
verb: "POST",
|
|
389
|
+
path: "/run/:run_id/steer",
|
|
390
|
+
body: [
|
|
391
|
+
"text",
|
|
392
|
+
"expected_version",
|
|
393
|
+
"command_id",
|
|
394
|
+
"idempotency_key"
|
|
395
|
+
],
|
|
396
|
+
source: "run_steer() -> execute_steer_run (shared with the WS arm)"
|
|
397
|
+
},
|
|
398
|
+
"run/answer-decision": {
|
|
399
|
+
verb: "POST",
|
|
400
|
+
path: "/run/:run_id/answer-decision",
|
|
401
|
+
body: [
|
|
402
|
+
"step_id",
|
|
403
|
+
"attempt_id",
|
|
404
|
+
"answer",
|
|
405
|
+
"expected_execution_version",
|
|
406
|
+
"expected_step_version",
|
|
407
|
+
"expected_attempt_version"
|
|
408
|
+
],
|
|
409
|
+
source: "run_answer_decision() -> execute_answer_decision (shared with the WS arm; engine answer gate, no always_allow)"
|
|
410
|
+
},
|
|
411
|
+
"skill/list": {
|
|
412
|
+
verb: "GET",
|
|
413
|
+
path: "/skills",
|
|
414
|
+
source: "list_skills_route() -> list_skills_impl"
|
|
415
|
+
},
|
|
416
|
+
"skill/get": {
|
|
417
|
+
verb: "GET",
|
|
418
|
+
path: "/skills/:skill_id",
|
|
419
|
+
source: "get_skill_route() -> get_skill_impl"
|
|
420
|
+
},
|
|
421
|
+
"skill/files": {
|
|
422
|
+
verb: "GET",
|
|
423
|
+
path: "/skills/:skill_id/files",
|
|
424
|
+
source: "list_skill_files_route() -> list_skill_files_impl"
|
|
425
|
+
},
|
|
426
|
+
"connector/list": {
|
|
427
|
+
verb: "GET",
|
|
428
|
+
path: "/connectors",
|
|
429
|
+
source: "list_connectors_route() -> list_connectors_impl"
|
|
430
|
+
},
|
|
431
|
+
"models/list": {
|
|
432
|
+
verb: "GET",
|
|
433
|
+
path: "/models",
|
|
434
|
+
source: "list_models_route() -> list_models_impl"
|
|
435
|
+
},
|
|
436
|
+
"connector/get": {
|
|
437
|
+
verb: "GET",
|
|
438
|
+
path: "/connectors/:connector_id",
|
|
439
|
+
source: "get_connector_route() -> get_connector_impl"
|
|
440
|
+
},
|
|
441
|
+
"connector/status": {
|
|
442
|
+
verb: "GET",
|
|
443
|
+
path: "/connectors/:connector_id/status",
|
|
444
|
+
source: "connector_status_route() -> connector_status_impl"
|
|
445
|
+
},
|
|
446
|
+
"connector/test": {
|
|
447
|
+
verb: "POST",
|
|
448
|
+
path: "/connectors/:connector_id/test",
|
|
449
|
+
source: "connector_test_route() -> connector_test_impl"
|
|
450
|
+
},
|
|
451
|
+
"connector/call": {
|
|
452
|
+
verb: "POST",
|
|
453
|
+
path: "/connectors/:connector_id/call",
|
|
454
|
+
source: "connector_call_route() -> connector_call_impl"
|
|
455
|
+
},
|
|
456
|
+
"connector/auth/start": {
|
|
457
|
+
verb: "POST",
|
|
458
|
+
path: "/connectors/:connector_id/auth-start",
|
|
459
|
+
source: "connector_auth_start_route() -> connector_auth_start_impl (WS name uses slashes)"
|
|
460
|
+
},
|
|
461
|
+
"connector/auth/status": {
|
|
462
|
+
verb: "GET",
|
|
463
|
+
path: "/connectors/:connector_id/auth-status",
|
|
464
|
+
source: "connector_auth_status_route() -> connector_auth_status_impl"
|
|
465
|
+
},
|
|
466
|
+
"connector/auth/logout": {
|
|
467
|
+
verb: "POST",
|
|
468
|
+
path: "/connectors/:connector_id/auth-logout",
|
|
469
|
+
source: "connector_auth_logout_route() -> connector_auth_logout_impl"
|
|
470
|
+
},
|
|
471
|
+
"import/run": {
|
|
472
|
+
verb: "POST",
|
|
473
|
+
path: "/imports",
|
|
474
|
+
source: "run_import_route() -> run_import_impl"
|
|
475
|
+
},
|
|
476
|
+
"import/list": {
|
|
477
|
+
verb: "GET",
|
|
478
|
+
path: "/imports",
|
|
479
|
+
source: "list_imports_route() -> list_imports_impl"
|
|
480
|
+
},
|
|
481
|
+
"import/get": {
|
|
482
|
+
verb: "GET",
|
|
483
|
+
path: "/imports/:snapshot_id",
|
|
484
|
+
source: "get_import_route() -> get_import_impl"
|
|
485
|
+
},
|
|
486
|
+
"install/run": {
|
|
487
|
+
verb: "POST",
|
|
488
|
+
path: "/installs",
|
|
489
|
+
source: "run_install_route() -> install_impl"
|
|
490
|
+
},
|
|
491
|
+
"install/status": {
|
|
492
|
+
verb: "GET",
|
|
493
|
+
path: "/installs/:snapshot_id",
|
|
494
|
+
source: "install_status_route() -> install_status_impl"
|
|
495
|
+
},
|
|
496
|
+
"install/disable": {
|
|
497
|
+
verb: "POST",
|
|
498
|
+
path: "/installs/:snapshot_id/disable",
|
|
499
|
+
body: ["component_ids"],
|
|
500
|
+
source: "install_disable_route() -> install_disable_impl; body `component_ids`"
|
|
501
|
+
},
|
|
502
|
+
"install/enable": {
|
|
503
|
+
verb: "POST",
|
|
504
|
+
path: "/installs/:snapshot_id/enable",
|
|
505
|
+
body: ["component_ids"],
|
|
506
|
+
source: "install_enable_route() -> install_enable_impl; body `component_ids`"
|
|
507
|
+
},
|
|
508
|
+
"install/uninstall": {
|
|
509
|
+
verb: "POST",
|
|
510
|
+
path: "/installs/:snapshot_id/uninstall",
|
|
511
|
+
body: ["component_ids"],
|
|
512
|
+
source: "install_uninstall_route() -> install_uninstall_impl; body `component_ids`"
|
|
513
|
+
},
|
|
514
|
+
"market/add": {
|
|
515
|
+
verb: "POST",
|
|
516
|
+
path: "/markets",
|
|
517
|
+
source: "market_add_route() -> market_add_impl"
|
|
518
|
+
},
|
|
519
|
+
"market/list": {
|
|
520
|
+
verb: "GET",
|
|
521
|
+
path: "/markets",
|
|
522
|
+
source: "market_list_route() -> market_list_impl"
|
|
523
|
+
},
|
|
524
|
+
"market/get": {
|
|
525
|
+
verb: "GET",
|
|
526
|
+
path: "/markets/:marketplace_id",
|
|
527
|
+
source: "market_get_route() -> market_get_impl"
|
|
528
|
+
},
|
|
529
|
+
"market/remove": {
|
|
530
|
+
verb: "POST",
|
|
531
|
+
path: "/markets/:marketplace_id/remove",
|
|
532
|
+
body: ["cascade"],
|
|
533
|
+
source: "market_remove_route() -> market_remove_impl; body `cascade` defaults to true"
|
|
534
|
+
},
|
|
535
|
+
"market/auto-update": {
|
|
536
|
+
verb: "POST",
|
|
537
|
+
path: "/markets/:marketplace_id/auto-update",
|
|
538
|
+
body: ["enabled"],
|
|
539
|
+
source: "market_auto_update_route() -> market_auto_update_impl; body `enabled` defaults to true"
|
|
540
|
+
},
|
|
541
|
+
"market/refresh": {
|
|
542
|
+
verb: "POST",
|
|
543
|
+
path: "/markets/:marketplace_id/refresh",
|
|
544
|
+
source: "market_refresh_route() -> market_refresh_impl"
|
|
545
|
+
},
|
|
546
|
+
"market/entry-import": {
|
|
547
|
+
verb: "POST",
|
|
548
|
+
path: "/markets/:marketplace_id/entries/:entry_name/import",
|
|
549
|
+
source: "market_entry_import_route() -> market_entry_import_impl"
|
|
550
|
+
},
|
|
551
|
+
"store/list": {
|
|
552
|
+
verb: "GET",
|
|
553
|
+
path: "/store",
|
|
554
|
+
source: "store_list_route() -> store_list_impl"
|
|
555
|
+
},
|
|
556
|
+
"store/install-entry": {
|
|
557
|
+
verb: "POST",
|
|
558
|
+
path: "/store/:marketplace_id/entries/:entry_name/install",
|
|
559
|
+
source: "store_install_entry_route() -> store_install_entry_impl"
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
/** Methods that are WebSocket-only by design — named so the error explains itself. */
|
|
563
|
+
const WS_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
564
|
+
"initialize",
|
|
565
|
+
"initialized",
|
|
566
|
+
"conversation/subscribe",
|
|
567
|
+
"conversation/unsubscribe",
|
|
568
|
+
"run/subscribe",
|
|
569
|
+
"run/unsubscribe"
|
|
570
|
+
]);
|
|
571
|
+
var HttpTransport = class {
|
|
572
|
+
routes;
|
|
573
|
+
options;
|
|
574
|
+
closed = false;
|
|
575
|
+
constructor(options, routes = HTTP_ROUTES) {
|
|
576
|
+
this.options = options;
|
|
577
|
+
this.routes = routes;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* No-op: the HTTP binding handshakes per call (`05` §2.1.1), so there is no
|
|
581
|
+
* connection to open. Kept for `Transport` compatibility.
|
|
582
|
+
*/
|
|
583
|
+
async connect() {
|
|
584
|
+
this.closed = false;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Perform one business call over its own handshake.
|
|
588
|
+
*
|
|
589
|
+
* Throws `TransportError("send", …)` for a method with no HTTP binding —
|
|
590
|
+
* never a silent fallback to another binding.
|
|
591
|
+
*/
|
|
592
|
+
async request(method, params) {
|
|
593
|
+
if (this.closed) throw new _flowy_agent_store_protocol.TransportError("connect", "http transport is closed");
|
|
594
|
+
const route = this.routes[method];
|
|
595
|
+
if (!route) {
|
|
596
|
+
const reason = WS_ONLY_METHODS.has(method) ? `"${method}" is WebSocket-only (doc 05 §2.1.1); use WebSocketTransport for subscriptions and live events` : `"${method}" has no HTTP binding in this SDK (doc 16 R2 route table)`;
|
|
597
|
+
throw new _flowy_agent_store_protocol.TransportError("send", reason);
|
|
598
|
+
}
|
|
599
|
+
const { path, query, body } = this.splitParams(route, params);
|
|
600
|
+
const connectionId = await this.openConnection();
|
|
601
|
+
const response = await this.fetchWithTimeout(route.verb, path, {
|
|
602
|
+
query,
|
|
603
|
+
body,
|
|
604
|
+
connectionId
|
|
605
|
+
});
|
|
606
|
+
return await this.readResult(response, method);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Not supported: HTTP carries no server-push. Throws instead of pretending.
|
|
610
|
+
*/
|
|
611
|
+
notify(method, _params) {
|
|
612
|
+
throw new _flowy_agent_store_protocol.TransportError("send", `HTTP binding cannot notify ("${method}"): notifications are WebSocket-only (doc 05 §2.1.1)`);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Returns a no-op unsubscribe: this binding receives **no** notifications.
|
|
616
|
+
*
|
|
617
|
+
* Not equivalent to the WebSocket binding — callers that need live events
|
|
618
|
+
* must use `WebSocketTransport` (or run both).
|
|
619
|
+
*/
|
|
620
|
+
onNotification(_listener) {
|
|
621
|
+
return () => void 0;
|
|
622
|
+
}
|
|
623
|
+
/** No-op: every call is independent, so nothing is held open. */
|
|
624
|
+
close() {
|
|
625
|
+
this.closed = true;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* One-shot handshake: `POST /initialize` (connection id header) →
|
|
629
|
+
* `POST /initialized`. Returns the connection id.
|
|
630
|
+
*
|
|
631
|
+
* Public because host-side services that sit *outside* the protocol surface
|
|
632
|
+
* (`/api/fs/*`, the HTTP-only `POST /workspaces` register route) still need a
|
|
633
|
+
* ready connection id; they are not protocol methods, so they cannot go
|
|
634
|
+
* through `request()`.
|
|
635
|
+
*/
|
|
636
|
+
async openConnection() {
|
|
637
|
+
const connectionId = (await this.fetchWithTimeout("POST", "/initialize", { body: {
|
|
638
|
+
protocol_version: PROTOCOL_VERSION,
|
|
639
|
+
client: this.options.client ?? {
|
|
640
|
+
name: "flowy-agent-store-client",
|
|
641
|
+
version: "0.1.0"
|
|
642
|
+
},
|
|
643
|
+
...this.options.capabilities === void 0 ? {} : { capabilities: this.options.capabilities }
|
|
644
|
+
} })).headers.get(CONNECTION_HEADER);
|
|
645
|
+
if (!connectionId) throw new _flowy_agent_store_protocol.TransportError("connect", `handshake response is missing the ${CONNECTION_HEADER} header`, { retryable: true });
|
|
646
|
+
await this.fetchWithTimeout("POST", "/initialized", { connectionId });
|
|
647
|
+
return connectionId;
|
|
648
|
+
}
|
|
649
|
+
splitParams(route, params) {
|
|
650
|
+
const source = isRecord(params) ? params : {};
|
|
651
|
+
const pathParams = [...route.path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]);
|
|
652
|
+
const path = route.path.replace(/:([A-Za-z0-9_]+)/g, (_full, name) => {
|
|
653
|
+
const value = source[name];
|
|
654
|
+
if (value === void 0 || value === null) throw new _flowy_agent_store_protocol.TransportError("send", `missing required path param "${name}" for ${route.verb} ${route.path}`);
|
|
655
|
+
return encodeURIComponent(String(value));
|
|
656
|
+
});
|
|
657
|
+
const query = {};
|
|
658
|
+
for (const key of route.query ?? []) {
|
|
659
|
+
const value = source[key];
|
|
660
|
+
if (value !== void 0 && value !== null) query[key] = String(value);
|
|
661
|
+
}
|
|
662
|
+
if (route.verb === "GET" || route.verb === "DELETE") return {
|
|
663
|
+
path,
|
|
664
|
+
query,
|
|
665
|
+
body: void 0
|
|
666
|
+
};
|
|
667
|
+
const carried = route.body ?? [];
|
|
668
|
+
const body = {};
|
|
669
|
+
for (const key of Object.keys(source)) {
|
|
670
|
+
if (pathParams.includes(key) || (route.query ?? []).includes(key)) continue;
|
|
671
|
+
if (carried.length > 0 && !carried.includes(key)) continue;
|
|
672
|
+
body[key] = source[key];
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
path,
|
|
676
|
+
query,
|
|
677
|
+
body
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async fetchWithTimeout(verb, path, request) {
|
|
681
|
+
const url = new URL(`${this.baseUrl()}${path}`);
|
|
682
|
+
for (const [key, value] of Object.entries(request.query ?? {})) url.searchParams.set(key, value);
|
|
683
|
+
const headers = { accept: "application/json" };
|
|
684
|
+
if (request.body !== void 0) headers["content-type"] = "application/json";
|
|
685
|
+
if (request.connectionId) headers[CONNECTION_HEADER] = request.connectionId;
|
|
686
|
+
if (this.options.token) headers.authorization = `Bearer ${this.options.token}`;
|
|
687
|
+
const controller = new AbortController();
|
|
688
|
+
const timeoutMs = this.options.requestTimeoutMs ?? 3e4;
|
|
689
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
690
|
+
try {
|
|
691
|
+
const fetchImpl = this.options.fetch ?? globalThis.fetch;
|
|
692
|
+
if (!fetchImpl) throw new _flowy_agent_store_protocol.TransportError("connect", "no fetch implementation available");
|
|
693
|
+
return await fetchImpl(url.toString(), {
|
|
694
|
+
method: verb,
|
|
695
|
+
headers,
|
|
696
|
+
body: request.body === void 0 ? void 0 : JSON.stringify(request.body),
|
|
697
|
+
signal: controller.signal
|
|
698
|
+
});
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof _flowy_agent_store_protocol.TransportError) throw error;
|
|
701
|
+
const aborted = error?.name === "AbortError";
|
|
702
|
+
throw new _flowy_agent_store_protocol.TransportError("send", aborted ? `${verb} ${path} timed out after ${timeoutMs}ms` : `${verb} ${path} failed: ${String(error)}`, {
|
|
703
|
+
retryable: true,
|
|
704
|
+
cause: error
|
|
705
|
+
});
|
|
706
|
+
} finally {
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/** 2xx → result body; otherwise the server's wire error becomes an `AppServerError`. */
|
|
711
|
+
async readResult(response, method) {
|
|
712
|
+
let payload = void 0;
|
|
713
|
+
const text = await response.text();
|
|
714
|
+
if (text.length > 0) try {
|
|
715
|
+
payload = JSON.parse(text);
|
|
716
|
+
} catch {
|
|
717
|
+
if (response.ok) throw new _flowy_agent_store_protocol.TransportError("receive", `${method}: response is not JSON`, { retryable: true });
|
|
718
|
+
}
|
|
719
|
+
if (response.ok) return payload;
|
|
720
|
+
throw appServerErrorFromWire(payload, response.status, method);
|
|
721
|
+
}
|
|
722
|
+
baseUrl() {
|
|
723
|
+
return this.options.baseUrl.replace(/\/+$/, "").replace(/\/ws$/, "");
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
function isRecord(value) {
|
|
727
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Turn an HTTP failure body into the SDK error model.
|
|
731
|
+
*
|
|
732
|
+
* Shared by this transport and by host-side helpers (webui `lib/client.ts`) so
|
|
733
|
+
* a second spelling of the wire-error mapping cannot drift. Two wire contracts
|
|
734
|
+
* reach this function, both of them `AppError` on the server:
|
|
735
|
+
*
|
|
736
|
+
* - the App Server emits `{code, message, retryable, details, request_id}`
|
|
737
|
+
* (`into_wire_error`), which is exactly the `AppServerError` shape;
|
|
738
|
+
* - the standalone host file service (`/api/fs/*`) emits the `ErrorResponse`
|
|
739
|
+
* envelope `{success: false, error, code, details?}` — same error, another
|
|
740
|
+
* spelling: `error` carries the human message, and there is no retry hint, so
|
|
741
|
+
* a 5xx status stays the retry signal.
|
|
742
|
+
*
|
|
743
|
+
* Anything else becomes a retryable-on-5xx `TransportError`.
|
|
744
|
+
*/
|
|
745
|
+
function appServerErrorFromWire(payload, status, context = "request") {
|
|
746
|
+
if (isRecord(payload) && typeof payload.code === "string" && typeof payload.message === "string") return new _flowy_agent_store_protocol.AppServerError({
|
|
747
|
+
code: payload.code,
|
|
748
|
+
message: payload.message,
|
|
749
|
+
retryable: payload.retryable === true,
|
|
750
|
+
request_id: payload.request_id ?? null,
|
|
751
|
+
details: isRecord(payload.details) ? payload.details : {}
|
|
752
|
+
});
|
|
753
|
+
if (isRecord(payload) && payload.success === false && typeof payload.code === "string" && typeof payload.error === "string") return new _flowy_agent_store_protocol.AppServerError({
|
|
754
|
+
code: payload.code,
|
|
755
|
+
message: payload.error,
|
|
756
|
+
retryable: status >= 500,
|
|
757
|
+
request_id: null,
|
|
758
|
+
details: isRecord(payload.details) ? payload.details : {}
|
|
759
|
+
});
|
|
760
|
+
return new _flowy_agent_store_protocol.TransportError("receive", `${context}: HTTP ${status} without a wire error body`, { retryable: status >= 500 });
|
|
761
|
+
}
|
|
762
|
+
/** Exposed for tests and docs (R5): the verified method → route table. */
|
|
763
|
+
function httpRouteTable() {
|
|
764
|
+
return HTTP_ROUTES;
|
|
765
|
+
}
|
|
766
|
+
//#endregion
|
|
169
767
|
//#region src/agents.ts
|
|
170
768
|
var AgentClient = class {
|
|
171
769
|
transport;
|
|
@@ -228,11 +826,19 @@ var ConversationClient = class {
|
|
|
228
826
|
cursor: query.cursor
|
|
229
827
|
});
|
|
230
828
|
}
|
|
231
|
-
|
|
829
|
+
/**
|
|
830
|
+
* R15(W10):`attachments` 是**会话工作区内的绝对路径**(图片附件)。
|
|
831
|
+
*
|
|
832
|
+
* 缺省不传,保持老调用方的 wire 形状逐字不变;服务端只接受会话工作区内的真实
|
|
833
|
+
* 文件(越界 / 相对路径 / 不存在一律拒),具体准入见
|
|
834
|
+
* `nomifun-app-server` 的 `resolve_conversation_attachments`。
|
|
835
|
+
*/
|
|
836
|
+
send(conversationId, content, idempotencyKey, attachments = []) {
|
|
232
837
|
return this.transport.request("conversation/send", {
|
|
233
838
|
conversation_id: conversationId,
|
|
234
839
|
content,
|
|
235
|
-
idempotency_key: idempotencyKey
|
|
840
|
+
idempotency_key: idempotencyKey,
|
|
841
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
236
842
|
});
|
|
237
843
|
}
|
|
238
844
|
cancel(conversationId) {
|
|
@@ -241,35 +847,92 @@ var ConversationClient = class {
|
|
|
241
847
|
delete(conversationId) {
|
|
242
848
|
return this.transport.request("conversation/delete", { conversation_id: conversationId });
|
|
243
849
|
}
|
|
244
|
-
|
|
850
|
+
/**
|
|
851
|
+
* Subscribe to one conversation's realtime stream (doc `16` R1).
|
|
852
|
+
*
|
|
853
|
+
* `options.fetchMessages` is what makes auto catch-up possible: a detected
|
|
854
|
+
* sequence gap or a `conversation/resync-required` triggers one transcript
|
|
855
|
+
* fetch whose result is handed to `onBackfill`. Conversations have no event
|
|
856
|
+
* replay API (`05` §「V1 不提供 after_cursor 事件追平」), so the transcript is
|
|
857
|
+
* the recovery medium — unlike Runs, where `run/events` replays the events
|
|
858
|
+
* themselves.
|
|
859
|
+
*/
|
|
860
|
+
async follow(conversationId, options = {}) {
|
|
245
861
|
await this.transport.request("conversation/subscribe", { conversation_id: conversationId });
|
|
246
|
-
return new ConversationSubscription(this.transport, conversationId
|
|
862
|
+
return new ConversationSubscription(this.transport, conversationId, {
|
|
863
|
+
fetchMessages: (id) => this.messages({
|
|
864
|
+
conversationId: id,
|
|
865
|
+
pageSize: 50
|
|
866
|
+
}),
|
|
867
|
+
...options
|
|
868
|
+
});
|
|
247
869
|
}
|
|
248
870
|
};
|
|
871
|
+
/** Events held before the first listener attaches (see `pendingLimit`). */
|
|
872
|
+
const DEFAULT_PENDING_LIMIT = 256;
|
|
249
873
|
var ConversationSubscription = class {
|
|
250
874
|
transport;
|
|
251
875
|
conversationId;
|
|
876
|
+
options;
|
|
252
877
|
events = /* @__PURE__ */ new Set();
|
|
253
878
|
resyncs = /* @__PURE__ */ new Set();
|
|
879
|
+
errors = /* @__PURE__ */ new Set();
|
|
880
|
+
backfills = /* @__PURE__ */ new Set();
|
|
254
881
|
closed = false;
|
|
255
882
|
lastSeenSequence = 0;
|
|
256
883
|
removeListener;
|
|
257
|
-
|
|
884
|
+
/**
|
|
885
|
+
* `follow()` resolves as soon as the server acknowledged the subscription,
|
|
886
|
+
* but the caller attaches listeners one tick later. Events landing in that
|
|
887
|
+
* window used to be dropped silently; they are held here (oldest first) and
|
|
888
|
+
* flushed to the first `onEvent` listener.
|
|
889
|
+
*/
|
|
890
|
+
pending = [];
|
|
891
|
+
pendingDropped = 0;
|
|
892
|
+
backfillInFlight = null;
|
|
893
|
+
constructor(transport, conversationId, options = {}) {
|
|
258
894
|
this.transport = transport;
|
|
259
895
|
this.conversationId = conversationId;
|
|
896
|
+
this.options = options;
|
|
260
897
|
this.removeListener = transport.onNotification((notification) => this.dispatch(notification));
|
|
261
898
|
}
|
|
262
899
|
get lastSequence() {
|
|
263
900
|
return this.lastSeenSequence;
|
|
264
901
|
}
|
|
902
|
+
/** Events dropped because nobody was listening and the buffer was full. */
|
|
903
|
+
get droppedPendingCount() {
|
|
904
|
+
return this.pendingDropped;
|
|
905
|
+
}
|
|
265
906
|
onEvent(listener) {
|
|
266
907
|
this.events.add(listener);
|
|
908
|
+
if (this.events.size === 1 && this.pending.length > 0) {
|
|
909
|
+
const queued = this.pending;
|
|
910
|
+
this.pending = [];
|
|
911
|
+
for (const event of queued) this.deliver(event);
|
|
912
|
+
}
|
|
267
913
|
return () => this.events.delete(listener);
|
|
268
914
|
}
|
|
269
915
|
onResync(listener) {
|
|
270
916
|
this.resyncs.add(listener);
|
|
271
917
|
return () => this.resyncs.delete(listener);
|
|
272
918
|
}
|
|
919
|
+
/**
|
|
920
|
+
* Auto catch-up result (R1). Fires only when `autoResync` is on and the
|
|
921
|
+
* transcript fetch succeeded; the listener owns replacing the transcript.
|
|
922
|
+
*/
|
|
923
|
+
onBackfill(listener) {
|
|
924
|
+
this.backfills.add(listener);
|
|
925
|
+
return () => this.backfills.delete(listener);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Failures that used to be swallowed: the catch-up fetch, and a failed
|
|
929
|
+
* re-subscribe during `rearm()`. Listener isolation keeps one bad listener
|
|
930
|
+
* from hiding the error from the others.
|
|
931
|
+
*/
|
|
932
|
+
onError(listener) {
|
|
933
|
+
this.errors.add(listener);
|
|
934
|
+
return () => this.errors.delete(listener);
|
|
935
|
+
}
|
|
273
936
|
async close() {
|
|
274
937
|
if (this.closed) return;
|
|
275
938
|
this.closed = true;
|
|
@@ -277,27 +940,109 @@ var ConversationSubscription = class {
|
|
|
277
940
|
this.removeListener = null;
|
|
278
941
|
this.events.clear();
|
|
279
942
|
this.resyncs.clear();
|
|
943
|
+
this.errors.clear();
|
|
944
|
+
this.backfills.clear();
|
|
945
|
+
this.pending = [];
|
|
280
946
|
try {
|
|
281
947
|
await this.transport.request("conversation/unsubscribe", { conversation_id: this.conversationId });
|
|
282
948
|
} catch {}
|
|
283
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
|
|
952
|
+
* notification listener, reset the cursor and re-issue
|
|
953
|
+
* `conversation/subscribe`. The server restarts a conversation's sequence at
|
|
954
|
+
* 1 for a fresh subscription, which is exactly why the cursor resets. On a
|
|
955
|
+
* failed re-subscribe the error reaches `onError` and the caller can still
|
|
956
|
+
* backfill the outage window via `conversation/messages`.
|
|
957
|
+
*/
|
|
958
|
+
async rearm() {
|
|
959
|
+
if (this.closed) return;
|
|
960
|
+
this.removeListener?.();
|
|
961
|
+
this.removeListener = this.transport.onNotification((notification) => this.dispatch(notification));
|
|
962
|
+
this.lastSeenSequence = 0;
|
|
963
|
+
try {
|
|
964
|
+
await this.transport.request("conversation/subscribe", { conversation_id: this.conversationId });
|
|
965
|
+
} catch (error) {
|
|
966
|
+
this.emitError(error);
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
284
970
|
dispatch(notification) {
|
|
285
971
|
if (notification.method === "conversation/event") {
|
|
286
972
|
const event = notification.params;
|
|
287
|
-
if (event.conversation_id !== this.conversationId
|
|
973
|
+
if (event.conversation_id !== this.conversationId) return;
|
|
974
|
+
if (!Number.isFinite(event.sequence)) return;
|
|
975
|
+
if (event.sequence <= this.lastSeenSequence) return;
|
|
976
|
+
const gap = this.lastSeenSequence > 0 && event.sequence > this.lastSeenSequence + 1;
|
|
288
977
|
this.lastSeenSequence = event.sequence;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
978
|
+
if (this.events.size === 0) this.buffer(event);
|
|
979
|
+
else this.deliver(event);
|
|
980
|
+
if (gap) {
|
|
981
|
+
this.emitResync("gap");
|
|
982
|
+
this.requestBackfill("gap");
|
|
983
|
+
}
|
|
292
984
|
return;
|
|
293
985
|
}
|
|
294
986
|
if (notification.method === "conversation/resync-required") {
|
|
295
987
|
if (!notification.params.conversation_ids.includes(this.conversationId)) return;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
} catch {}
|
|
988
|
+
this.emitResync(notification.params.reason);
|
|
989
|
+
this.requestBackfill(notification.params.reason);
|
|
299
990
|
}
|
|
300
991
|
}
|
|
992
|
+
buffer(event) {
|
|
993
|
+
const limit = this.options.pendingLimit ?? DEFAULT_PENDING_LIMIT;
|
|
994
|
+
this.pending.push(event);
|
|
995
|
+
while (this.pending.length > limit) {
|
|
996
|
+
this.pending.shift();
|
|
997
|
+
this.pendingDropped += 1;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
deliver(event) {
|
|
1001
|
+
for (const listener of [...this.events]) try {
|
|
1002
|
+
listener(event);
|
|
1003
|
+
} catch {}
|
|
1004
|
+
}
|
|
1005
|
+
emitResync(reason) {
|
|
1006
|
+
for (const listener of [...this.resyncs]) try {
|
|
1007
|
+
listener(reason);
|
|
1008
|
+
} catch {}
|
|
1009
|
+
}
|
|
1010
|
+
emitError(error) {
|
|
1011
|
+
for (const listener of [...this.errors]) try {
|
|
1012
|
+
listener(error);
|
|
1013
|
+
} catch {}
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* One in-flight catch-up at a time: a burst of resync signals collapses into
|
|
1017
|
+
* a single transcript fetch (the fetch itself returns the newest page, so the
|
|
1018
|
+
* result supersedes every signal that arrived before it).
|
|
1019
|
+
*/
|
|
1020
|
+
requestBackfill(reason) {
|
|
1021
|
+
if (this.closed || this.options.autoResync === false) return;
|
|
1022
|
+
const fetchMessages = this.options.fetchMessages;
|
|
1023
|
+
if (!fetchMessages || this.backfillInFlight) return;
|
|
1024
|
+
const task = (async () => {
|
|
1025
|
+
try {
|
|
1026
|
+
const page = await fetchMessages(this.conversationId);
|
|
1027
|
+
if (this.closed) return;
|
|
1028
|
+
const backfill = {
|
|
1029
|
+
conversationId: this.conversationId,
|
|
1030
|
+
reason,
|
|
1031
|
+
messages: page?.items ?? [],
|
|
1032
|
+
hasMore: page?.has_more === true
|
|
1033
|
+
};
|
|
1034
|
+
for (const listener of [...this.backfills]) try {
|
|
1035
|
+
listener(backfill);
|
|
1036
|
+
} catch {}
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.emitError(error);
|
|
1039
|
+
}
|
|
1040
|
+
})();
|
|
1041
|
+
this.backfillInFlight = task;
|
|
1042
|
+
task.finally(() => {
|
|
1043
|
+
if (this.backfillInFlight === task) this.backfillInFlight = null;
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
301
1046
|
};
|
|
302
1047
|
//#endregion
|
|
303
1048
|
//#region src/connectors.ts
|
|
@@ -337,6 +1082,28 @@ var ConnectorClient = class {
|
|
|
337
1082
|
logout(connectorId) {
|
|
338
1083
|
return this.transport.request("connector/auth/logout", { connector_id: connectorId }).then(() => void 0);
|
|
339
1084
|
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Run one tool on a registered connector, through the host's own connection.
|
|
1087
|
+
*
|
|
1088
|
+
* The host holds the transport, its headers and its OAuth token; this sends a
|
|
1089
|
+
* tool name and an argument object and nothing else — you cannot name a URL,
|
|
1090
|
+
* a command or a header. Whether the pair is callable at all is the host's
|
|
1091
|
+
* `[connector_proxy]` allowlist, so expect `policy_denied` on a host whose
|
|
1092
|
+
* operator has not listed this tool (that is the default, not a
|
|
1093
|
+
* misconfiguration).
|
|
1094
|
+
*
|
|
1095
|
+
* Resolves even when the tool itself failed — check `is_error`. It rejects
|
|
1096
|
+
* only when the call never reached the tool: `connector_call_timeout`,
|
|
1097
|
+
* `connector_call_failed`, `response_too_large`, `connector_unavailable`,
|
|
1098
|
+
* `policy_denied`, `not_found`.
|
|
1099
|
+
*/
|
|
1100
|
+
call(connectorId, tool, args = {}) {
|
|
1101
|
+
return this.transport.request("connector/call", {
|
|
1102
|
+
connector_id: connectorId,
|
|
1103
|
+
tool,
|
|
1104
|
+
arguments: args
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
340
1107
|
};
|
|
341
1108
|
//#endregion
|
|
342
1109
|
//#region src/runs.ts
|
|
@@ -353,6 +1120,18 @@ var RunClient = class {
|
|
|
353
1120
|
agent(input) {
|
|
354
1121
|
return this.transport.request("agent/run", toWire(input));
|
|
355
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Start a Team run (`16` §7 决策 3): the server creates the Leader
|
|
1125
|
+
* Conversation, materializes/binds the Team's execution template and returns
|
|
1126
|
+
* the public run id of the execution the Leader started.
|
|
1127
|
+
*
|
|
1128
|
+
* The receipt is `TeamRunReceipt`, not `RunReceipt` — a Team Run has no lead
|
|
1129
|
+
* preset revision/digest to report. Member pool and concurrency limits are not
|
|
1130
|
+
* inputs; sending `planning` is rejected as `invalid_request`.
|
|
1131
|
+
*/
|
|
1132
|
+
team(input) {
|
|
1133
|
+
return this.transport.request("team/run", toTeamWire(input));
|
|
1134
|
+
}
|
|
356
1135
|
/** Read the authoritative run state. */
|
|
357
1136
|
get(runId) {
|
|
358
1137
|
return this.transport.request("run/get", { run_id: runId });
|
|
@@ -361,6 +1140,14 @@ var RunClient = class {
|
|
|
361
1140
|
result(runId) {
|
|
362
1141
|
return this.transport.request("run/result", { run_id: runId });
|
|
363
1142
|
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Read the authoritative plan / step snapshot (W4 / W6, resolves D-W6-1):
|
|
1145
|
+
* titles, statuses, retries with their reason, errors and timings — none of
|
|
1146
|
+
* which the append-only event log carries.
|
|
1147
|
+
*/
|
|
1148
|
+
plan(runId) {
|
|
1149
|
+
return this.transport.request("run/plan", { run_id: runId });
|
|
1150
|
+
}
|
|
364
1151
|
/** Replay persisted events after a cursor. */
|
|
365
1152
|
events(query) {
|
|
366
1153
|
return this.transport.request("run/events", {
|
|
@@ -391,6 +1178,26 @@ var RunClient = class {
|
|
|
391
1178
|
idempotency_key: input.idempotencyKey
|
|
392
1179
|
});
|
|
393
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Answer the pending decision of a `waiting_input` attempt.
|
|
1183
|
+
*
|
|
1184
|
+
* The three `expected*Version` tokens are mandatory CAS inputs, normally the
|
|
1185
|
+
* ones projected on the pending `approval.requested` event. The server passes
|
|
1186
|
+
* them straight to the engine's single answer gate, so a stale token is a
|
|
1187
|
+
* `conflict`, never a silent overwrite. There is no `always_allow` here: the
|
|
1188
|
+
* desktop confirmation route's approve-all flag is not part of this protocol.
|
|
1189
|
+
*/
|
|
1190
|
+
answerDecision(input) {
|
|
1191
|
+
return this.transport.request("run/answer-decision", {
|
|
1192
|
+
run_id: input.runId,
|
|
1193
|
+
step_id: input.stepId,
|
|
1194
|
+
attempt_id: input.attemptId,
|
|
1195
|
+
answer: input.answer,
|
|
1196
|
+
expected_execution_version: input.expectedExecutionVersion,
|
|
1197
|
+
expected_step_version: input.expectedStepVersion,
|
|
1198
|
+
expected_attempt_version: input.expectedAttemptVersion
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
394
1201
|
/** Subscribe to best-effort realtime events for one run. */
|
|
395
1202
|
async follow(runId, options) {
|
|
396
1203
|
const params = { run_id: runId };
|
|
@@ -520,6 +1327,33 @@ var EventSubscription = class {
|
|
|
520
1327
|
if (this.resyncInFlight === task) this.resyncInFlight = null;
|
|
521
1328
|
}
|
|
522
1329
|
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Re-arm after the connection was re-established (docs/agent-store/16 T8).
|
|
1332
|
+
* A new socket means the server dropped its subscriptions and the transport
|
|
1333
|
+
* dropped its notification listeners, so this:
|
|
1334
|
+
*
|
|
1335
|
+
* 1. re-registers this subscription's notification listener,
|
|
1336
|
+
* 2. resets the cursor to 0 and forgets the dedupe set, then
|
|
1337
|
+
* 3. re-issues `run/subscribe` and replays **all** persisted events.
|
|
1338
|
+
*
|
|
1339
|
+
* The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
|
|
1340
|
+
* consumer must dedupe by `sequence`, since events seen before the outage
|
|
1341
|
+
* are delivered again. Call it only once the session handshake has completed
|
|
1342
|
+
* again — the transport's `open` event fires before `initialize`. A no-op
|
|
1343
|
+
* once closed.
|
|
1344
|
+
*/
|
|
1345
|
+
async rearm() {
|
|
1346
|
+
if (this.closed || !this.fetchAfter) return [];
|
|
1347
|
+
this.unsubscribeNotification?.();
|
|
1348
|
+
this.unsubscribeNotification = this.transport.onNotification((notification) => {
|
|
1349
|
+
this.dispatch(notification);
|
|
1350
|
+
});
|
|
1351
|
+
this.lastSeenSequence = 0;
|
|
1352
|
+
this.seenSequences.clear();
|
|
1353
|
+
this.resyncInFlight = null;
|
|
1354
|
+
await this.transport.request("run/subscribe", { run_id: this.runId });
|
|
1355
|
+
return this.resync();
|
|
1356
|
+
}
|
|
523
1357
|
markSeen(sequence) {
|
|
524
1358
|
this.seenSequences.add(sequence);
|
|
525
1359
|
if (sequence > this.lastSeenSequence) this.lastSeenSequence = sequence;
|
|
@@ -558,6 +1392,17 @@ function toWire(input) {
|
|
|
558
1392
|
mentions: input.mentions
|
|
559
1393
|
};
|
|
560
1394
|
}
|
|
1395
|
+
function toTeamWire(input) {
|
|
1396
|
+
return {
|
|
1397
|
+
team_id: input.teamId,
|
|
1398
|
+
team_version: input.teamVersion,
|
|
1399
|
+
goal: input.goal,
|
|
1400
|
+
input: input.input,
|
|
1401
|
+
workspace: input.workspaceId ? { id: input.workspaceId } : void 0,
|
|
1402
|
+
command_id: input.commandId,
|
|
1403
|
+
idempotency_key: input.idempotencyKey
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
561
1406
|
//#endregion
|
|
562
1407
|
//#region src/skills.ts
|
|
563
1408
|
var SkillClient = class {
|
|
@@ -573,6 +1418,309 @@ var SkillClient = class {
|
|
|
573
1418
|
get(skillId) {
|
|
574
1419
|
return this.transport.request("skill/get", { skill_id: skillId });
|
|
575
1420
|
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Every readable file inside a Skill's directory, with a tree digest.
|
|
1423
|
+
*
|
|
1424
|
+
* This is the only way to learn what a Skill actually ships: `get()` returns
|
|
1425
|
+
* a bounded summary of `SKILL.md` and says nothing about the `references/`,
|
|
1426
|
+
* `scripts/`, `templates/` and `assets/` beside it. Check
|
|
1427
|
+
* `capabilities.skill_files` first — a host may wire the catalog without
|
|
1428
|
+
* this face, in which case it answers `unsupported_operation`.
|
|
1429
|
+
*/
|
|
1430
|
+
files(skillId) {
|
|
1431
|
+
return this.transport.request("skill/files", { skill_id: skillId });
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* One skill file's bytes, over the WebSocket binding.
|
|
1435
|
+
*
|
|
1436
|
+
* The wire carries base64 because JSON has no byte string, so this decodes
|
|
1437
|
+
* back to `Uint8Array` for you. The HTTP route
|
|
1438
|
+
* (`GET /api/app-server/skills/{id}/files/{path}`) returns the raw body
|
|
1439
|
+
* instead, but that is deliberately not on the typed client's JSON
|
|
1440
|
+
* transport — use this method, or `fetch` the route directly if you are on a
|
|
1441
|
+
* host token.
|
|
1442
|
+
*/
|
|
1443
|
+
async readFile(skillId, path) {
|
|
1444
|
+
return decodeBase64((await this.transport.request("skill/file", {
|
|
1445
|
+
skill_id: skillId,
|
|
1446
|
+
path
|
|
1447
|
+
})).content);
|
|
1448
|
+
}
|
|
1449
|
+
/** Same as {@link readFile}, but also returns the server's content type. */
|
|
1450
|
+
readFileWithType(skillId, path) {
|
|
1451
|
+
return this.transport.request("skill/file", {
|
|
1452
|
+
skill_id: skillId,
|
|
1453
|
+
path
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
/**
|
|
1458
|
+
* Decode base64 to bytes.
|
|
1459
|
+
*
|
|
1460
|
+
* `atob` is used when present (browsers, Bun, Node 16+) and a manual decode
|
|
1461
|
+
* otherwise, so the package keeps working on a bare Node without a DOM lib.
|
|
1462
|
+
*/
|
|
1463
|
+
function decodeBase64(content) {
|
|
1464
|
+
if (typeof atob === "function") {
|
|
1465
|
+
const binary = atob(content);
|
|
1466
|
+
const bytes = new Uint8Array(binary.length);
|
|
1467
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
1468
|
+
return bytes;
|
|
1469
|
+
}
|
|
1470
|
+
const bufferCtor = globalThis.Buffer;
|
|
1471
|
+
if (bufferCtor) return bufferCtor.from(content, "base64");
|
|
1472
|
+
throw new Error("no base64 decoder available in this environment");
|
|
1473
|
+
}
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/store.ts
|
|
1476
|
+
var StoreError = class extends Error {
|
|
1477
|
+
code;
|
|
1478
|
+
constructor(code, message) {
|
|
1479
|
+
super(message);
|
|
1480
|
+
this.code = code;
|
|
1481
|
+
this.name = "StoreError";
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
const DEFAULT_READY_TIMEOUT_MS = 3e4;
|
|
1485
|
+
const DEFAULT_READY_POLL_MS = 400;
|
|
1486
|
+
const DEFAULT_READY_POLL_MAX_MS = 4e3;
|
|
1487
|
+
/** Every string a `LocalizedText` carries, for haystack building. */
|
|
1488
|
+
function localizedValues(text) {
|
|
1489
|
+
if (typeof text === "string") return [text];
|
|
1490
|
+
if (!text || typeof text !== "object") return [];
|
|
1491
|
+
return Object.values(text).filter((value) => typeof value === "string");
|
|
1492
|
+
}
|
|
1493
|
+
function haystack(item) {
|
|
1494
|
+
return [
|
|
1495
|
+
item.entry_name,
|
|
1496
|
+
item.name,
|
|
1497
|
+
item.id,
|
|
1498
|
+
item.description ?? "",
|
|
1499
|
+
item.version,
|
|
1500
|
+
...localizedValues(item.display_name),
|
|
1501
|
+
...localizedValues(item.display_description),
|
|
1502
|
+
...localizedValues(item.profession),
|
|
1503
|
+
...(item.tags ?? []).flatMap(localizedValues)
|
|
1504
|
+
].join("\n").toLowerCase();
|
|
1505
|
+
}
|
|
1506
|
+
function abortableDelay(ms, signal) {
|
|
1507
|
+
return new Promise((resolve, reject) => {
|
|
1508
|
+
if (signal?.aborted) {
|
|
1509
|
+
reject(new StoreError("aborted", "the operation was aborted"));
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const timer = setTimeout(() => {
|
|
1513
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1514
|
+
resolve();
|
|
1515
|
+
}, ms);
|
|
1516
|
+
const onAbort = () => {
|
|
1517
|
+
clearTimeout(timer);
|
|
1518
|
+
reject(new StoreError("aborted", "the operation was aborted"));
|
|
1519
|
+
};
|
|
1520
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
var StoreClient = class {
|
|
1524
|
+
host;
|
|
1525
|
+
readyTimeoutMs;
|
|
1526
|
+
readyPollMs;
|
|
1527
|
+
readyPollMaxMs;
|
|
1528
|
+
constructor(host, options = {}) {
|
|
1529
|
+
this.host = host;
|
|
1530
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
1531
|
+
this.readyPollMs = options.readyPollMs ?? DEFAULT_READY_POLL_MS;
|
|
1532
|
+
this.readyPollMaxMs = options.readyPollMaxMs ?? DEFAULT_READY_POLL_MAX_MS;
|
|
1533
|
+
}
|
|
1534
|
+
/** Every item across enabled marketplaces, with install state. */
|
|
1535
|
+
async list() {
|
|
1536
|
+
return (await this.host.listStore()).items;
|
|
1537
|
+
}
|
|
1538
|
+
/** Case-insensitive substring search over the fields a user can see. */
|
|
1539
|
+
async search(query, filter = {}) {
|
|
1540
|
+
const needle = query.trim().toLowerCase();
|
|
1541
|
+
return (await this.list()).filter((item) => {
|
|
1542
|
+
if (filter.kind && item.kind !== filter.kind) return false;
|
|
1543
|
+
if (!needle) return true;
|
|
1544
|
+
return haystack(item).includes(needle);
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
/** Items currently installed on this host. */
|
|
1548
|
+
async installed() {
|
|
1549
|
+
return (await this.list()).filter((item) => item.installed);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Installed items whose marketplace offers a different version.
|
|
1553
|
+
*
|
|
1554
|
+
* Both conditions matter: `update_available` is computed from the imported
|
|
1555
|
+
* snapshot even when nothing is installed, so filtering on it alone would
|
|
1556
|
+
* report an "update" for an entry the user has never installed.
|
|
1557
|
+
*/
|
|
1558
|
+
async checkUpdates() {
|
|
1559
|
+
return (await this.list()).filter((item) => item.installed && item.update_available);
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* What a caller can offer for an item's pending update.
|
|
1563
|
+
*
|
|
1564
|
+
* `"none"` means there is nothing to do; `"uninstall_reinstall"` means the
|
|
1565
|
+
* entry is installed at a different version and the only path the wire offers
|
|
1566
|
+
* is to release it and install again (the version-aware re-import then picks
|
|
1567
|
+
* up the current version). `"unknown"` is returned rather than guessing when
|
|
1568
|
+
* the item carries no snapshot to release.
|
|
1569
|
+
*/
|
|
1570
|
+
updateHint(item) {
|
|
1571
|
+
if (!item.installed || !item.update_available) return "none";
|
|
1572
|
+
return item.snapshot_id ? "uninstall_reinstall" : "unknown";
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Import (when needed) and install one store item, then wait until it is
|
|
1576
|
+
* usable.
|
|
1577
|
+
*/
|
|
1578
|
+
async install(item, options = {}) {
|
|
1579
|
+
this.assertUsable(item, options.signal);
|
|
1580
|
+
const result = await this.host.installStoreEntry(item.marketplace_id, item.entry_name);
|
|
1581
|
+
const components = result.outcomes ?? [];
|
|
1582
|
+
const outcome = {
|
|
1583
|
+
snapshotId: result.snapshot_id,
|
|
1584
|
+
reused: result.reused,
|
|
1585
|
+
components,
|
|
1586
|
+
ok: result.errors.length === 0 && components.every((component) => component.ok)
|
|
1587
|
+
};
|
|
1588
|
+
if (options.waitForReady === false) return outcome;
|
|
1589
|
+
return this.awaitReady(outcome, options);
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Release an item's runtime artifacts and clear its install record.
|
|
1593
|
+
*
|
|
1594
|
+
* The server only clears the record for components it actually released, so a
|
|
1595
|
+
* partial failure leaves `ok: false` with the failing component named — and
|
|
1596
|
+
* the item stays installed, which is what makes a retry meaningful.
|
|
1597
|
+
*/
|
|
1598
|
+
async uninstall(item, options = {}) {
|
|
1599
|
+
const snapshotId = this.requireSnapshot(item);
|
|
1600
|
+
const componentIds = options.componentIds ?? await this.installedComponentIds(snapshotId);
|
|
1601
|
+
if (componentIds.length === 0) return {
|
|
1602
|
+
snapshotId,
|
|
1603
|
+
reused: true,
|
|
1604
|
+
components: [],
|
|
1605
|
+
ok: true
|
|
1606
|
+
};
|
|
1607
|
+
const status = await this.host.uninstallInstall(snapshotId, componentIds);
|
|
1608
|
+
return this.outcomeFromStatus(snapshotId, status, false);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Switch an item's components on or off.
|
|
1612
|
+
*
|
|
1613
|
+
* Idempotent: the server sets the state it is asked for instead of toggling.
|
|
1614
|
+
* A skill reports `skill_disable_flag_only` because the skill corpus has no
|
|
1615
|
+
* enable state — that code is passed through, not swallowed, so a caller can
|
|
1616
|
+
* tell "switched off" from "marked in the catalogue".
|
|
1617
|
+
*/
|
|
1618
|
+
async setEnabled(item, enabled, options = {}) {
|
|
1619
|
+
const snapshotId = this.requireSnapshot(item);
|
|
1620
|
+
const componentIds = options.componentIds ?? await this.installedComponentIds(snapshotId);
|
|
1621
|
+
if (componentIds.length === 0) return {
|
|
1622
|
+
snapshotId,
|
|
1623
|
+
reused: true,
|
|
1624
|
+
components: [],
|
|
1625
|
+
ok: true
|
|
1626
|
+
};
|
|
1627
|
+
const status = enabled ? await this.host.enableInstall(snapshotId, componentIds) : await this.host.disableInstall(snapshotId, componentIds);
|
|
1628
|
+
return this.outcomeFromStatus(snapshotId, status, false);
|
|
1629
|
+
}
|
|
1630
|
+
assertUsable(item, signal) {
|
|
1631
|
+
if (signal?.aborted) throw new StoreError("aborted", "the operation was aborted before it started");
|
|
1632
|
+
if (item.blocked_reason) throw new StoreError("item_blocked", item.blocked_reason);
|
|
1633
|
+
}
|
|
1634
|
+
requireSnapshot(item) {
|
|
1635
|
+
const snapshotId = item.snapshot_id;
|
|
1636
|
+
if (!snapshotId) throw new StoreError("not_installed", `store item ${item.id} has no imported snapshot to act on`);
|
|
1637
|
+
return snapshotId;
|
|
1638
|
+
}
|
|
1639
|
+
async installedComponentIds(snapshotId) {
|
|
1640
|
+
return (await this.host.getInstallStatus(snapshotId)).components.filter((component) => component.state !== "not-installed").map((component) => component.id);
|
|
1641
|
+
}
|
|
1642
|
+
outcomeFromStatus(snapshotId, status, reused) {
|
|
1643
|
+
const components = status.outcomes ?? [];
|
|
1644
|
+
return {
|
|
1645
|
+
snapshotId,
|
|
1646
|
+
reused,
|
|
1647
|
+
components,
|
|
1648
|
+
ok: (status.errors ?? []).length === 0 && components.every((component) => component.ok)
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Poll until every component of the snapshot is usable.
|
|
1653
|
+
*
|
|
1654
|
+
* A timeout does **not** discard the install: the caller gets the successful
|
|
1655
|
+
* install plus `ready: false` and `readyIssue: "ready_timeout"`, so a slow
|
|
1656
|
+
* connector never turns into "the install failed".
|
|
1657
|
+
*/
|
|
1658
|
+
async awaitReady(outcome, options) {
|
|
1659
|
+
const snapshotId = outcome.snapshotId;
|
|
1660
|
+
if (!snapshotId) return outcome;
|
|
1661
|
+
const status = await this.host.getInstallStatus(snapshotId);
|
|
1662
|
+
const connectors = status.components.filter((component) => component.kind === "connector" && component.state !== "not-installed");
|
|
1663
|
+
if (connectors.length > 0) await this.host.enableInstall(snapshotId, connectors.map((component) => component.id));
|
|
1664
|
+
const deadline = Date.now() + (options.timeoutMs ?? this.readyTimeoutMs);
|
|
1665
|
+
let interval = this.readyPollMs;
|
|
1666
|
+
let last = status.components;
|
|
1667
|
+
for (;;) {
|
|
1668
|
+
const blocked = (await Promise.all(last.filter((component) => component.state !== "not-installed").map(async (component) => ({
|
|
1669
|
+
component,
|
|
1670
|
+
verdict: await this.readiness(component)
|
|
1671
|
+
})))).find((entry) => !entry.verdict.ready);
|
|
1672
|
+
if (!blocked) return {
|
|
1673
|
+
...outcome,
|
|
1674
|
+
ready: true
|
|
1675
|
+
};
|
|
1676
|
+
if (!blocked.verdict.ready && blocked.verdict.terminal) return {
|
|
1677
|
+
...outcome,
|
|
1678
|
+
ready: false,
|
|
1679
|
+
readyIssue: blocked.verdict.terminal,
|
|
1680
|
+
readyComponentId: blocked.component.id
|
|
1681
|
+
};
|
|
1682
|
+
if (Date.now() >= deadline) return {
|
|
1683
|
+
...outcome,
|
|
1684
|
+
ready: false,
|
|
1685
|
+
readyIssue: "ready_timeout",
|
|
1686
|
+
readyComponentId: blocked.component.id
|
|
1687
|
+
};
|
|
1688
|
+
await abortableDelay(interval, options.signal);
|
|
1689
|
+
interval = Math.min(interval * 2, this.readyPollMaxMs);
|
|
1690
|
+
last = (await this.host.getInstallStatus(snapshotId)).components;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Is this one component usable yet?
|
|
1695
|
+
*
|
|
1696
|
+
* Each kind answers with the narrowest read the protocol already offers. An
|
|
1697
|
+
* unknown kind is reported ready rather than blocking: this layer cannot judge
|
|
1698
|
+
* a component it does not understand, and a false timeout on a future kind
|
|
1699
|
+
* would be worse than passing it through.
|
|
1700
|
+
*/
|
|
1701
|
+
async readiness(component) {
|
|
1702
|
+
try {
|
|
1703
|
+
switch (component.kind) {
|
|
1704
|
+
case "skill": return (await this.host.skills.list()).some((skill) => skill.name === component.name) ? { ready: true } : { ready: false };
|
|
1705
|
+
case "agent": return (await this.host.agents.get(component.id)).preset_id ? { ready: true } : { ready: false };
|
|
1706
|
+
case "team":
|
|
1707
|
+
await this.host.teams.get(component.id);
|
|
1708
|
+
return { ready: true };
|
|
1709
|
+
case "connector": {
|
|
1710
|
+
if ((await this.host.connectors.test(component.id)).success) return { ready: true };
|
|
1711
|
+
const status = await this.host.connectors.status(component.id);
|
|
1712
|
+
if (status.status === "authorization_required" || status.status === "reauthorization_required") return {
|
|
1713
|
+
ready: false,
|
|
1714
|
+
terminal: "authorization_required"
|
|
1715
|
+
};
|
|
1716
|
+
return { ready: false };
|
|
1717
|
+
}
|
|
1718
|
+
default: return { ready: true };
|
|
1719
|
+
}
|
|
1720
|
+
} catch {
|
|
1721
|
+
return { ready: false };
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
576
1724
|
};
|
|
577
1725
|
//#endregion
|
|
578
1726
|
//#region src/teams.ts
|
|
@@ -662,10 +1810,18 @@ var AppServerClient = class {
|
|
|
662
1810
|
teams;
|
|
663
1811
|
/** Public model directory (`models/list`, REQ-PAR-05b). */
|
|
664
1812
|
models;
|
|
1813
|
+
/**
|
|
1814
|
+
* Agent Store lifecycle as one state machine: search → install (wait until
|
|
1815
|
+
* usable) → enable/disable → uninstall. Composes the flat methods below; it
|
|
1816
|
+
* adds no wire surface.
|
|
1817
|
+
*/
|
|
1818
|
+
store;
|
|
665
1819
|
clientInfo;
|
|
666
1820
|
capabilities;
|
|
667
1821
|
initializeResult = null;
|
|
668
1822
|
notificationListeners = /* @__PURE__ */ new Set();
|
|
1823
|
+
/** Transport → client notification bridge; re-armed after a transport close. */
|
|
1824
|
+
unbridgeNotifications = null;
|
|
669
1825
|
constructor(options) {
|
|
670
1826
|
this.clientInfo = options.client;
|
|
671
1827
|
this.capabilities = options.capabilities;
|
|
@@ -678,7 +1834,17 @@ var AppServerClient = class {
|
|
|
678
1834
|
this.agents = new AgentClient(this.transport);
|
|
679
1835
|
this.teams = new TeamClient(this.transport);
|
|
680
1836
|
this.models = new ModelClient(this.transport);
|
|
681
|
-
this.
|
|
1837
|
+
this.store = new StoreClient(this, options.store);
|
|
1838
|
+
this.bridgeNotifications();
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Subscribe the client-level fan-out on the transport exactly once.
|
|
1842
|
+
* `transport.close()` drops its listeners, so this is also called on
|
|
1843
|
+
* `connect()`: otherwise a reconnect would leave notifications silent.
|
|
1844
|
+
*/
|
|
1845
|
+
bridgeNotifications() {
|
|
1846
|
+
if (this.unbridgeNotifications) return;
|
|
1847
|
+
this.unbridgeNotifications = this.transport.onNotification((notification) => {
|
|
682
1848
|
for (const listener of [...this.notificationListeners]) try {
|
|
683
1849
|
listener(notification);
|
|
684
1850
|
} catch {}
|
|
@@ -692,6 +1858,7 @@ var AppServerClient = class {
|
|
|
692
1858
|
}
|
|
693
1859
|
/** Connect and perform the initialize/initialized handshake. */
|
|
694
1860
|
async connect() {
|
|
1861
|
+
this.bridgeNotifications();
|
|
695
1862
|
await this.transport.connect();
|
|
696
1863
|
const request = {
|
|
697
1864
|
protocol_version: _flowy_agent_store_protocol.APP_SERVER_PROTOCOL_VERSION,
|
|
@@ -712,6 +1879,8 @@ var AppServerClient = class {
|
|
|
712
1879
|
}
|
|
713
1880
|
/** Close the transport; the server revokes the connection immediately. */
|
|
714
1881
|
close() {
|
|
1882
|
+
this.unbridgeNotifications?.();
|
|
1883
|
+
this.unbridgeNotifications = null;
|
|
715
1884
|
this.transport.close();
|
|
716
1885
|
this.initializeResult = null;
|
|
717
1886
|
this.notificationListeners.clear();
|
|
@@ -735,7 +1904,14 @@ var AppServerClient = class {
|
|
|
735
1904
|
async getInstallStatus(snapshotId) {
|
|
736
1905
|
return this.transport.request("install/status", { snapshot_id: snapshotId });
|
|
737
1906
|
}
|
|
738
|
-
/**
|
|
1907
|
+
/**
|
|
1908
|
+
* Disable installed components.
|
|
1909
|
+
*
|
|
1910
|
+
* The runtime state moves with the flag — a connector's MCP server is switched
|
|
1911
|
+
* off, an expert's Preset is disabled — while the artifacts stay on disk. A
|
|
1912
|
+
* skill is the documented exception: the corpus has no enable state, so it
|
|
1913
|
+
* reports `skill_disable_flag_only` and the flag stays a catalogue marker.
|
|
1914
|
+
*/
|
|
739
1915
|
async disableInstall(snapshotId, componentIds) {
|
|
740
1916
|
return this.transport.request("install/disable", {
|
|
741
1917
|
snapshot_id: snapshotId,
|
|
@@ -1203,14 +2379,19 @@ exports.ConversationClient = ConversationClient;
|
|
|
1203
2379
|
exports.ConversationHandle = ConversationHandle;
|
|
1204
2380
|
exports.ConversationSubscription = ConversationSubscription;
|
|
1205
2381
|
exports.EventSubscription = EventSubscription;
|
|
2382
|
+
exports.HttpTransport = HttpTransport;
|
|
1206
2383
|
exports.ModelClient = ModelClient;
|
|
1207
2384
|
exports.RunClient = RunClient;
|
|
1208
2385
|
exports.SkillClient = SkillClient;
|
|
2386
|
+
exports.StoreClient = StoreClient;
|
|
2387
|
+
exports.StoreError = StoreError;
|
|
1209
2388
|
exports.TeamClient = TeamClient;
|
|
1210
2389
|
exports.WebSocketTransport = WebSocketTransport;
|
|
1211
2390
|
exports.WorkspaceClient = WorkspaceClient;
|
|
1212
2391
|
exports.aggregateTurnResult = aggregateTurnResult;
|
|
2392
|
+
exports.appServerErrorFromWire = appServerErrorFromWire;
|
|
1213
2393
|
exports.conversationMessages = conversationMessages;
|
|
2394
|
+
exports.httpRouteTable = httpRouteTable;
|
|
1214
2395
|
exports.isLoopbackUrl = isLoopbackUrl;
|
|
1215
2396
|
exports.launchRun = launchRun;
|
|
1216
2397
|
exports.withRetry = withRetry;
|