pilotswarm-sdk 0.3.2 → 0.4.0
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/README.md +21 -4
- package/api/README.md +44 -0
- package/api/index.d.ts +86 -0
- package/api/index.js +16 -0
- package/api/src/api-client.js +363 -0
- package/api/src/http-api-transport.js +342 -0
- package/api/src/protocol.js +230 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -0
- package/dist/client.js.map +1 -1
- package/dist/cms-migrations.d.ts.map +1 -1
- package/dist/cms-migrations.js +250 -0
- package/dist/cms-migrations.js.map +1 -1
- package/dist/cms.d.ts +10 -0
- package/dist/cms.d.ts.map +1 -1
- package/dist/cms.js +5 -0
- package/dist/cms.js.map +1 -1
- package/dist/index.d.ts +25 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -7
- package/dist/index.js.map +1 -1
- package/dist/managed-session.d.ts +35 -0
- package/dist/managed-session.d.ts.map +1 -1
- package/dist/managed-session.js +74 -0
- package/dist/managed-session.js.map +1 -1
- package/dist/management-client.d.ts +88 -3
- package/dist/management-client.d.ts.map +1 -1
- package/dist/management-client.js +259 -2
- package/dist/management-client.js.map +1 -1
- package/dist/orchestration/index.d.ts +2 -2
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/runtime.d.ts +1 -1
- package/dist/orchestration/turn.d.ts +13 -0
- package/dist/orchestration/turn.d.ts.map +1 -1
- package/dist/orchestration/turn.js +317 -163
- package/dist/orchestration/turn.js.map +1 -1
- package/dist/orchestration-registry.d.ts.map +1 -1
- package/dist/orchestration-registry.js +4 -2
- package/dist/orchestration-registry.js.map +1 -1
- package/dist/orchestration-version.d.ts +1 -1
- package/dist/orchestration-version.js +1 -1
- package/dist/orchestration.d.ts +2 -2
- package/dist/orchestration.js +1 -1
- package/dist/orchestration_1_0_46.d.ts +1 -1
- package/dist/orchestration_1_0_47.d.ts +1 -1
- package/dist/orchestration_1_0_48.d.ts +1 -1
- package/dist/orchestration_1_0_49.d.ts +1 -1
- package/dist/orchestration_1_0_50.d.ts +1 -1
- package/dist/orchestration_1_0_55/agents.d.ts +41 -0
- package/dist/orchestration_1_0_55/agents.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/agents.js +758 -0
- package/dist/orchestration_1_0_55/agents.js.map +1 -0
- package/dist/orchestration_1_0_55/index.d.ts +24 -0
- package/dist/orchestration_1_0_55/index.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/index.js +13 -0
- package/dist/orchestration_1_0_55/index.js.map +1 -0
- package/dist/orchestration_1_0_55/lifecycle.d.ts +41 -0
- package/dist/orchestration_1_0_55/lifecycle.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/lifecycle.js +580 -0
- package/dist/orchestration_1_0_55/lifecycle.js.map +1 -0
- package/dist/orchestration_1_0_55/queue.d.ts +7 -0
- package/dist/orchestration_1_0_55/queue.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/queue.js +644 -0
- package/dist/orchestration_1_0_55/queue.js.map +1 -0
- package/dist/orchestration_1_0_55/runtime.d.ts +29 -0
- package/dist/orchestration_1_0_55/runtime.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/runtime.js +194 -0
- package/dist/orchestration_1_0_55/runtime.js.map +1 -0
- package/dist/orchestration_1_0_55/state.d.ts +126 -0
- package/dist/orchestration_1_0_55/state.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/state.js +106 -0
- package/dist/orchestration_1_0_55/state.js.map +1 -0
- package/dist/orchestration_1_0_55/turn.d.ts +17 -0
- package/dist/orchestration_1_0_55/turn.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/turn.js +921 -0
- package/dist/orchestration_1_0_55/turn.js.map +1 -0
- package/dist/orchestration_1_0_55/utils.d.ts +22 -0
- package/dist/orchestration_1_0_55/utils.d.ts.map +1 -0
- package/dist/orchestration_1_0_55/utils.js +226 -0
- package/dist/orchestration_1_0_55/utils.js.map +1 -0
- package/dist/session-manager.d.ts +20 -1
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +62 -0
- package/dist/session-manager.js.map +1 -1
- package/dist/session-proxy.d.ts +7 -0
- package/dist/session-proxy.d.ts.map +1 -1
- package/dist/session-proxy.js +35 -1
- package/dist/session-proxy.js.map +1 -1
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +13 -3
- package/dist/session-store.js.map +1 -1
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/dist/web/api-connection.d.ts +20 -0
- package/dist/web/api-connection.d.ts.map +1 -0
- package/dist/web/api-connection.js +23 -0
- package/dist/web/api-connection.js.map +1 -0
- package/dist/web/web-client.d.ts +91 -0
- package/dist/web/web-client.d.ts.map +1 -0
- package/dist/web/web-client.js +378 -0
- package/dist/web/web-client.js.map +1 -0
- package/dist/web/web-fact-store.d.ts +78 -0
- package/dist/web/web-fact-store.d.ts.map +1 -0
- package/dist/web/web-fact-store.js +112 -0
- package/dist/web/web-fact-store.js.map +1 -0
- package/dist/web/web-graph-store.d.ts +38 -0
- package/dist/web/web-graph-store.d.ts.map +1 -0
- package/dist/web/web-graph-store.js +68 -0
- package/dist/web/web-graph-store.js.map +1 -0
- package/dist/web/web-management-client.d.ts +125 -0
- package/dist/web/web-management-client.d.ts.map +1 -0
- package/dist/web/web-management-client.js +256 -0
- package/dist/web/web-management-client.js.map +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +10 -0
- package/dist/worker.js.map +1 -1
- package/package.json +16 -6
package/README.md
CHANGED
|
@@ -14,6 +14,23 @@ Minimal usage:
|
|
|
14
14
|
import { PilotSwarmClient, PilotSwarmWorker, defineTool } from "pilotswarm-sdk";
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Client apps connect to a deployment's Web API (web mode):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const client = new PilotSwarmClient({ apiUrl: "https://portal.example.com" });
|
|
21
|
+
await client.start();
|
|
22
|
+
const session = await client.createSession();
|
|
23
|
+
const reply = await session.sendAndWait("hello");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pass `getAccessToken` for authenticated deployments; use `PilotSwarmManagementClient({ apiUrl, getAccessToken? })` for management operations. Constructing a client directly with `{ store }` is internal (worker/portal-host embedding and testing) — see the [Web API reference](https://github.com/affandar/PilotSwarm/blob/main/docs/api/reference.md).
|
|
27
|
+
|
|
28
|
+
Workers are trusted backend components and always attach directly to the store:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const worker = new PilotSwarmWorker({ store: process.env.DATABASE_URL });
|
|
32
|
+
```
|
|
33
|
+
|
|
17
34
|
`pilotswarm-sdk` ships PilotSwarm's embedded framework prompt, framework skills, and management plugins inside the package. App code should provide its own `plugin/` directory and worker-side tool handlers on top of that base.
|
|
18
35
|
|
|
19
36
|
Artifact note:
|
|
@@ -24,9 +41,9 @@ Artifact note:
|
|
|
24
41
|
|
|
25
42
|
Common docs:
|
|
26
43
|
|
|
27
|
-
- SDK apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/
|
|
28
|
-
- SDK agents: `https://github.com/affandar/PilotSwarm/blob/main/docs/
|
|
29
|
-
- Configuration: `https://github.com/affandar/PilotSwarm/blob/main/docs/configuration.md`
|
|
30
|
-
- Architecture: `https://github.com/affandar/PilotSwarm/blob/main/docs/architecture.md`
|
|
44
|
+
- SDK apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/building/sdk-apps.md`
|
|
45
|
+
- SDK agents: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/building/sdk-agents.md`
|
|
46
|
+
- Configuration: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/reference/configuration.md`
|
|
47
|
+
- Architecture: `https://github.com/affandar/PilotSwarm/blob/main/docs/architecture/system.md`
|
|
31
48
|
|
|
32
49
|
If you want the shipped terminal UI, install `pilotswarm-cli`.
|
package/api/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# pilotswarm-sdk/api
|
|
2
|
+
|
|
3
|
+
Isomorphic protocol client for the [PilotSwarm Web API](../../docs/api/reference.md)
|
|
4
|
+
(`/api/v1` + `/api/v1/ws`). Runs in browsers and Node (≥22) with zero
|
|
5
|
+
dependencies — `fetch` and `WebSocket` come from the environment and are
|
|
6
|
+
injectable for tests.
|
|
7
|
+
|
|
8
|
+
This package is the transport layer under everything that talks to a
|
|
9
|
+
PilotSwarm deployment remotely: the browser portal, the TUI's API mode, and
|
|
10
|
+
the SDK's web provider (`new PilotSwarmClient({ apiUrl })`). Most applications
|
|
11
|
+
should use the [SDK](../sdk/README.md) rather than this package directly.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { ApiClient } from "pilotswarm-sdk/api";
|
|
15
|
+
|
|
16
|
+
const api = new ApiClient({
|
|
17
|
+
apiUrl: "https://portal.example.com",
|
|
18
|
+
getAccessToken, // optional; omit for no-auth deployments
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const session = await api.call("createSession", { model: "anthropic:claude-sonnet-4-6" });
|
|
22
|
+
await api.call("sendMessage", { sessionId: session.sessionId, prompt: "hello" });
|
|
23
|
+
const unsubscribe = api.subscribeSession(session.sessionId, (event) => console.log(event));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Modules
|
|
27
|
+
|
|
28
|
+
- `src/protocol.js` — the **operations table**: every JSON operation's name,
|
|
29
|
+
HTTP method, path template, and parameter placement. This is the single
|
|
30
|
+
source of truth shared with the portal server (which generates its routes
|
|
31
|
+
from it) and the API reference doc.
|
|
32
|
+
- `src/api-client.js` — `ApiClient`: request building, `{ ok, result }`
|
|
33
|
+
envelope handling, 401/403 callbacks, and the `/api/v1/ws` WebSocket with
|
|
34
|
+
automatic resubscribe + backoff.
|
|
35
|
+
- `src/http-api-transport.js` — `HttpApiTransport`: the shared-UI transport
|
|
36
|
+
surface (`pilotswarm/ui-core`) over `ApiClient`. Environment conveniences
|
|
37
|
+
(save-to-disk, open-in-app) are injected by the host (browser portal, TUI).
|
|
38
|
+
|
|
39
|
+
## Auth
|
|
40
|
+
|
|
41
|
+
`getAccessToken` is called before every request and WebSocket connect. HTTP
|
|
42
|
+
401/403 invoke `onUnauthorized`/`onForbidden`; WebSocket close codes 4401/4403
|
|
43
|
+
do the same and suppress reconnection. Deployment auth mode is discovered from
|
|
44
|
+
the public `GET /api/v1/auth/config` (`api.getAuthConfig()`).
|
package/api/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Typed surface for pilotswarm-sdk/api (implementation is plain ESM JS). */
|
|
2
|
+
|
|
3
|
+
export declare const API_PREFIX: string;
|
|
4
|
+
export declare const API_VERSION: number;
|
|
5
|
+
export declare const WS_PATH: string;
|
|
6
|
+
export declare const WS_CLIENT_MESSAGES: string[];
|
|
7
|
+
export declare const WS_SERVER_MESSAGES: string[];
|
|
8
|
+
export declare const WEB_MODE_UNSUPPORTED: string;
|
|
9
|
+
|
|
10
|
+
export interface OperationParamSpec {
|
|
11
|
+
in: "path" | "query" | "body";
|
|
12
|
+
name?: string;
|
|
13
|
+
type?: "string" | "number" | "boolean" | "json";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Operation {
|
|
17
|
+
name: string;
|
|
18
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
19
|
+
path: string;
|
|
20
|
+
params?: Record<string, OperationParamSpec>;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export declare const OPERATIONS: Operation[];
|
|
25
|
+
export declare function getOperation(name: string): Operation | null;
|
|
26
|
+
export declare function buildOperationRequest(name: string, params?: Record<string, unknown>): {
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
query: URLSearchParams;
|
|
30
|
+
body: Record<string, unknown> | null;
|
|
31
|
+
};
|
|
32
|
+
export declare function coerceQueryValue(value: unknown, type?: string): unknown;
|
|
33
|
+
export declare function artifactDownloadPath(sessionId: string, filename: string): string;
|
|
34
|
+
|
|
35
|
+
export declare class ApiError extends Error {
|
|
36
|
+
code: string;
|
|
37
|
+
status: number;
|
|
38
|
+
constructor(message: string, opts?: { code?: string; status?: number });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ApiClientOptions {
|
|
42
|
+
apiUrl: string;
|
|
43
|
+
getAccessToken?: () => Promise<string | null>;
|
|
44
|
+
onUnauthorized?: () => void;
|
|
45
|
+
onForbidden?: (message: string) => void;
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
WebSocketImpl?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export declare class ApiClient {
|
|
51
|
+
constructor(options: ApiClientOptions);
|
|
52
|
+
apiUrl: string;
|
|
53
|
+
call(name: string, params?: Record<string, unknown>): Promise<any>;
|
|
54
|
+
request(method: string, pathWithQuery: string, opts?: { body?: unknown; headers?: Record<string, string> }): Promise<any>;
|
|
55
|
+
health(): Promise<any>;
|
|
56
|
+
getAuthConfig(): Promise<any>;
|
|
57
|
+
getAuthContext(): Promise<any>;
|
|
58
|
+
getBootstrap(): Promise<any>;
|
|
59
|
+
downloadArtifactResponse(sessionId: string, filename: string): Promise<Response>;
|
|
60
|
+
start(): Promise<void>;
|
|
61
|
+
stop(): Promise<void>;
|
|
62
|
+
subscribeSession(sessionId: string, handler: (event: unknown) => void, onResubscribe?: () => void): () => void;
|
|
63
|
+
subscribeLogs(handler: (entry: unknown) => void): () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HttpApiTransportHost {
|
|
67
|
+
saveArtifactDownload?: (transport: HttpApiTransport, sessionId: string, filename: string) => Promise<unknown>;
|
|
68
|
+
uploadArtifactFromPath?: (transport: HttpApiTransport, sessionId: string, filePath: string) => Promise<unknown>;
|
|
69
|
+
openPathInDefaultApp?: (targetPath: string) => Promise<unknown>;
|
|
70
|
+
openUrlInDefaultBrowser?: (targetUrl: string) => Promise<unknown>;
|
|
71
|
+
artifactExportDirectory?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HttpApiTransportOptions extends ApiClientOptions {
|
|
75
|
+
api?: ApiClient;
|
|
76
|
+
host?: HttpApiTransportHost;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export declare class HttpApiTransport {
|
|
80
|
+
constructor(options: HttpApiTransportOptions);
|
|
81
|
+
api: ApiClient;
|
|
82
|
+
bootstrap: any;
|
|
83
|
+
start(): Promise<void>;
|
|
84
|
+
stop(): Promise<void>;
|
|
85
|
+
[method: string]: any;
|
|
86
|
+
}
|
package/api/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
API_PREFIX,
|
|
3
|
+
API_VERSION,
|
|
4
|
+
WS_PATH,
|
|
5
|
+
WS_CLIENT_MESSAGES,
|
|
6
|
+
WS_SERVER_MESSAGES,
|
|
7
|
+
WEB_MODE_UNSUPPORTED,
|
|
8
|
+
OPERATIONS,
|
|
9
|
+
getOperation,
|
|
10
|
+
buildOperationRequest,
|
|
11
|
+
coerceQueryValue,
|
|
12
|
+
artifactDownloadPath,
|
|
13
|
+
ApiError,
|
|
14
|
+
} from "./src/protocol.js";
|
|
15
|
+
export { ApiClient } from "./src/api-client.js";
|
|
16
|
+
export { HttpApiTransport } from "./src/http-api-transport.js";
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { API_PREFIX, ApiError, WS_PATH, artifactDownloadPath, buildOperationRequest } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
const RECONNECT_DELAY_MS = 1500;
|
|
4
|
+
|
|
5
|
+
function normalizeApiUrl(apiUrl) {
|
|
6
|
+
const raw = String(apiUrl || "").trim();
|
|
7
|
+
if (!raw) throw new Error("ApiClient requires an apiUrl");
|
|
8
|
+
return raw.replace(/\/+$/, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toWebSocketUrl(apiUrl) {
|
|
12
|
+
const url = new URL(apiUrl);
|
|
13
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
14
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}${WS_PATH}`;
|
|
15
|
+
url.search = "";
|
|
16
|
+
return url.toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readErrorEnvelope(response) {
|
|
20
|
+
let message = response.statusText || `HTTP ${response.status}`;
|
|
21
|
+
let code = response.status === 401 ? "UNAUTHORIZED" : response.status === 403 ? "FORBIDDEN" : "INTERNAL_ERROR";
|
|
22
|
+
try {
|
|
23
|
+
const payload = await response.json();
|
|
24
|
+
const error = payload?.error;
|
|
25
|
+
if (typeof error === "string" && error) message = error;
|
|
26
|
+
else if (error && typeof error === "object") {
|
|
27
|
+
if (error.message) message = error.message;
|
|
28
|
+
if (error.code) code = error.code;
|
|
29
|
+
} else if (payload?.message) {
|
|
30
|
+
message = payload.message;
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
return new ApiError(message, { code, status: response.status });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Typed low-level client for the PilotSwarm Web API.
|
|
38
|
+
*
|
|
39
|
+
* Isomorphic: runs in browsers and Node. `fetch` and `WebSocket` come from
|
|
40
|
+
* the global environment unless injected (`fetchImpl` / `WebSocketImpl`).
|
|
41
|
+
*/
|
|
42
|
+
export class ApiClient {
|
|
43
|
+
constructor({
|
|
44
|
+
apiUrl,
|
|
45
|
+
getAccessToken,
|
|
46
|
+
onUnauthorized,
|
|
47
|
+
onForbidden,
|
|
48
|
+
fetchImpl,
|
|
49
|
+
WebSocketImpl,
|
|
50
|
+
} = {}) {
|
|
51
|
+
this.apiUrl = normalizeApiUrl(apiUrl);
|
|
52
|
+
this.getAccessToken = typeof getAccessToken === "function" ? getAccessToken : async () => null;
|
|
53
|
+
this.onUnauthorized = typeof onUnauthorized === "function" ? onUnauthorized : () => {};
|
|
54
|
+
this.onForbidden = typeof onForbidden === "function" ? onForbidden : () => {};
|
|
55
|
+
this.fetchImpl = fetchImpl || ((...args) => globalThis.fetch(...args));
|
|
56
|
+
this.WebSocketImpl = WebSocketImpl || globalThis.WebSocket;
|
|
57
|
+
|
|
58
|
+
this.socket = null;
|
|
59
|
+
this.socketOpenPromise = null;
|
|
60
|
+
this.reconnectTimer = null;
|
|
61
|
+
this.stopped = false;
|
|
62
|
+
this.hasConnected = false;
|
|
63
|
+
this.sessionSubscribers = new Map();
|
|
64
|
+
this.sessionResubscribeHandlers = new Map();
|
|
65
|
+
this.logSubscribers = new Set();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── HTTP ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async authHeaders(extra = {}) {
|
|
71
|
+
const token = await this.getAccessToken();
|
|
72
|
+
const headers = { ...extra };
|
|
73
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async request(method, pathWithQuery, { body, headers } = {}) {
|
|
78
|
+
const requestHeaders = await this.authHeaders(headers || {});
|
|
79
|
+
if (body !== undefined && !requestHeaders["content-type"]) {
|
|
80
|
+
requestHeaders["content-type"] = "application/json";
|
|
81
|
+
}
|
|
82
|
+
const response = await this.fetchImpl(`${this.apiUrl}${pathWithQuery}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: requestHeaders,
|
|
85
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
86
|
+
});
|
|
87
|
+
if (response.status === 401) {
|
|
88
|
+
this.onUnauthorized();
|
|
89
|
+
throw await readErrorEnvelope(response);
|
|
90
|
+
}
|
|
91
|
+
if (response.status === 403) {
|
|
92
|
+
const error = await readErrorEnvelope(response);
|
|
93
|
+
this.onForbidden(error.message || "Forbidden");
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
throw await readErrorEnvelope(response);
|
|
98
|
+
}
|
|
99
|
+
const payload = await response.json();
|
|
100
|
+
if (payload && payload.ok === false) {
|
|
101
|
+
const error = payload.error;
|
|
102
|
+
throw new ApiError(
|
|
103
|
+
(typeof error === "object" ? error?.message : error) || "Request failed",
|
|
104
|
+
{ code: (typeof error === "object" && error?.code) || "INTERNAL_ERROR", status: response.status },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return payload?.result !== undefined ? payload.result : payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Invoke a protocol operation by name with rpc-shaped params. */
|
|
111
|
+
async call(name, params = {}) {
|
|
112
|
+
const { method, path, query, body } = buildOperationRequest(name, params);
|
|
113
|
+
// Avoid URLSearchParams.prototype.size (absent on Safari 16 / iOS 16,
|
|
114
|
+
// which the portal build targets); toString() is universally supported.
|
|
115
|
+
const queryString = query.toString();
|
|
116
|
+
const suffix = queryString ? `?${queryString}` : "";
|
|
117
|
+
return this.request(method, `${path}${suffix}`, body !== null ? { body } : {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Bespoke (non-table) endpoints ───────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async health() {
|
|
123
|
+
return this.request("GET", `${API_PREFIX}/health`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Public: no token required. */
|
|
127
|
+
async getAuthConfig() {
|
|
128
|
+
const response = await this.fetchImpl(`${this.apiUrl}${API_PREFIX}/auth/config`, { method: "GET" });
|
|
129
|
+
if (!response.ok) throw await readErrorEnvelope(response);
|
|
130
|
+
return response.json();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getAuthContext() {
|
|
134
|
+
return this.request("GET", `${API_PREFIX}/auth/me`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async getBootstrap() {
|
|
138
|
+
return this.request("GET", `${API_PREFIX}/bootstrap`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Raw artifact download; returns the Response for streaming/blob use. */
|
|
142
|
+
async downloadArtifactResponse(sessionId, filename) {
|
|
143
|
+
const headers = await this.authHeaders();
|
|
144
|
+
const response = await this.fetchImpl(`${this.apiUrl}${artifactDownloadPath(sessionId, filename)}`, {
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers,
|
|
147
|
+
});
|
|
148
|
+
if (response.status === 401) {
|
|
149
|
+
this.onUnauthorized();
|
|
150
|
+
throw await readErrorEnvelope(response);
|
|
151
|
+
}
|
|
152
|
+
if (response.status === 403) {
|
|
153
|
+
const error = await readErrorEnvelope(response);
|
|
154
|
+
this.onForbidden(error.message || "Forbidden");
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
if (!response.ok) throw await readErrorEnvelope(response);
|
|
158
|
+
return response;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── WebSocket (session events + log tail) ───────────────────────────
|
|
162
|
+
|
|
163
|
+
async start() {
|
|
164
|
+
this.stopped = false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async stop() {
|
|
168
|
+
this.stopped = true;
|
|
169
|
+
this.hasConnected = false;
|
|
170
|
+
if (this.reconnectTimer) {
|
|
171
|
+
clearTimeout(this.reconnectTimer);
|
|
172
|
+
this.reconnectTimer = null;
|
|
173
|
+
}
|
|
174
|
+
if (this.socket) {
|
|
175
|
+
try {
|
|
176
|
+
this.socket.close();
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
this.socket = null;
|
|
180
|
+
this.socketOpenPromise = null;
|
|
181
|
+
this.sessionSubscribers.clear();
|
|
182
|
+
this.logSubscribers.clear();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
scheduleReconnect() {
|
|
186
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
187
|
+
if (this.sessionSubscribers.size === 0 && this.logSubscribers.size === 0) return;
|
|
188
|
+
this.reconnectTimer = setTimeout(() => {
|
|
189
|
+
this.reconnectTimer = null;
|
|
190
|
+
this.ensureSocket().catch(() => {});
|
|
191
|
+
}, RECONNECT_DELAY_MS);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async ensureSocket() {
|
|
195
|
+
if (this.stopped) return null;
|
|
196
|
+
const WebSocketImpl = this.WebSocketImpl;
|
|
197
|
+
if (!WebSocketImpl) throw new Error("No WebSocket implementation available");
|
|
198
|
+
if (this.socket && this.socket.readyState === WebSocketImpl.OPEN) {
|
|
199
|
+
return this.socket;
|
|
200
|
+
}
|
|
201
|
+
if (this.socketOpenPromise) {
|
|
202
|
+
return this.socketOpenPromise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.socketOpenPromise = (async () => {
|
|
206
|
+
let socket;
|
|
207
|
+
try {
|
|
208
|
+
const token = await this.getAccessToken();
|
|
209
|
+
const socketUrl = toWebSocketUrl(this.apiUrl);
|
|
210
|
+
socket = token
|
|
211
|
+
? new WebSocketImpl(socketUrl, ["access_token", token])
|
|
212
|
+
: new WebSocketImpl(socketUrl);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// getAccessToken rejected or the constructor threw before any
|
|
215
|
+
// socket exists — no close/error event will fire, so schedule
|
|
216
|
+
// the retry here or the connection dies permanently.
|
|
217
|
+
this.socket = null;
|
|
218
|
+
this.scheduleReconnect();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
this.socket = socket;
|
|
222
|
+
|
|
223
|
+
socket.addEventListener("message", (event) => {
|
|
224
|
+
try {
|
|
225
|
+
const message = JSON.parse(String(event.data || ""));
|
|
226
|
+
if (message.type === "sessionEvent") {
|
|
227
|
+
const handlers = this.sessionSubscribers.get(message.sessionId);
|
|
228
|
+
if (handlers) {
|
|
229
|
+
for (const handler of handlers) handler(message.event);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (message.type === "logEntry") {
|
|
234
|
+
for (const handler of this.logSubscribers) handler(message.entry);
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
socket.addEventListener("close", (event) => {
|
|
240
|
+
this.socket = null;
|
|
241
|
+
this.socketOpenPromise = null;
|
|
242
|
+
if (event.code === 4401) {
|
|
243
|
+
this.onUnauthorized();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (event.code === 4403) {
|
|
247
|
+
this.onForbidden(event.reason || "Forbidden");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.scheduleReconnect();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
socket.addEventListener("error", () => {
|
|
254
|
+
this.scheduleReconnect();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
socket.addEventListener("open", resolve, { once: true });
|
|
259
|
+
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { once: true });
|
|
260
|
+
socket.addEventListener("close", (event) => {
|
|
261
|
+
if (event.code === 4401) reject(new ApiError("Unauthorized", { code: "UNAUTHORIZED", status: 401 }));
|
|
262
|
+
if (event.code === 4403) reject(new ApiError(event.reason || "Forbidden", { code: "FORBIDDEN", status: 403 }));
|
|
263
|
+
}, { once: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const isReconnect = this.hasConnected;
|
|
267
|
+
this.hasConnected = true;
|
|
268
|
+
this.resubscribeAll(isReconnect);
|
|
269
|
+
return socket;
|
|
270
|
+
})();
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
return await this.socketOpenPromise;
|
|
274
|
+
} finally {
|
|
275
|
+
if (!this.socket || this.socket.readyState !== WebSocketImpl.OPEN) {
|
|
276
|
+
this.socketOpenPromise = null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
socketSend(message) {
|
|
282
|
+
if (this.socket && this.socket.readyState === this.WebSocketImpl.OPEN) {
|
|
283
|
+
this.socket.send(JSON.stringify(message));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
resubscribeAll(isReconnect = false) {
|
|
288
|
+
for (const sessionId of this.sessionSubscribers.keys()) {
|
|
289
|
+
this.socketSend({ type: "subscribeSession", sessionId });
|
|
290
|
+
// On a RECONNECT, live delivery resumes but events emitted during
|
|
291
|
+
// the outage were missed. Signal consumers so they can replay via
|
|
292
|
+
// events?afterSeq. Not on the first connect — the consumer does its
|
|
293
|
+
// own initial catch-up then.
|
|
294
|
+
if (isReconnect) {
|
|
295
|
+
for (const onResubscribe of this.sessionResubscribeHandlers.get(sessionId) || []) {
|
|
296
|
+
try {
|
|
297
|
+
onResubscribe();
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (this.logSubscribers.size > 0) {
|
|
303
|
+
this.socketSend({ type: "subscribeLogs" });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Register a subscription and make sure the server knows about it: when
|
|
309
|
+
* the socket is already open, send the subscribe message directly;
|
|
310
|
+
* otherwise connect, and resubscribeAll() announces it on open.
|
|
311
|
+
*/
|
|
312
|
+
announceSubscription(message) {
|
|
313
|
+
if (this.socket && this.socket.readyState === this.WebSocketImpl?.OPEN) {
|
|
314
|
+
this.socketSend(message);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.ensureSocket().catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Subscribe to a session's events. `onResubscribe` (optional) fires after
|
|
322
|
+
* every reconnect so the caller can replay events missed during the
|
|
323
|
+
* outage — WS delivery is an acceleration path; replay is the correctness
|
|
324
|
+
* mechanism.
|
|
325
|
+
*/
|
|
326
|
+
subscribeSession(sessionId, handler, onResubscribe) {
|
|
327
|
+
if (!this.sessionSubscribers.has(sessionId)) {
|
|
328
|
+
this.sessionSubscribers.set(sessionId, new Set());
|
|
329
|
+
}
|
|
330
|
+
const handlers = this.sessionSubscribers.get(sessionId);
|
|
331
|
+
handlers.add(handler);
|
|
332
|
+
if (typeof onResubscribe === "function") {
|
|
333
|
+
if (!this.sessionResubscribeHandlers.has(sessionId)) {
|
|
334
|
+
this.sessionResubscribeHandlers.set(sessionId, new Set());
|
|
335
|
+
}
|
|
336
|
+
this.sessionResubscribeHandlers.get(sessionId).add(onResubscribe);
|
|
337
|
+
}
|
|
338
|
+
this.announceSubscription({ type: "subscribeSession", sessionId });
|
|
339
|
+
|
|
340
|
+
return () => {
|
|
341
|
+
handlers.delete(handler);
|
|
342
|
+
const resubHandlers = this.sessionResubscribeHandlers.get(sessionId);
|
|
343
|
+
if (resubHandlers && typeof onResubscribe === "function") resubHandlers.delete(onResubscribe);
|
|
344
|
+
if (handlers.size === 0) {
|
|
345
|
+
this.sessionSubscribers.delete(sessionId);
|
|
346
|
+
this.sessionResubscribeHandlers.delete(sessionId);
|
|
347
|
+
this.socketSend({ type: "unsubscribeSession", sessionId });
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
subscribeLogs(handler) {
|
|
353
|
+
this.logSubscribers.add(handler);
|
|
354
|
+
this.announceSubscription({ type: "subscribeLogs" });
|
|
355
|
+
|
|
356
|
+
return () => {
|
|
357
|
+
this.logSubscribers.delete(handler);
|
|
358
|
+
if (this.logSubscribers.size === 0) {
|
|
359
|
+
this.socketSend({ type: "unsubscribeLogs" });
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|