@the-open-engine/zeroshot 6.25.1 → 6.27.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/lib/cluster/client.cjs +30 -5
- package/lib/cluster/client.d.ts +11 -1
- package/lib/cluster/client.mjs +29 -5
- package/lib/cluster/connection.cjs +38 -2
- package/lib/cluster/connection.d.ts +3 -0
- package/lib/cluster/connection.mjs +37 -1
- package/lib/cluster/index.cjs +3 -1
- package/lib/cluster/index.d.ts +3 -3
- package/lib/cluster/index.mjs +2 -2
- package/lib/hosted-session/coordinator.cjs +101 -0
- package/lib/hosted-session/coordinator.d.ts +9 -0
- package/lib/hosted-session/coordinator.mjs +97 -0
- package/lib/hosted-session/index.cjs +5 -0
- package/lib/hosted-session/index.d.ts +2 -0
- package/lib/hosted-session/index.mjs +1 -0
- package/lib/hosted-session/types.cjs +2 -0
- package/lib/hosted-session/types.d.ts +20 -0
- package/lib/hosted-session/types.mjs +1 -0
- package/package.json +14 -5
- package/scripts/build-cluster.js +21 -7
- package/src/cluster/client.ts +45 -5
- package/src/cluster/connection.ts +32 -1
- package/src/cluster/index.ts +4 -2
- package/src/cluster/ws.d.ts +1 -0
- package/src/hosted-session/coordinator.ts +110 -0
- package/src/hosted-session/index.ts +2 -0
- package/src/hosted-session/types.ts +21 -0
- package/src/hosted-target/bounds.ts +6 -0
- package/src/hosted-target/errors.ts +90 -0
- package/src/hosted-target/index.ts +44 -0
- package/src/hosted-target/response-validation.ts +86 -0
- package/src/hosted-target/retry.ts +46 -0
- package/src/hosted-target/target-adapter.ts +10 -0
- package/src/hosted-target/types.ts +58 -0
- package/src/hosted-target/zero-cloud-v1-adapter.ts +386 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-open-engine/zeroshot",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.0",
|
|
4
4
|
"description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
|
|
5
5
|
"main": "src/orchestrator.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,19 +40,22 @@
|
|
|
40
40
|
"test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
|
|
41
41
|
"postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
|
|
42
42
|
"start": "node cli/index.js",
|
|
43
|
-
"typecheck": "tsc --noEmit && npm run typecheck:cluster",
|
|
43
|
+
"typecheck": "tsc --noEmit && npm run typecheck:cluster && npm run typecheck:hosted-target && npm run typecheck:hosted-session",
|
|
44
44
|
"typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
|
|
45
|
+
"typecheck:hosted-target": "tsc --project tsconfig.hosted-target.json",
|
|
46
|
+
"typecheck:hosted-session": "tsc --project tsconfig.hosted-session.json",
|
|
47
|
+
"test:hosted-target": "node --test tests/hosted-target/*.test.ts",
|
|
45
48
|
"typecheck:cluster": "tsc --project tsconfig.cluster.json",
|
|
46
49
|
"lint:agent-cli-provider": "eslint \"src/agent-cli-provider/**/*.ts\" \"tests/agent-cli-provider/**/*.ts\"",
|
|
47
50
|
"build:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.build.json",
|
|
48
|
-
"build:cluster": "npm run protocol:generate && tsc --project tsconfig.cluster.cjs.json && tsc --project tsconfig.cluster.esm.json && node scripts/build-cluster.js",
|
|
51
|
+
"build:cluster": "npm run protocol:generate && tsc --project tsconfig.cluster.cjs.json && tsc --project tsconfig.cluster.esm.json && tsc --project tsconfig.hosted-session.cjs.json && tsc --project tsconfig.hosted-session.esm.json && node scripts/build-cluster.js",
|
|
49
52
|
"test:agent-cli-provider": "node --test tests/agent-cli-provider/*.test.js",
|
|
50
53
|
"test:omp": "npm run build:agent-cli-provider && node --test tests/agent-cli-provider/omp-*.test.js && node tests/run-tests.js tests/omp-rpc-watcher.test.js tests/omp-isolation-capability.test.js tests/omp-docker-auth.test.js tests/omp-docker-fresh-only.test.js",
|
|
51
54
|
"test:providers:live": "npm run build:agent-cli-provider && node scripts/live-provider-smoke.js",
|
|
52
55
|
"check:agent-cli-provider": "npm run check:agent-cli-provider:ci",
|
|
53
56
|
"check:agent-cli-provider:ci": "npm run typecheck:agent-cli-provider && npm run lint:agent-cli-provider && npm run build:agent-cli-provider && npm run test:agent-cli-provider",
|
|
54
|
-
"test:cluster-client": "npm run build:cluster && node --test tests/cluster/client.test.js tests/cluster/parity.test.js tests/cluster/architecture.test.js tests/cluster/verifier-regressions.test.js tests/cluster/request-validation.test.js",
|
|
55
|
-
"test:cluster-package": "npm run build:cluster && node --test tests/cluster/package.test.js",
|
|
57
|
+
"test:cluster-client": "npm run build:cluster && node --test tests/cluster/client.test.js tests/cluster/parity.test.js tests/cluster/architecture.test.js tests/cluster/verifier-regressions.test.js tests/cluster/request-validation.test.js tests/hosted-session/coordinator.test.js",
|
|
58
|
+
"test:cluster-package": "npm run build:agent-cli-provider && npm run build:cluster && node --test tests/cluster/package.test.js",
|
|
56
59
|
"dev:link": "npm link",
|
|
57
60
|
"lint": "eslint .",
|
|
58
61
|
"lint:fix": "eslint . --fix",
|
|
@@ -130,6 +133,12 @@
|
|
|
130
133
|
"require": "./lib/cluster/index.cjs",
|
|
131
134
|
"default": "./lib/cluster/index.cjs"
|
|
132
135
|
},
|
|
136
|
+
"./hosted-session": {
|
|
137
|
+
"types": "./lib/hosted-session/index.d.ts",
|
|
138
|
+
"import": "./lib/hosted-session/index.mjs",
|
|
139
|
+
"require": "./lib/hosted-session/index.cjs",
|
|
140
|
+
"default": "./lib/hosted-session/index.cjs"
|
|
141
|
+
},
|
|
133
142
|
"./package.json": "./package.json",
|
|
134
143
|
"./*": "./*"
|
|
135
144
|
},
|
package/scripts/build-cluster.js
CHANGED
|
@@ -4,8 +4,10 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
6
|
const root = path.resolve(__dirname, '..');
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const clusterBuildRoot = path.join(root, '.cluster-build');
|
|
8
|
+
const hostedSessionBuildRoot = path.join(root, '.hosted-session-build');
|
|
9
|
+
const clusterOutputRoot = path.join(root, 'lib/cluster');
|
|
10
|
+
const hostedSessionOutputRoot = path.join(root, 'lib/hosted-session');
|
|
9
11
|
|
|
10
12
|
function filesBelow(directory) {
|
|
11
13
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
@@ -15,7 +17,7 @@ function filesBelow(directory) {
|
|
|
15
17
|
});
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
function copyBuild(sourceRoot, mode) {
|
|
20
|
+
function copyBuild(sourceRoot, outputRoot, mode) {
|
|
19
21
|
for (const source of filesBelow(sourceRoot)) {
|
|
20
22
|
const relative = path.relative(sourceRoot, source);
|
|
21
23
|
const extension = path.extname(relative);
|
|
@@ -37,7 +39,19 @@ function copyBuild(sourceRoot, mode) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
fs.rmSync(
|
|
41
|
-
|
|
42
|
-
copyBuild(path.join(
|
|
43
|
-
|
|
42
|
+
fs.rmSync(clusterOutputRoot, { recursive: true, force: true });
|
|
43
|
+
fs.rmSync(hostedSessionOutputRoot, { recursive: true, force: true });
|
|
44
|
+
copyBuild(path.join(clusterBuildRoot, 'cjs'), clusterOutputRoot, 'cjs');
|
|
45
|
+
copyBuild(path.join(clusterBuildRoot, 'esm'), clusterOutputRoot, 'esm');
|
|
46
|
+
copyBuild(
|
|
47
|
+
path.join(hostedSessionBuildRoot, 'cjs', 'hosted-session'),
|
|
48
|
+
hostedSessionOutputRoot,
|
|
49
|
+
'cjs'
|
|
50
|
+
);
|
|
51
|
+
copyBuild(
|
|
52
|
+
path.join(hostedSessionBuildRoot, 'esm', 'hosted-session'),
|
|
53
|
+
hostedSessionOutputRoot,
|
|
54
|
+
'esm'
|
|
55
|
+
);
|
|
56
|
+
fs.rmSync(clusterBuildRoot, { recursive: true, force: true });
|
|
57
|
+
fs.rmSync(hostedSessionBuildRoot, { recursive: true, force: true });
|
package/src/cluster/client.ts
CHANGED
|
@@ -16,16 +16,25 @@ import {
|
|
|
16
16
|
WatchSubscriptionStream,
|
|
17
17
|
} from './subscriptions.js';
|
|
18
18
|
|
|
19
|
+
export interface WebSocketFactoryOptions {
|
|
20
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
21
|
+
}
|
|
19
22
|
export interface ConnectOptions {
|
|
20
23
|
readonly protocols?: string | readonly string[];
|
|
21
|
-
readonly webSocketFactory?: (url: string, protocols?: string | readonly string[]) => WebSocketLike | Promise<WebSocketLike>;
|
|
24
|
+
readonly webSocketFactory?: (url: string, protocols?: string | readonly string[], options?: WebSocketFactoryOptions) => WebSocketLike | Promise<WebSocketLike>;
|
|
22
25
|
readonly signal?: AbortSignal;
|
|
23
26
|
readonly initialize?: InitializeParams;
|
|
27
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
24
28
|
}
|
|
25
29
|
export interface WatchSubscription { readonly result: WatchResult; readonly stream: WatchSubscriptionStream; }
|
|
26
30
|
export interface LogsSubscription { readonly result: LogsResult; readonly stream: LogsSubscriptionStream; }
|
|
27
31
|
export interface AgentAttachSubscription { readonly result: AgentAttachResult; readonly stream: AgentAttachSubscriptionStream; }
|
|
28
32
|
export interface CoherentWatchSubscription extends WatchSubscription { readonly snapshot: GetResult; }
|
|
33
|
+
export interface ConnectInitializedResult {
|
|
34
|
+
readonly connection: Connection;
|
|
35
|
+
readonly client: ClusterClient;
|
|
36
|
+
readonly initializeResult: InitializeResult;
|
|
37
|
+
}
|
|
29
38
|
|
|
30
39
|
export class ClusterClient {
|
|
31
40
|
constructor(readonly connection: Connection) {}
|
|
@@ -114,6 +123,7 @@ export class ClusterClient {
|
|
|
114
123
|
async function defaultWebSocketFactory(
|
|
115
124
|
url: string,
|
|
116
125
|
protocols?: string | readonly string[],
|
|
126
|
+
options?: WebSocketFactoryOptions,
|
|
117
127
|
): Promise<WebSocketLike> {
|
|
118
128
|
const globalWebSocket = (globalThis as {
|
|
119
129
|
readonly WebSocket?: new (
|
|
@@ -121,9 +131,16 @@ async function defaultWebSocketFactory(
|
|
|
121
131
|
protocols?: string | readonly string[],
|
|
122
132
|
) => WebSocketLike;
|
|
123
133
|
}).WebSocket;
|
|
124
|
-
if (globalWebSocket)
|
|
134
|
+
if (globalWebSocket) {
|
|
135
|
+
if (options?.headers && Object.keys(options.headers).length > 0) {
|
|
136
|
+
throw new ClusterConfigError(
|
|
137
|
+
'WebSocket upgrade headers require the ws library; the browser WebSocket API cannot carry request headers',
|
|
138
|
+
'HEADERS_UNSUPPORTED',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return new globalWebSocket(url, protocols);
|
|
142
|
+
}
|
|
125
143
|
try {
|
|
126
|
-
// Dynamic loading keeps the optional Node runtime off the browser/global-WebSocket path.
|
|
127
144
|
const imported: unknown = await import('ws');
|
|
128
145
|
const candidate = imported !== null && typeof imported === 'object' && 'default' in imported
|
|
129
146
|
? imported.default
|
|
@@ -134,8 +151,9 @@ async function defaultWebSocketFactory(
|
|
|
134
151
|
const Constructor = candidate as new (
|
|
135
152
|
url: string,
|
|
136
153
|
protocols?: string | readonly string[],
|
|
154
|
+
options?: { readonly headers?: Readonly<Record<string, string>> },
|
|
137
155
|
) => WebSocketLike;
|
|
138
|
-
return new Constructor(url, protocols);
|
|
156
|
+
return options?.headers ? new Constructor(url, protocols, { headers: options.headers }) : new Constructor(url, protocols);
|
|
139
157
|
} catch (cause) {
|
|
140
158
|
throw new ClusterConfigError(
|
|
141
159
|
"No WebSocket runtime is available; install 'ws' or pass webSocketFactory",
|
|
@@ -181,7 +199,7 @@ export async function connect(url: string, options: ConnectOptions = {}): Promis
|
|
|
181
199
|
const factory = options.webSocketFactory ?? defaultWebSocketFactory;
|
|
182
200
|
let socket: WebSocketLike | undefined;
|
|
183
201
|
try {
|
|
184
|
-
socket = await factory(url, options.protocols);
|
|
202
|
+
socket = await factory(url, options.protocols, options.headers ? { headers: options.headers } : undefined);
|
|
185
203
|
await waitForOpen(socket, options.signal);
|
|
186
204
|
const connection = new Connection(socket);
|
|
187
205
|
await new ClusterClient(connection).initialize(
|
|
@@ -197,3 +215,25 @@ export async function connect(url: string, options: ConnectOptions = {}): Promis
|
|
|
197
215
|
throw error;
|
|
198
216
|
}
|
|
199
217
|
}
|
|
218
|
+
|
|
219
|
+
export async function connectInitialized(url: string, options: ConnectOptions = {}): Promise<ConnectInitializedResult> {
|
|
220
|
+
const factory = options.webSocketFactory ?? defaultWebSocketFactory;
|
|
221
|
+
let socket: WebSocketLike | undefined;
|
|
222
|
+
try {
|
|
223
|
+
socket = await factory(url, options.protocols, options.headers ? { headers: options.headers } : undefined);
|
|
224
|
+
await waitForOpen(socket, options.signal);
|
|
225
|
+
const connection = new Connection(socket);
|
|
226
|
+
const client = new ClusterClient(connection);
|
|
227
|
+
const initializeResult = await client.initialize(
|
|
228
|
+
options.initialize,
|
|
229
|
+
options.signal === undefined ? {} : { signal: options.signal },
|
|
230
|
+
);
|
|
231
|
+
return { connection, client, initializeResult };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (socket) {
|
|
234
|
+
try { await socket.close(); }
|
|
235
|
+
catch { /* preserve the construction error */ }
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -27,6 +27,20 @@ export const CONNECTION_TRANSITIONS: Readonly<Record<ConnectionState, readonly C
|
|
|
27
27
|
CLOSED: Object.freeze([] as const),
|
|
28
28
|
});
|
|
29
29
|
export const PROTOCOL_DIAGNOSTIC_CAPACITY = 128;
|
|
30
|
+
export const CLOSE_REASON_MAX_BYTES = 123;
|
|
31
|
+
const CLOSE_REASON_ENCODER = new TextEncoder();
|
|
32
|
+
function boundedCloseReason(reason: string): string {
|
|
33
|
+
const retained: string[] = [];
|
|
34
|
+
const scratch = new Uint8Array(4);
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
for (const codePoint of reason) {
|
|
37
|
+
const { written } = CLOSE_REASON_ENCODER.encodeInto(codePoint, scratch);
|
|
38
|
+
if (bytes + written > CLOSE_REASON_MAX_BYTES) break;
|
|
39
|
+
retained.push(codePoint);
|
|
40
|
+
bytes += written;
|
|
41
|
+
}
|
|
42
|
+
return retained.join('');
|
|
43
|
+
}
|
|
30
44
|
export interface CallOptions { readonly signal?: AbortSignal; readonly requestTimeoutMs?: number; }
|
|
31
45
|
|
|
32
46
|
type Deferred<T> = {
|
|
@@ -70,6 +84,8 @@ export class Connection {
|
|
|
70
84
|
readonly #removeSocketListeners: Array<() => void> = [];
|
|
71
85
|
readonly #ownedSubscriptions = new WeakSet<SubscriptionRegistration>();
|
|
72
86
|
#closePromise?: Promise<void>;
|
|
87
|
+
#closeCode: number | undefined;
|
|
88
|
+
#closeReason: string | undefined;
|
|
73
89
|
readonly closeDiagnostics: unknown[] = [];
|
|
74
90
|
readonly protocolDiagnostics: ClusterProtocolError[] = [];
|
|
75
91
|
|
|
@@ -80,12 +96,14 @@ export class Connection {
|
|
|
80
96
|
this.#removeSocketListeners.push(
|
|
81
97
|
addSocketListener(socket, 'message', (event) => this.#onMessage(event)),
|
|
82
98
|
addSocketListener(socket, 'error', () => { void this.#startClose(false); }),
|
|
83
|
-
addSocketListener(socket, 'close', () => { void this.#startClose(false); }),
|
|
99
|
+
addSocketListener(socket, 'close', (...args: unknown[]) => { this.#captureCloseState(args); void this.#startClose(false); }),
|
|
84
100
|
);
|
|
85
101
|
}
|
|
86
102
|
get state(): ConnectionState { return this.#state; }
|
|
87
103
|
get pendingSize(): number { return this.#pending.size; }
|
|
88
104
|
get subscriptionCount(): number { return this.#subscriptions.size; }
|
|
105
|
+
get closeCode(): number | undefined { return this.#closeCode; }
|
|
106
|
+
get closeReason(): string | undefined { return this.#closeReason; }
|
|
89
107
|
call<M extends UnaryClusterMethod>(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise<ClusterMethodResults[M]> {
|
|
90
108
|
if (!(UNARY_METHODS as readonly string[]).includes(method)) {
|
|
91
109
|
throw new ClusterConfigError(`${method} is a subscription method`, 'INVALID_METHOD');
|
|
@@ -266,6 +284,19 @@ export class Connection {
|
|
|
266
284
|
if (this.protocolDiagnostics.length === PROTOCOL_DIAGNOSTIC_CAPACITY) this.protocolDiagnostics.shift();
|
|
267
285
|
this.protocolDiagnostics.push(new ClusterProtocolError(message, 'INVALID_PEER_FRAME', cause === undefined ? undefined : { cause }));
|
|
268
286
|
}
|
|
287
|
+
#captureCloseState(args: unknown[]): void {
|
|
288
|
+
if (args.length === 0) return;
|
|
289
|
+
const first = args[0];
|
|
290
|
+
if (typeof first === 'number') {
|
|
291
|
+
this.#closeCode = first;
|
|
292
|
+
const raw = args.length > 1 ? String(args[1]) : undefined;
|
|
293
|
+
this.#closeReason = raw === undefined ? undefined : boundedCloseReason(raw);
|
|
294
|
+
} else if (first !== null && typeof first === 'object') {
|
|
295
|
+
const event = first as { code?: unknown; reason?: unknown };
|
|
296
|
+
if (typeof event.code === 'number') this.#closeCode = event.code;
|
|
297
|
+
if (typeof event.reason === 'string') this.#closeReason = boundedCloseReason(event.reason);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
269
300
|
#startClose(sendCancels: boolean): Promise<void> {
|
|
270
301
|
if (this.#closePromise) return this.#closePromise; if (this.#state === 'CLOSED') return Promise.resolve();
|
|
271
302
|
this.#transition('CLOSING'); this.#closePromise = Promise.resolve().then(() => this.#finishClose(sendCancels)); return this.#closePromise;
|
package/src/cluster/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export {
|
|
|
14
14
|
export { assertGraphProfile, assertGraphProfileSupported, assertGraphSpec } from './validators.js';
|
|
15
15
|
export * from './payload-value.js';
|
|
16
16
|
export * from './json-source.js';
|
|
17
|
-
export { CONNECTION_TRANSITIONS, PROTOCOL_DIAGNOSTIC_CAPACITY, Connection } from './connection.js';
|
|
17
|
+
export { CLOSE_REASON_MAX_BYTES, CONNECTION_TRANSITIONS, PROTOCOL_DIAGNOSTIC_CAPACITY, Connection } from './connection.js';
|
|
18
18
|
export type {
|
|
19
19
|
CallOptions,
|
|
20
20
|
ConnectionState,
|
|
@@ -32,11 +32,13 @@ export type {
|
|
|
32
32
|
WatchSubscriptionItem,
|
|
33
33
|
WatchSubscriptionClosedItem,
|
|
34
34
|
} from './subscriptions.js';
|
|
35
|
-
export { ClusterClient, connect } from './client.js';
|
|
35
|
+
export { ClusterClient, connect, connectInitialized } from './client.js';
|
|
36
36
|
export type {
|
|
37
37
|
AgentAttachSubscription,
|
|
38
38
|
CoherentWatchSubscription,
|
|
39
|
+
ConnectInitializedResult,
|
|
39
40
|
ConnectOptions,
|
|
40
41
|
LogsSubscription,
|
|
41
42
|
WatchSubscription,
|
|
43
|
+
WebSocketFactoryOptions,
|
|
42
44
|
} from './client.js';
|
package/src/cluster/ws.d.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ClusterConfigError, connectInitialized } from '../cluster/index.js';
|
|
2
|
+
import type { ServerCapabilities, GraphProfile } from '../cluster/index.js';
|
|
3
|
+
import type { ConnectOptions } from '../cluster/index.js';
|
|
4
|
+
import type { AccessResponse, HostedSessionInit, InitializedSession } from './types.js';
|
|
5
|
+
|
|
6
|
+
function combineSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
|
7
|
+
const defined = signals.filter((s): s is AbortSignal => s !== undefined);
|
|
8
|
+
if (defined.length === 0) return undefined;
|
|
9
|
+
if (defined.length === 1) return defined[0];
|
|
10
|
+
return AbortSignal.any(defined);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class HostedSessionCoordinator {
|
|
14
|
+
readonly #getAccess: (signal?: AbortSignal) => Promise<AccessResponse>;
|
|
15
|
+
readonly #connectOptions: Omit<ConnectOptions, 'headers' | 'signal'> | undefined;
|
|
16
|
+
readonly #clock: { now(): number };
|
|
17
|
+
readonly #closeController = new AbortController();
|
|
18
|
+
#referenceCapabilities: ServerCapabilities | undefined;
|
|
19
|
+
#closed = false;
|
|
20
|
+
|
|
21
|
+
constructor(init: HostedSessionInit) {
|
|
22
|
+
this.#getAccess = init.getAccess;
|
|
23
|
+
this.#connectOptions = init.connectOptions;
|
|
24
|
+
this.#clock = init.clock ?? Date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async open(signal?: AbortSignal): Promise<InitializedSession> {
|
|
28
|
+
this.#requireNotClosed();
|
|
29
|
+
const session = await this.#createSession(signal);
|
|
30
|
+
this.#referenceCapabilities = session.initializeResult.capabilities;
|
|
31
|
+
return session;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async replace(signal?: AbortSignal): Promise<InitializedSession> {
|
|
35
|
+
this.#requireNotClosed();
|
|
36
|
+
const session = await this.#createSession(signal);
|
|
37
|
+
this.#verifyCapabilities(session.initializeResult.capabilities, session);
|
|
38
|
+
return session;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
renewalDeadline(access: AccessResponse, receivedAt: number): number {
|
|
42
|
+
const expiresAt = Date.parse(access.expiresAt);
|
|
43
|
+
if (Number.isNaN(expiresAt)) {
|
|
44
|
+
throw new ClusterConfigError(`invalid expiresAt: ${access.expiresAt}`, 'INVALID_EXPIRY');
|
|
45
|
+
}
|
|
46
|
+
const lifetime = expiresAt - receivedAt;
|
|
47
|
+
return Math.min(expiresAt - 30_000, receivedAt + 0.8 * lifetime);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async close(): Promise<void> {
|
|
51
|
+
this.#closed = true;
|
|
52
|
+
this.#closeController.abort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async #createSession(signal?: AbortSignal): Promise<InitializedSession> {
|
|
56
|
+
const combined = combineSignals([signal, this.#closeController.signal]);
|
|
57
|
+
const access = await this.#getAccess(combined);
|
|
58
|
+
const expiresAt = Date.parse(access.expiresAt);
|
|
59
|
+
if (Number.isNaN(expiresAt)) {
|
|
60
|
+
throw new ClusterConfigError(`invalid expiresAt: ${access.expiresAt}`, 'INVALID_EXPIRY');
|
|
61
|
+
}
|
|
62
|
+
if (expiresAt <= this.#clock.now()) {
|
|
63
|
+
throw new ClusterConfigError('access token is already expired', 'ACCESS_EXPIRED');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let endpoint: URL;
|
|
67
|
+
try {
|
|
68
|
+
endpoint = new URL(access.endpoint);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new ClusterConfigError('hosted access endpoint is invalid', 'INVALID_ENDPOINT');
|
|
71
|
+
}
|
|
72
|
+
if (endpoint.protocol !== 'wss:') {
|
|
73
|
+
throw new ClusterConfigError('hosted access endpoint must use wss', 'INSECURE_ENDPOINT');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return connectInitialized(endpoint.href, {
|
|
77
|
+
...this.#connectOptions,
|
|
78
|
+
headers: { Authorization: `Bearer ${access.token}` },
|
|
79
|
+
...(combined !== undefined ? { signal: combined } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#verifyCapabilities(incoming: ServerCapabilities, session: InitializedSession): void {
|
|
84
|
+
if (!this.#referenceCapabilities) return;
|
|
85
|
+
const ref = this.#referenceCapabilities;
|
|
86
|
+
const mismatches: string[] = [];
|
|
87
|
+
|
|
88
|
+
if (ref.graphProfiles) {
|
|
89
|
+
const incomingProfiles = new Set<GraphProfile>(incoming.graphProfiles ?? []);
|
|
90
|
+
for (const profile of ref.graphProfiles) {
|
|
91
|
+
if (!incomingProfiles.has(profile)) mismatches.push(`missing graphProfile: ${profile}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ref.logs && !incoming.logs) mismatches.push('missing capability: logs');
|
|
95
|
+
if (ref.agentAttach && !incoming.agentAttach)
|
|
96
|
+
mismatches.push('missing capability: agentAttach');
|
|
97
|
+
|
|
98
|
+
if (mismatches.length > 0) {
|
|
99
|
+
void session.connection.close();
|
|
100
|
+
throw new ClusterConfigError(
|
|
101
|
+
`replacement capabilities incompatible: ${mismatches.join(', ')}`,
|
|
102
|
+
'INCOMPATIBLE_CAPABILITIES'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#requireNotClosed(): void {
|
|
108
|
+
if (this.#closed) throw new ClusterConfigError('coordinator is closed', 'COORDINATOR_CLOSED');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Connection } from '../cluster/index.js';
|
|
2
|
+
import type { ClusterClient, ConnectOptions } from '../cluster/index.js';
|
|
3
|
+
import type { InitializeResult } from '../cluster/index.js';
|
|
4
|
+
|
|
5
|
+
export interface AccessResponse {
|
|
6
|
+
readonly endpoint: string;
|
|
7
|
+
readonly token: string;
|
|
8
|
+
readonly expiresAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HostedSessionInit {
|
|
12
|
+
readonly getAccess: (signal?: AbortSignal) => Promise<AccessResponse>;
|
|
13
|
+
readonly connectOptions?: Omit<ConnectOptions, 'headers' | 'signal'>;
|
|
14
|
+
readonly clock?: { now(): number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InitializedSession {
|
|
18
|
+
readonly connection: Connection;
|
|
19
|
+
readonly client: ClusterClient;
|
|
20
|
+
readonly initializeResult: InitializeResult;
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
export const MAX_PAGINATION_PAGES = 100;
|
|
3
|
+
export const MAX_RETRY_ATTEMPTS = 3;
|
|
4
|
+
export const MAX_RETRY_ELAPSED_MS = 30_000;
|
|
5
|
+
export const MAX_ERROR_BODY_BYTES = 8192;
|
|
6
|
+
export const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function sanitize(text: string): string {
|
|
2
|
+
return text
|
|
3
|
+
.replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: Bearer [REDACTED]')
|
|
4
|
+
.replace(/token["']?\s*[:=]\s*["'][^"']+["']/gi, 'token: "[REDACTED]"')
|
|
5
|
+
.replace(/https?:\/\/[^\s]*(?:token|key|secret|credential|auth)[^\s]*/gi, '[REDACTED_URL]');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function sanitizeCause(cause: unknown): unknown {
|
|
9
|
+
if (!cause) return cause;
|
|
10
|
+
if (cause instanceof Error) {
|
|
11
|
+
const cleaned = new Error(sanitize(cause.message));
|
|
12
|
+
cleaned.name = cause.name;
|
|
13
|
+
if (cause.cause) cleaned.cause = sanitizeCause(cause.cause);
|
|
14
|
+
return cleaned;
|
|
15
|
+
}
|
|
16
|
+
if (typeof cause === 'string') return sanitize(cause);
|
|
17
|
+
return cause;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TargetAdapterError extends Error {
|
|
21
|
+
readonly code: string;
|
|
22
|
+
readonly retryable: boolean;
|
|
23
|
+
|
|
24
|
+
constructor(code: string, message: string, retryable: boolean, cause?: unknown) {
|
|
25
|
+
super(sanitize(message), { cause: sanitizeCause(cause) });
|
|
26
|
+
this.name = 'TargetAdapterError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.retryable = retryable;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TargetAuthError extends TargetAdapterError {
|
|
33
|
+
constructor(message: string, cause?: unknown) {
|
|
34
|
+
super('AUTH_FAILED', message, false, cause);
|
|
35
|
+
this.name = 'TargetAuthError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TargetConflictError extends TargetAdapterError {
|
|
40
|
+
readonly idempotencyKey: string;
|
|
41
|
+
|
|
42
|
+
constructor(idempotencyKey: string, message: string, cause?: unknown) {
|
|
43
|
+
super('CONFLICT', message, true, cause);
|
|
44
|
+
this.name = 'TargetConflictError';
|
|
45
|
+
this.idempotencyKey = idempotencyKey;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class TargetRateLimitError extends TargetAdapterError {
|
|
50
|
+
readonly retryAfterMs: number | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(message: string, retryAfterMs?: number, cause?: unknown) {
|
|
53
|
+
super('RATE_LIMITED', message, true, cause);
|
|
54
|
+
this.name = 'TargetRateLimitError';
|
|
55
|
+
this.retryAfterMs = retryAfterMs;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class TargetTransportError extends TargetAdapterError {
|
|
60
|
+
constructor(message: string, cause?: unknown) {
|
|
61
|
+
super('TRANSPORT', message, true, cause);
|
|
62
|
+
this.name = 'TargetTransportError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class TargetProtocolError extends TargetAdapterError {
|
|
67
|
+
constructor(message: string, cause?: unknown) {
|
|
68
|
+
super('PROTOCOL', message, false, cause);
|
|
69
|
+
this.name = 'TargetProtocolError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TargetCapacityError extends TargetAdapterError {
|
|
74
|
+
constructor(message: string, cause?: unknown) {
|
|
75
|
+
super('CAPACITY', message, false, cause);
|
|
76
|
+
this.name = 'TargetCapacityError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class TargetNotFoundError extends TargetAdapterError {
|
|
81
|
+
constructor(message: string, cause?: unknown) {
|
|
82
|
+
super('NOT_FOUND', message, false, cause);
|
|
83
|
+
this.name = 'TargetNotFoundError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isRetryable(error: unknown): boolean {
|
|
88
|
+
if (error instanceof TargetAdapterError) return error.retryable;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type { TargetAdapter } from './target-adapter.ts';
|
|
2
|
+
export { ZeroCloudV1TargetAdapter } from './zero-cloud-v1-adapter.ts';
|
|
3
|
+
export {
|
|
4
|
+
TargetAdapterError,
|
|
5
|
+
TargetAuthError,
|
|
6
|
+
TargetConflictError,
|
|
7
|
+
TargetRateLimitError,
|
|
8
|
+
TargetTransportError,
|
|
9
|
+
TargetProtocolError,
|
|
10
|
+
TargetCapacityError,
|
|
11
|
+
TargetNotFoundError,
|
|
12
|
+
isRetryable,
|
|
13
|
+
} from './errors.ts';
|
|
14
|
+
export type {
|
|
15
|
+
TargetAccessTokenProvider,
|
|
16
|
+
CapsuleState,
|
|
17
|
+
Capsule,
|
|
18
|
+
CapsuleAccess,
|
|
19
|
+
CapsuleListPage,
|
|
20
|
+
CapsuleLimits,
|
|
21
|
+
AllocateRequest,
|
|
22
|
+
HttpTransport,
|
|
23
|
+
Clock,
|
|
24
|
+
RetryPolicy,
|
|
25
|
+
TargetDiscovery,
|
|
26
|
+
} from './types.ts';
|
|
27
|
+
export { KNOWN_CAPSULE_STATES } from './types.ts';
|
|
28
|
+
export {
|
|
29
|
+
MAX_RESPONSE_BYTES,
|
|
30
|
+
MAX_PAGINATION_PAGES,
|
|
31
|
+
MAX_RETRY_ATTEMPTS,
|
|
32
|
+
MAX_RETRY_ELAPSED_MS,
|
|
33
|
+
MAX_ERROR_BODY_BYTES,
|
|
34
|
+
IDEMPOTENCY_KEY_PATTERN,
|
|
35
|
+
} from './bounds.ts';
|
|
36
|
+
export { DefaultRetryPolicy, parseRetryAfter } from './retry.ts';
|
|
37
|
+
export {
|
|
38
|
+
assertRequiredFields,
|
|
39
|
+
assertKnownEnum,
|
|
40
|
+
assertCapsule,
|
|
41
|
+
assertCapsuleAccess,
|
|
42
|
+
assertCapsuleLimits,
|
|
43
|
+
assertCapsuleListPage,
|
|
44
|
+
} from './response-validation.ts';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TargetProtocolError } from './errors.ts';
|
|
2
|
+
import { KNOWN_CAPSULE_STATES } from './types.ts';
|
|
3
|
+
import type { Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export function assertRequiredFields(
|
|
6
|
+
body: unknown,
|
|
7
|
+
fields: readonly string[],
|
|
8
|
+
context: string,
|
|
9
|
+
): asserts body is Record<string, unknown> {
|
|
10
|
+
if (body === null || typeof body !== 'object') {
|
|
11
|
+
throw new TargetProtocolError(`${context}: expected object, got ${typeof body}`);
|
|
12
|
+
}
|
|
13
|
+
const record = body as Record<string, unknown>;
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
if (record[field] === undefined || record[field] === null) {
|
|
16
|
+
throw new TargetProtocolError(`${context}: missing required field "${field}"`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertKnownEnum(value: string, known: readonly string[], field: string): void {
|
|
22
|
+
if (!known.includes(value)) {
|
|
23
|
+
// eslint-disable-next-line no-console
|
|
24
|
+
console.warn(`Unknown ${field} value: "${value}". Known values: ${known.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertCapsule(body: unknown): Capsule {
|
|
29
|
+
assertRequiredFields(body, ['id', 'state', 'createdAt'], 'Capsule');
|
|
30
|
+
const record = body as Record<string, unknown>;
|
|
31
|
+
if (typeof record['id'] !== 'string') {
|
|
32
|
+
throw new TargetProtocolError('Capsule: "id" must be a string');
|
|
33
|
+
}
|
|
34
|
+
if (typeof record['state'] !== 'string') {
|
|
35
|
+
throw new TargetProtocolError('Capsule: "state" must be a string');
|
|
36
|
+
}
|
|
37
|
+
if (typeof record['createdAt'] !== 'string') {
|
|
38
|
+
throw new TargetProtocolError('Capsule: "createdAt" must be a string');
|
|
39
|
+
}
|
|
40
|
+
assertKnownEnum(record['state'] as string, KNOWN_CAPSULE_STATES, 'CapsuleState');
|
|
41
|
+
return record as unknown as Capsule;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertCapsuleAccess(body: unknown): CapsuleAccess {
|
|
45
|
+
assertRequiredFields(body, ['endpoint', 'token', 'expiresAt'], 'CapsuleAccess');
|
|
46
|
+
const record = body as Record<string, unknown>;
|
|
47
|
+
if (typeof record['endpoint'] !== 'string') {
|
|
48
|
+
throw new TargetProtocolError('CapsuleAccess: "endpoint" must be a string');
|
|
49
|
+
}
|
|
50
|
+
if (typeof record['token'] !== 'string') {
|
|
51
|
+
throw new TargetProtocolError('CapsuleAccess: "token" must be a string');
|
|
52
|
+
}
|
|
53
|
+
if (typeof record['expiresAt'] !== 'string') {
|
|
54
|
+
throw new TargetProtocolError('CapsuleAccess: "expiresAt" must be a string');
|
|
55
|
+
}
|
|
56
|
+
return record as unknown as CapsuleAccess;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertCapsuleLimits(body: unknown): CapsuleLimits {
|
|
60
|
+
assertRequiredFields(body, ['maxConcurrent', 'maxPerHour'], 'CapsuleLimits');
|
|
61
|
+
const record = body as Record<string, unknown>;
|
|
62
|
+
if (typeof record['maxConcurrent'] !== 'number') {
|
|
63
|
+
throw new TargetProtocolError('CapsuleLimits: "maxConcurrent" must be a number');
|
|
64
|
+
}
|
|
65
|
+
if (typeof record['maxPerHour'] !== 'number') {
|
|
66
|
+
throw new TargetProtocolError('CapsuleLimits: "maxPerHour" must be a number');
|
|
67
|
+
}
|
|
68
|
+
return record as unknown as CapsuleLimits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertCapsuleListPage(body: unknown): CapsuleListPage {
|
|
72
|
+
assertRequiredFields(body, ['items'], 'CapsuleListPage');
|
|
73
|
+
const record = body as Record<string, unknown>;
|
|
74
|
+
if (!Array.isArray(record['items'])) {
|
|
75
|
+
throw new TargetProtocolError('CapsuleListPage: "items" must be an array');
|
|
76
|
+
}
|
|
77
|
+
const items = (record['items'] as unknown[]).map((item) => assertCapsule(item));
|
|
78
|
+
const cursor = record['cursor'];
|
|
79
|
+
if (cursor !== undefined && cursor !== null && typeof cursor !== 'string') {
|
|
80
|
+
throw new TargetProtocolError('CapsuleListPage: "cursor" must be a string if present');
|
|
81
|
+
}
|
|
82
|
+
if (typeof cursor === 'string') {
|
|
83
|
+
return { items, cursor };
|
|
84
|
+
}
|
|
85
|
+
return { items };
|
|
86
|
+
}
|