@robosystems/client 1.12.2 → 1.13.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/artifacts/OperationClient.d.ts +2 -0
- package/artifacts/OperationClient.ts +3 -0
- package/artifacts/OperatorClient.d.ts +40 -6
- package/artifacts/OperatorClient.js +171 -105
- package/artifacts/OperatorClient.ts +231 -134
- package/artifacts/QueryClient.d.ts +2 -0
- package/artifacts/QueryClient.ts +3 -0
- package/artifacts/SSEClient.d.ts +26 -0
- package/artifacts/SSEClient.js +28 -2
- package/artifacts/SSEClient.ts +51 -3
- package/artifacts/hooks.js +5 -0
- package/artifacts/hooks.ts +5 -0
- package/artifacts/index.d.ts +6 -3
- package/artifacts/index.js +9 -2
- package/artifacts/index.ts +15 -5
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TokenProvider } from './graphql/client';
|
|
1
2
|
export interface OperationProgress {
|
|
2
3
|
message: string;
|
|
3
4
|
progressPercent?: number;
|
|
@@ -25,6 +26,7 @@ export declare class OperationClient {
|
|
|
25
26
|
credentials?: 'include' | 'same-origin' | 'omit';
|
|
26
27
|
headers?: Record<string, string>;
|
|
27
28
|
token?: string;
|
|
29
|
+
tokenProvider?: TokenProvider;
|
|
28
30
|
maxRetries?: number;
|
|
29
31
|
retryDelay?: number;
|
|
30
32
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { cancelOperation as cancelOperationSDK, getOperationStatus } from '../sdk.gen'
|
|
9
|
+
import type { TokenProvider } from './graphql/client'
|
|
9
10
|
import { EventType, SSEClient } from './SSEClient'
|
|
10
11
|
|
|
11
12
|
export interface OperationProgress {
|
|
@@ -35,6 +36,7 @@ export class OperationClient {
|
|
|
35
36
|
credentials?: 'include' | 'same-origin' | 'omit'
|
|
36
37
|
headers?: Record<string, string>
|
|
37
38
|
token?: string // JWT token for authentication
|
|
39
|
+
tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
|
|
38
40
|
maxRetries?: number
|
|
39
41
|
retryDelay?: number
|
|
40
42
|
}
|
|
@@ -46,6 +48,7 @@ export class OperationClient {
|
|
|
46
48
|
credentials?: 'include' | 'same-origin' | 'omit'
|
|
47
49
|
headers?: Record<string, string>
|
|
48
50
|
token?: string // JWT token for authentication
|
|
51
|
+
tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
|
|
49
52
|
maxRetries?: number
|
|
50
53
|
retryDelay?: number
|
|
51
54
|
}) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TokenProvider } from './graphql/client';
|
|
1
2
|
export interface OperatorQueryRequest {
|
|
2
3
|
message: string;
|
|
3
4
|
history?: Array<{
|
|
@@ -13,6 +14,12 @@ export interface OperatorOptions {
|
|
|
13
14
|
mode?: 'auto' | 'sync' | 'async';
|
|
14
15
|
maxWait?: number;
|
|
15
16
|
onProgress?: (message: string, percentage?: number) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Interval between `/v1/operations/{id}/status` polls while the client
|
|
19
|
+
* follows a queued run it could not open an SSE stream for. Defaults to
|
|
20
|
+
* 2000 ms; only the fallback path uses it.
|
|
21
|
+
*/
|
|
22
|
+
pollIntervalMs?: number;
|
|
16
23
|
}
|
|
17
24
|
export interface OperatorResult {
|
|
18
25
|
content: string;
|
|
@@ -27,6 +34,12 @@ export interface OperatorResult {
|
|
|
27
34
|
confidence_score?: number;
|
|
28
35
|
execution_time?: number;
|
|
29
36
|
timestamp?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Present when the API reports a failed run inside an otherwise successful
|
|
39
|
+
* response (credit pre-flight, operator timeouts, cancelled runs). `content`
|
|
40
|
+
* then carries the explanation rather than an answer.
|
|
41
|
+
*/
|
|
42
|
+
error_details?: Record<string, any>;
|
|
30
43
|
}
|
|
31
44
|
export interface QueuedOperatorResponse {
|
|
32
45
|
status: 'queued';
|
|
@@ -34,15 +47,25 @@ export interface QueuedOperatorResponse {
|
|
|
34
47
|
message: string;
|
|
35
48
|
sse_endpoint?: string;
|
|
36
49
|
}
|
|
50
|
+
export interface OperatorClientConfig {
|
|
51
|
+
baseUrl: string;
|
|
52
|
+
credentials?: 'include' | 'same-origin' | 'omit';
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Static JWT captured at construction. Prefer `tokenProvider` in the
|
|
56
|
+
* browser — see `SSEConfig` for why a captured token goes stale.
|
|
57
|
+
*/
|
|
58
|
+
token?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Consulted on every SSE connect so a rotated JWT keeps the progress
|
|
61
|
+
* stream authenticated. Wins over `token` when both are set.
|
|
62
|
+
*/
|
|
63
|
+
tokenProvider?: TokenProvider;
|
|
64
|
+
}
|
|
37
65
|
export declare class OperatorClient {
|
|
38
66
|
private sseClient?;
|
|
39
67
|
private config;
|
|
40
|
-
constructor(config:
|
|
41
|
-
baseUrl: string;
|
|
42
|
-
credentials?: 'include' | 'same-origin' | 'omit';
|
|
43
|
-
headers?: Record<string, string>;
|
|
44
|
-
token?: string;
|
|
45
|
-
});
|
|
68
|
+
constructor(config: OperatorClientConfig);
|
|
46
69
|
/**
|
|
47
70
|
* Execute operator query with automatic operator selection
|
|
48
71
|
*/
|
|
@@ -51,7 +74,18 @@ export declare class OperatorClient {
|
|
|
51
74
|
* Execute specific operator type
|
|
52
75
|
*/
|
|
53
76
|
executeOperator(graphId: string, operatorType: string, request: OperatorQueryRequest, options?: OperatorOptions): Promise<OperatorResult>;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve an operator endpoint response: a sync 200 body is the result, a
|
|
79
|
+
* 202 with an `operation_id` is followed to completion.
|
|
80
|
+
*/
|
|
81
|
+
private settle;
|
|
54
82
|
private waitForOperatorCompletion;
|
|
83
|
+
/**
|
|
84
|
+
* Follow a queued run over `/v1/operations/{id}/status` until it settles.
|
|
85
|
+
* Used when the SSE stream could not be opened; `streamError` is folded
|
|
86
|
+
* into the failure message if polling cannot reach a verdict either.
|
|
87
|
+
*/
|
|
88
|
+
private pollForCompletion;
|
|
55
89
|
/**
|
|
56
90
|
* Convenience method for simple agent queries with auto-selection
|
|
57
91
|
*/
|
|
@@ -8,6 +8,50 @@ exports.QueuedOperatorError = exports.OperatorClient = void 0;
|
|
|
8
8
|
*/
|
|
9
9
|
const sdk_gen_1 = require("../sdk.gen");
|
|
10
10
|
const SSEClient_1 = require("./SSEClient");
|
|
11
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
12
|
+
// Consecutive `/status` failures tolerated before the polling fallback gives
|
|
13
|
+
// up: one transient network error must not lose a run that is still going.
|
|
14
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
const describeError = (err) => (err instanceof Error ? err.message : String(err));
|
|
17
|
+
/**
|
|
18
|
+
* Text for the `error` slot of a generated-client envelope: a thrown `Error`
|
|
19
|
+
* (network failure — the client returns those rather than throwing), an API
|
|
20
|
+
* body with `detail`, or whatever else came back.
|
|
21
|
+
*/
|
|
22
|
+
const describeEnvelopeError = (err) => {
|
|
23
|
+
if (err instanceof Error)
|
|
24
|
+
return err.message;
|
|
25
|
+
if (err && typeof err === 'object' && 'detail' in err)
|
|
26
|
+
return String(err.detail);
|
|
27
|
+
if (err === undefined || err === null)
|
|
28
|
+
return 'empty response';
|
|
29
|
+
return typeof err === 'string' ? err : JSON.stringify(err);
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Shape an operator payload — the sync 200 body, the `operation_completed`
|
|
33
|
+
* event, or the `/status` `result` — into the public result type. All three
|
|
34
|
+
* carry the same fields, so one mapper keeps them consistent.
|
|
35
|
+
*/
|
|
36
|
+
function toOperatorResult(data) {
|
|
37
|
+
const result = {
|
|
38
|
+
content: data.content ?? '',
|
|
39
|
+
operator_used: data.operator_used || 'unknown',
|
|
40
|
+
mode_used: data.mode_used || 'standard',
|
|
41
|
+
metadata: data.metadata,
|
|
42
|
+
tokens_used: data.tokens_used,
|
|
43
|
+
confidence_score: data.confidence_score,
|
|
44
|
+
execution_time: data.execution_time,
|
|
45
|
+
timestamp: data.timestamp || new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
if (data.error_details) {
|
|
48
|
+
result.error_details = data.error_details;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
/** Internal: a `/status` verdict that must not be retried. */
|
|
53
|
+
class PollAbort extends Error {
|
|
54
|
+
}
|
|
11
55
|
class OperatorClient {
|
|
12
56
|
constructor(config) {
|
|
13
57
|
this.config = config;
|
|
@@ -29,32 +73,7 @@ class OperatorClient {
|
|
|
29
73
|
},
|
|
30
74
|
};
|
|
31
75
|
const response = await (0, sdk_gen_1.autoSelectOperator)(data);
|
|
32
|
-
|
|
33
|
-
// Check if this is an immediate response (sync execution)
|
|
34
|
-
if (responseData?.content !== undefined && responseData?.operator_used) {
|
|
35
|
-
return {
|
|
36
|
-
content: responseData.content,
|
|
37
|
-
operator_used: responseData.operator_used,
|
|
38
|
-
mode_used: responseData.mode_used,
|
|
39
|
-
metadata: responseData.metadata,
|
|
40
|
-
tokens_used: responseData.tokens_used,
|
|
41
|
-
confidence_score: responseData.confidence_score,
|
|
42
|
-
execution_time: responseData.execution_time,
|
|
43
|
-
timestamp: new Date().toISOString(),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
// Check if this is a queued response (async background task execution)
|
|
47
|
-
if (responseData?.operation_id) {
|
|
48
|
-
const queuedResponse = responseData;
|
|
49
|
-
// If user doesn't want to wait, throw with queue info
|
|
50
|
-
if (options.maxWait === 0) {
|
|
51
|
-
throw new QueuedOperatorError(queuedResponse);
|
|
52
|
-
}
|
|
53
|
-
// Use SSE to monitor the operation
|
|
54
|
-
return this.waitForOperatorCompletion(queuedResponse.operation_id, options);
|
|
55
|
-
}
|
|
56
|
-
// Unexpected response format
|
|
57
|
-
throw new Error('Unexpected response format from operator endpoint');
|
|
76
|
+
return this.settle(response.data, options);
|
|
58
77
|
}
|
|
59
78
|
/**
|
|
60
79
|
* Execute specific operator type
|
|
@@ -73,103 +92,150 @@ class OperatorClient {
|
|
|
73
92
|
},
|
|
74
93
|
};
|
|
75
94
|
const response = await (0, sdk_gen_1.executeSpecificOperator)(data);
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
return this.settle(response.data, options);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve an operator endpoint response: a sync 200 body is the result, a
|
|
99
|
+
* 202 with an `operation_id` is followed to completion.
|
|
100
|
+
*/
|
|
101
|
+
async settle(responseData, options) {
|
|
102
|
+
// Immediate response (sync execution)
|
|
78
103
|
if (responseData?.content !== undefined && responseData?.operator_used) {
|
|
79
|
-
return
|
|
80
|
-
content: responseData.content,
|
|
81
|
-
operator_used: responseData.operator_used,
|
|
82
|
-
mode_used: responseData.mode_used,
|
|
83
|
-
metadata: responseData.metadata,
|
|
84
|
-
tokens_used: responseData.tokens_used,
|
|
85
|
-
confidence_score: responseData.confidence_score,
|
|
86
|
-
execution_time: responseData.execution_time,
|
|
87
|
-
timestamp: new Date().toISOString(),
|
|
88
|
-
};
|
|
104
|
+
return toOperatorResult(responseData);
|
|
89
105
|
}
|
|
90
|
-
//
|
|
106
|
+
// Queued response (async background task execution)
|
|
91
107
|
if (responseData?.operation_id) {
|
|
92
108
|
const queuedResponse = responseData;
|
|
93
109
|
// If user doesn't want to wait, throw with queue info
|
|
94
110
|
if (options.maxWait === 0) {
|
|
95
111
|
throw new QueuedOperatorError(queuedResponse);
|
|
96
112
|
}
|
|
97
|
-
// Use SSE to monitor the operation
|
|
98
113
|
return this.waitForOperatorCompletion(queuedResponse.operation_id, options);
|
|
99
114
|
}
|
|
100
115
|
// Unexpected response format
|
|
101
116
|
throw new Error('Unexpected response format from operator endpoint');
|
|
102
117
|
}
|
|
103
118
|
async waitForOperatorCompletion(operationId, options) {
|
|
119
|
+
const sseClient = new SSEClient_1.SSEClient(this.config);
|
|
120
|
+
this.sseClient = sseClient;
|
|
121
|
+
try {
|
|
122
|
+
await sseClient.connect(operationId);
|
|
123
|
+
}
|
|
124
|
+
catch (streamError) {
|
|
125
|
+
// The run is already queued and finishes whether or not anyone is
|
|
126
|
+
// watching, so a stream that cannot open — a revoked token, a proxy
|
|
127
|
+
// that will not hold the connection, the per-user connection cap —
|
|
128
|
+
// must not lose the result. Follow the operation over `/status`,
|
|
129
|
+
// which rides the regular REST auth path, instead.
|
|
130
|
+
this.sseClient = undefined;
|
|
131
|
+
return this.pollForCompletion(operationId, options, streamError);
|
|
132
|
+
}
|
|
104
133
|
return new Promise((resolve, reject) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
let result = null;
|
|
135
|
+
const finish = () => {
|
|
136
|
+
sseClient.close();
|
|
137
|
+
if (this.sseClient === sseClient) {
|
|
138
|
+
this.sseClient = undefined;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Listen for progress events
|
|
142
|
+
sseClient.on(SSEClient_1.EventType.OPERATION_PROGRESS, (data) => {
|
|
143
|
+
options.onProgress?.(data.message, data.progress_percentage);
|
|
144
|
+
});
|
|
145
|
+
// Listen for agent-specific events
|
|
146
|
+
sseClient.on('operator_started', (data) => {
|
|
147
|
+
options.onProgress?.(`Operator ${data.operator_type} started`, 0);
|
|
148
|
+
});
|
|
149
|
+
sseClient.on('operator_initialized', (data) => {
|
|
150
|
+
options.onProgress?.(`${data.agent_name} initialized`, 10);
|
|
151
|
+
});
|
|
152
|
+
sseClient.on('progress', (data) => {
|
|
153
|
+
options.onProgress?.(data.message, data.percentage);
|
|
154
|
+
});
|
|
155
|
+
sseClient.on('operator_completed', (data) => {
|
|
156
|
+
result = toOperatorResult(data);
|
|
157
|
+
finish();
|
|
158
|
+
resolve(result);
|
|
159
|
+
});
|
|
160
|
+
// Fallback to generic completion event
|
|
161
|
+
sseClient.on(SSEClient_1.EventType.OPERATION_COMPLETED, (data) => {
|
|
162
|
+
if (!result) {
|
|
163
|
+
result = toOperatorResult(data.result || data);
|
|
164
|
+
finish();
|
|
136
165
|
resolve(result);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
sseClient.close();
|
|
153
|
-
resolve(result);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
sseClient.on(SSEClient_1.EventType.OPERATION_ERROR, (error) => {
|
|
157
|
-
sseClient.close();
|
|
158
|
-
reject(new Error(error.message || error.error));
|
|
159
|
-
});
|
|
160
|
-
sseClient.on(SSEClient_1.EventType.OPERATION_CANCELLED, () => {
|
|
161
|
-
sseClient.close();
|
|
162
|
-
reject(new Error('Agent execution cancelled'));
|
|
163
|
-
});
|
|
164
|
-
// Handle generic error event
|
|
165
|
-
sseClient.on('error', (error) => {
|
|
166
|
-
sseClient.close();
|
|
167
|
-
reject(new Error(error.error || error.message || 'Agent execution failed'));
|
|
168
|
-
});
|
|
169
|
-
})
|
|
170
|
-
.catch(reject);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
sseClient.on(SSEClient_1.EventType.OPERATION_ERROR, (error) => {
|
|
169
|
+
finish();
|
|
170
|
+
reject(new Error(error.message || error.error));
|
|
171
|
+
});
|
|
172
|
+
sseClient.on(SSEClient_1.EventType.OPERATION_CANCELLED, () => {
|
|
173
|
+
finish();
|
|
174
|
+
reject(new Error('Agent execution cancelled'));
|
|
175
|
+
});
|
|
176
|
+
// Handle generic error event
|
|
177
|
+
sseClient.on('error', (error) => {
|
|
178
|
+
finish();
|
|
179
|
+
reject(new Error(error.error || error.message || 'Agent execution failed'));
|
|
180
|
+
});
|
|
171
181
|
});
|
|
172
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Follow a queued run over `/v1/operations/{id}/status` until it settles.
|
|
185
|
+
* Used when the SSE stream could not be opened; `streamError` is folded
|
|
186
|
+
* into the failure message if polling cannot reach a verdict either.
|
|
187
|
+
*/
|
|
188
|
+
async pollForCompletion(operationId, options, streamError) {
|
|
189
|
+
const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
190
|
+
const streamDetail = describeError(streamError);
|
|
191
|
+
let consecutiveFailures = 0;
|
|
192
|
+
options.onProgress?.('Live progress unavailable — waiting for the result');
|
|
193
|
+
for (;;) {
|
|
194
|
+
let status;
|
|
195
|
+
try {
|
|
196
|
+
const response = await (0, sdk_gen_1.getOperationStatus)({ path: { operation_id: operationId } });
|
|
197
|
+
if (response.error || !response.data) {
|
|
198
|
+
const httpStatus = response.response?.status;
|
|
199
|
+
const detail = describeEnvelopeError(response.error);
|
|
200
|
+
// A definitive 4xx (expired, not ours, unauthenticated) ends the
|
|
201
|
+
// wait; anything else is treated as transient and retried below.
|
|
202
|
+
if (httpStatus !== undefined &&
|
|
203
|
+
httpStatus >= 400 &&
|
|
204
|
+
httpStatus < 500 &&
|
|
205
|
+
httpStatus !== 429) {
|
|
206
|
+
throw new PollAbort(`Operator stream failed (${streamDetail}); status check failed (${httpStatus}: ${detail})`);
|
|
207
|
+
}
|
|
208
|
+
throw new Error(detail);
|
|
209
|
+
}
|
|
210
|
+
status = response.data;
|
|
211
|
+
consecutiveFailures = 0;
|
|
212
|
+
}
|
|
213
|
+
catch (pollError) {
|
|
214
|
+
if (pollError instanceof PollAbort) {
|
|
215
|
+
throw new Error(pollError.message);
|
|
216
|
+
}
|
|
217
|
+
consecutiveFailures += 1;
|
|
218
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
219
|
+
throw new Error(`Operator stream failed (${streamDetail}); status polling failed (${describeError(pollError)})`);
|
|
220
|
+
}
|
|
221
|
+
await sleep(interval);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
switch (status.status) {
|
|
225
|
+
case 'completed':
|
|
226
|
+
return toOperatorResult(status.result || {});
|
|
227
|
+
case 'failed':
|
|
228
|
+
throw new Error(status.error || status.message || 'Operator run failed');
|
|
229
|
+
case 'cancelled':
|
|
230
|
+
throw new Error('Agent execution cancelled');
|
|
231
|
+
default:
|
|
232
|
+
if (status.message) {
|
|
233
|
+
options.onProgress?.(status.message);
|
|
234
|
+
}
|
|
235
|
+
await sleep(interval);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
173
239
|
/**
|
|
174
240
|
* Convenience method for simple agent queries with auto-selection
|
|
175
241
|
*/
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* Provides intelligent operator execution with automatic strategy selection
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { autoSelectOperator, executeSpecificOperator } from '../sdk.gen'
|
|
8
|
+
import { autoSelectOperator, executeSpecificOperator, getOperationStatus } from '../sdk.gen'
|
|
9
9
|
import type { AutoSelectOperatorData, ExecuteSpecificOperatorData } from '../types.gen'
|
|
10
|
+
import type { TokenProvider } from './graphql/client'
|
|
10
11
|
import { EventType, SSEClient } from './SSEClient'
|
|
11
12
|
|
|
12
13
|
export interface OperatorQueryRequest {
|
|
@@ -22,6 +23,12 @@ export interface OperatorOptions {
|
|
|
22
23
|
mode?: 'auto' | 'sync' | 'async'
|
|
23
24
|
maxWait?: number
|
|
24
25
|
onProgress?: (message: string, percentage?: number) => void
|
|
26
|
+
/**
|
|
27
|
+
* Interval between `/v1/operations/{id}/status` polls while the client
|
|
28
|
+
* follows a queued run it could not open an SSE stream for. Defaults to
|
|
29
|
+
* 2000 ms; only the fallback path uses it.
|
|
30
|
+
*/
|
|
31
|
+
pollIntervalMs?: number
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface OperatorResult {
|
|
@@ -37,6 +44,12 @@ export interface OperatorResult {
|
|
|
37
44
|
confidence_score?: number
|
|
38
45
|
execution_time?: number
|
|
39
46
|
timestamp?: string
|
|
47
|
+
/**
|
|
48
|
+
* Present when the API reports a failed run inside an otherwise successful
|
|
49
|
+
* response (credit pre-flight, operator timeouts, cancelled runs). `content`
|
|
50
|
+
* then carries the explanation rather than an answer.
|
|
51
|
+
*/
|
|
52
|
+
error_details?: Record<string, any>
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
export interface QueuedOperatorResponse {
|
|
@@ -46,21 +59,74 @@ export interface QueuedOperatorResponse {
|
|
|
46
59
|
sse_endpoint?: string
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
export interface OperatorClientConfig {
|
|
63
|
+
baseUrl: string
|
|
64
|
+
credentials?: 'include' | 'same-origin' | 'omit'
|
|
65
|
+
headers?: Record<string, string>
|
|
66
|
+
/**
|
|
67
|
+
* Static JWT captured at construction. Prefer `tokenProvider` in the
|
|
68
|
+
* browser — see `SSEConfig` for why a captured token goes stale.
|
|
69
|
+
*/
|
|
70
|
+
token?: string
|
|
71
|
+
/**
|
|
72
|
+
* Consulted on every SSE connect so a rotated JWT keeps the progress
|
|
73
|
+
* stream authenticated. Wins over `token` when both are set.
|
|
74
|
+
*/
|
|
75
|
+
tokenProvider?: TokenProvider
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000
|
|
79
|
+
|
|
80
|
+
// Consecutive `/status` failures tolerated before the polling fallback gives
|
|
81
|
+
// up: one transient network error must not lose a run that is still going.
|
|
82
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3
|
|
83
|
+
|
|
84
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
85
|
+
|
|
86
|
+
const describeError = (err: unknown): string => (err instanceof Error ? err.message : String(err))
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Text for the `error` slot of a generated-client envelope: a thrown `Error`
|
|
90
|
+
* (network failure — the client returns those rather than throwing), an API
|
|
91
|
+
* body with `detail`, or whatever else came back.
|
|
92
|
+
*/
|
|
93
|
+
const describeEnvelopeError = (err: unknown): string => {
|
|
94
|
+
if (err instanceof Error) return err.message
|
|
95
|
+
if (err && typeof err === 'object' && 'detail' in err) return String((err as any).detail)
|
|
96
|
+
if (err === undefined || err === null) return 'empty response'
|
|
97
|
+
return typeof err === 'string' ? err : JSON.stringify(err)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Shape an operator payload — the sync 200 body, the `operation_completed`
|
|
102
|
+
* event, or the `/status` `result` — into the public result type. All three
|
|
103
|
+
* carry the same fields, so one mapper keeps them consistent.
|
|
104
|
+
*/
|
|
105
|
+
function toOperatorResult(data: Record<string, any>): OperatorResult {
|
|
106
|
+
const result: OperatorResult = {
|
|
107
|
+
content: data.content ?? '',
|
|
108
|
+
operator_used: data.operator_used || 'unknown',
|
|
109
|
+
mode_used: data.mode_used || 'standard',
|
|
110
|
+
metadata: data.metadata,
|
|
111
|
+
tokens_used: data.tokens_used,
|
|
112
|
+
confidence_score: data.confidence_score,
|
|
113
|
+
execution_time: data.execution_time,
|
|
114
|
+
timestamp: data.timestamp || new Date().toISOString(),
|
|
115
|
+
}
|
|
116
|
+
if (data.error_details) {
|
|
117
|
+
result.error_details = data.error_details
|
|
118
|
+
}
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Internal: a `/status` verdict that must not be retried. */
|
|
123
|
+
class PollAbort extends Error {}
|
|
124
|
+
|
|
49
125
|
export class OperatorClient {
|
|
50
126
|
private sseClient?: SSEClient
|
|
51
|
-
private config:
|
|
52
|
-
baseUrl: string
|
|
53
|
-
credentials?: 'include' | 'same-origin' | 'omit'
|
|
54
|
-
headers?: Record<string, string>
|
|
55
|
-
token?: string
|
|
56
|
-
}
|
|
127
|
+
private config: OperatorClientConfig
|
|
57
128
|
|
|
58
|
-
constructor(config: {
|
|
59
|
-
baseUrl: string
|
|
60
|
-
credentials?: 'include' | 'same-origin' | 'omit'
|
|
61
|
-
headers?: Record<string, string>
|
|
62
|
-
token?: string
|
|
63
|
-
}) {
|
|
129
|
+
constructor(config: OperatorClientConfig) {
|
|
64
130
|
this.config = config
|
|
65
131
|
}
|
|
66
132
|
|
|
@@ -86,37 +152,7 @@ export class OperatorClient {
|
|
|
86
152
|
}
|
|
87
153
|
|
|
88
154
|
const response = await autoSelectOperator(data)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Check if this is an immediate response (sync execution)
|
|
92
|
-
if (responseData?.content !== undefined && responseData?.operator_used) {
|
|
93
|
-
return {
|
|
94
|
-
content: responseData.content,
|
|
95
|
-
operator_used: responseData.operator_used,
|
|
96
|
-
mode_used: responseData.mode_used,
|
|
97
|
-
metadata: responseData.metadata,
|
|
98
|
-
tokens_used: responseData.tokens_used,
|
|
99
|
-
confidence_score: responseData.confidence_score,
|
|
100
|
-
execution_time: responseData.execution_time,
|
|
101
|
-
timestamp: new Date().toISOString(),
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Check if this is a queued response (async background task execution)
|
|
106
|
-
if (responseData?.operation_id) {
|
|
107
|
-
const queuedResponse = responseData as QueuedOperatorResponse
|
|
108
|
-
|
|
109
|
-
// If user doesn't want to wait, throw with queue info
|
|
110
|
-
if (options.maxWait === 0) {
|
|
111
|
-
throw new QueuedOperatorError(queuedResponse)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Use SSE to monitor the operation
|
|
115
|
-
return this.waitForOperatorCompletion(queuedResponse.operation_id, options)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Unexpected response format
|
|
119
|
-
throw new Error('Unexpected response format from operator endpoint')
|
|
155
|
+
return this.settle(response.data, options)
|
|
120
156
|
}
|
|
121
157
|
|
|
122
158
|
/**
|
|
@@ -142,23 +178,20 @@ export class OperatorClient {
|
|
|
142
178
|
}
|
|
143
179
|
|
|
144
180
|
const response = await executeSpecificOperator(data)
|
|
145
|
-
|
|
181
|
+
return this.settle(response.data, options)
|
|
182
|
+
}
|
|
146
183
|
|
|
147
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Resolve an operator endpoint response: a sync 200 body is the result, a
|
|
186
|
+
* 202 with an `operation_id` is followed to completion.
|
|
187
|
+
*/
|
|
188
|
+
private async settle(responseData: any, options: OperatorOptions): Promise<OperatorResult> {
|
|
189
|
+
// Immediate response (sync execution)
|
|
148
190
|
if (responseData?.content !== undefined && responseData?.operator_used) {
|
|
149
|
-
return
|
|
150
|
-
content: responseData.content,
|
|
151
|
-
operator_used: responseData.operator_used,
|
|
152
|
-
mode_used: responseData.mode_used,
|
|
153
|
-
metadata: responseData.metadata,
|
|
154
|
-
tokens_used: responseData.tokens_used,
|
|
155
|
-
confidence_score: responseData.confidence_score,
|
|
156
|
-
execution_time: responseData.execution_time,
|
|
157
|
-
timestamp: new Date().toISOString(),
|
|
158
|
-
}
|
|
191
|
+
return toOperatorResult(responseData)
|
|
159
192
|
}
|
|
160
193
|
|
|
161
|
-
//
|
|
194
|
+
// Queued response (async background task execution)
|
|
162
195
|
if (responseData?.operation_id) {
|
|
163
196
|
const queuedResponse = responseData as QueuedOperatorResponse
|
|
164
197
|
|
|
@@ -167,7 +200,6 @@ export class OperatorClient {
|
|
|
167
200
|
throw new QueuedOperatorError(queuedResponse)
|
|
168
201
|
}
|
|
169
202
|
|
|
170
|
-
// Use SSE to monitor the operation
|
|
171
203
|
return this.waitForOperatorCompletion(queuedResponse.operation_id, options)
|
|
172
204
|
}
|
|
173
205
|
|
|
@@ -179,86 +211,151 @@ export class OperatorClient {
|
|
|
179
211
|
operationId: string,
|
|
180
212
|
options: OperatorOptions
|
|
181
213
|
): Promise<OperatorResult> {
|
|
214
|
+
const sseClient = new SSEClient(this.config)
|
|
215
|
+
this.sseClient = sseClient
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await sseClient.connect(operationId)
|
|
219
|
+
} catch (streamError) {
|
|
220
|
+
// The run is already queued and finishes whether or not anyone is
|
|
221
|
+
// watching, so a stream that cannot open — a revoked token, a proxy
|
|
222
|
+
// that will not hold the connection, the per-user connection cap —
|
|
223
|
+
// must not lose the result. Follow the operation over `/status`,
|
|
224
|
+
// which rides the regular REST auth path, instead.
|
|
225
|
+
this.sseClient = undefined
|
|
226
|
+
return this.pollForCompletion(operationId, options, streamError)
|
|
227
|
+
}
|
|
228
|
+
|
|
182
229
|
return new Promise((resolve, reject) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
sseClient.on(EventType.OPERATION_ERROR, (error) => {
|
|
243
|
-
sseClient.close()
|
|
244
|
-
reject(new Error(error.message || error.error))
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
sseClient.on(EventType.OPERATION_CANCELLED, () => {
|
|
248
|
-
sseClient.close()
|
|
249
|
-
reject(new Error('Agent execution cancelled'))
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// Handle generic error event
|
|
253
|
-
sseClient.on('error' as EventType, (error) => {
|
|
254
|
-
sseClient.close()
|
|
255
|
-
reject(new Error(error.error || error.message || 'Agent execution failed'))
|
|
256
|
-
})
|
|
257
|
-
})
|
|
258
|
-
.catch(reject)
|
|
230
|
+
let result: OperatorResult | null = null
|
|
231
|
+
|
|
232
|
+
const finish = () => {
|
|
233
|
+
sseClient.close()
|
|
234
|
+
if (this.sseClient === sseClient) {
|
|
235
|
+
this.sseClient = undefined
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Listen for progress events
|
|
240
|
+
sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
|
|
241
|
+
options.onProgress?.(data.message, data.progress_percentage)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Listen for agent-specific events
|
|
245
|
+
sseClient.on('operator_started' as EventType, (data) => {
|
|
246
|
+
options.onProgress?.(`Operator ${data.operator_type} started`, 0)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
sseClient.on('operator_initialized' as EventType, (data) => {
|
|
250
|
+
options.onProgress?.(`${data.agent_name} initialized`, 10)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
sseClient.on('progress' as EventType, (data) => {
|
|
254
|
+
options.onProgress?.(data.message, data.percentage)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
sseClient.on('operator_completed' as EventType, (data) => {
|
|
258
|
+
result = toOperatorResult(data)
|
|
259
|
+
finish()
|
|
260
|
+
resolve(result)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Fallback to generic completion event
|
|
264
|
+
sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
|
|
265
|
+
if (!result) {
|
|
266
|
+
result = toOperatorResult(data.result || data)
|
|
267
|
+
finish()
|
|
268
|
+
resolve(result)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
sseClient.on(EventType.OPERATION_ERROR, (error) => {
|
|
273
|
+
finish()
|
|
274
|
+
reject(new Error(error.message || error.error))
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
sseClient.on(EventType.OPERATION_CANCELLED, () => {
|
|
278
|
+
finish()
|
|
279
|
+
reject(new Error('Agent execution cancelled'))
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Handle generic error event
|
|
283
|
+
sseClient.on('error' as EventType, (error) => {
|
|
284
|
+
finish()
|
|
285
|
+
reject(new Error(error.error || error.message || 'Agent execution failed'))
|
|
286
|
+
})
|
|
259
287
|
})
|
|
260
288
|
}
|
|
261
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Follow a queued run over `/v1/operations/{id}/status` until it settles.
|
|
292
|
+
* Used when the SSE stream could not be opened; `streamError` is folded
|
|
293
|
+
* into the failure message if polling cannot reach a verdict either.
|
|
294
|
+
*/
|
|
295
|
+
private async pollForCompletion(
|
|
296
|
+
operationId: string,
|
|
297
|
+
options: OperatorOptions,
|
|
298
|
+
streamError: unknown
|
|
299
|
+
): Promise<OperatorResult> {
|
|
300
|
+
const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
301
|
+
const streamDetail = describeError(streamError)
|
|
302
|
+
let consecutiveFailures = 0
|
|
303
|
+
|
|
304
|
+
options.onProgress?.('Live progress unavailable — waiting for the result')
|
|
305
|
+
|
|
306
|
+
for (;;) {
|
|
307
|
+
let status: any
|
|
308
|
+
try {
|
|
309
|
+
const response = await getOperationStatus({ path: { operation_id: operationId } })
|
|
310
|
+
if (response.error || !response.data) {
|
|
311
|
+
const httpStatus = response.response?.status
|
|
312
|
+
const detail = describeEnvelopeError(response.error)
|
|
313
|
+
// A definitive 4xx (expired, not ours, unauthenticated) ends the
|
|
314
|
+
// wait; anything else is treated as transient and retried below.
|
|
315
|
+
if (
|
|
316
|
+
httpStatus !== undefined &&
|
|
317
|
+
httpStatus >= 400 &&
|
|
318
|
+
httpStatus < 500 &&
|
|
319
|
+
httpStatus !== 429
|
|
320
|
+
) {
|
|
321
|
+
throw new PollAbort(
|
|
322
|
+
`Operator stream failed (${streamDetail}); status check failed (${httpStatus}: ${detail})`
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
throw new Error(detail)
|
|
326
|
+
}
|
|
327
|
+
status = response.data
|
|
328
|
+
consecutiveFailures = 0
|
|
329
|
+
} catch (pollError) {
|
|
330
|
+
if (pollError instanceof PollAbort) {
|
|
331
|
+
throw new Error(pollError.message)
|
|
332
|
+
}
|
|
333
|
+
consecutiveFailures += 1
|
|
334
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Operator stream failed (${streamDetail}); status polling failed (${describeError(pollError)})`
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
await sleep(interval)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
switch (status.status) {
|
|
344
|
+
case 'completed':
|
|
345
|
+
return toOperatorResult(status.result || {})
|
|
346
|
+
case 'failed':
|
|
347
|
+
throw new Error(status.error || status.message || 'Operator run failed')
|
|
348
|
+
case 'cancelled':
|
|
349
|
+
throw new Error('Agent execution cancelled')
|
|
350
|
+
default:
|
|
351
|
+
if (status.message) {
|
|
352
|
+
options.onProgress?.(status.message)
|
|
353
|
+
}
|
|
354
|
+
await sleep(interval)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
262
359
|
/**
|
|
263
360
|
* Convenience method for simple agent queries with auto-selection
|
|
264
361
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TokenProvider } from './graphql/client';
|
|
1
2
|
export interface QueryRequest {
|
|
2
3
|
query: string;
|
|
3
4
|
parameters?: Record<string, any>;
|
|
@@ -34,6 +35,7 @@ export declare class QueryClient {
|
|
|
34
35
|
credentials?: 'include' | 'same-origin' | 'omit';
|
|
35
36
|
headers?: Record<string, string>;
|
|
36
37
|
token?: string;
|
|
38
|
+
tokenProvider?: TokenProvider;
|
|
37
39
|
});
|
|
38
40
|
executeQuery(graphId: string, request: QueryRequest, options?: QueryOptions): Promise<QueryResult | AsyncIterableIterator<any>>;
|
|
39
41
|
private parseNDJSONResponse;
|
package/artifacts/QueryClient.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { executeCypher } from '../sdk.gen'
|
|
9
9
|
import type { ExecuteCypherData } from '../types.gen'
|
|
10
|
+
import type { TokenProvider } from './graphql/client'
|
|
10
11
|
import { EventType, SSEClient } from './SSEClient'
|
|
11
12
|
|
|
12
13
|
export interface QueryRequest {
|
|
@@ -48,6 +49,7 @@ export class QueryClient {
|
|
|
48
49
|
credentials?: 'include' | 'same-origin' | 'omit'
|
|
49
50
|
headers?: Record<string, string>
|
|
50
51
|
token?: string // JWT token for authentication
|
|
52
|
+
tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
constructor(config: {
|
|
@@ -55,6 +57,7 @@ export class QueryClient {
|
|
|
55
57
|
credentials?: 'include' | 'same-origin' | 'omit'
|
|
56
58
|
headers?: Record<string, string>
|
|
57
59
|
token?: string // JWT token for authentication
|
|
60
|
+
tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
|
|
58
61
|
}) {
|
|
59
62
|
this.config = config
|
|
60
63
|
}
|
package/artifacts/SSEClient.d.ts
CHANGED
|
@@ -14,12 +14,30 @@
|
|
|
14
14
|
* - Implementing a WebSocket-based alternative
|
|
15
15
|
* - Using short-lived tokens that expire quickly
|
|
16
16
|
* - Ensuring all connections use HTTPS
|
|
17
|
+
*
|
|
18
|
+
* CREDENTIAL ROTATION: the stream endpoint authenticates the JWT it finds in
|
|
19
|
+
* the URL, and the backend revokes the previous JWT on every session refresh.
|
|
20
|
+
* A `token` captured once therefore goes dead the moment the session rotates
|
|
21
|
+
* (about every 25 minutes in the browser apps); pass a `tokenProvider` and it
|
|
22
|
+
* is consulted on every connect — including automatic reconnects — instead.
|
|
17
23
|
*/
|
|
24
|
+
import type { TokenProvider } from './graphql/client';
|
|
18
25
|
export interface SSEConfig {
|
|
19
26
|
baseUrl: string;
|
|
20
27
|
credentials?: 'include' | 'same-origin' | 'omit';
|
|
21
28
|
headers?: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Static JWT captured at construction. Fine for long-lived API keys and
|
|
31
|
+
* server-side flows; browser sessions should use `tokenProvider`.
|
|
32
|
+
*/
|
|
22
33
|
token?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Dynamic credential callback, consulted on every `connect()` and
|
|
36
|
+
* preferred over `token` when both are set. Return `null` to connect
|
|
37
|
+
* without a token. See the class documentation for why a static token
|
|
38
|
+
* is not enough in the browser.
|
|
39
|
+
*/
|
|
40
|
+
tokenProvider?: TokenProvider;
|
|
23
41
|
maxRetries?: number;
|
|
24
42
|
retryDelay?: number;
|
|
25
43
|
heartbeatInterval?: number;
|
|
@@ -51,6 +69,14 @@ export declare class SSEClient {
|
|
|
51
69
|
private listeners;
|
|
52
70
|
constructor(config: SSEConfig);
|
|
53
71
|
connect(operationId: string, fromSequence?: number): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Credential for one connect attempt: the `tokenProvider` result when one
|
|
74
|
+
* is configured (`null`/`undefined` connects unauthenticated), otherwise
|
|
75
|
+
* the static `token`. A throwing provider fails the connect — matching the
|
|
76
|
+
* GraphQL client — because the caller intended to authenticate, and a
|
|
77
|
+
* silent unauthenticated attempt would surface as an unrelated-looking 401.
|
|
78
|
+
*/
|
|
79
|
+
private resolveToken;
|
|
54
80
|
private handleMessage;
|
|
55
81
|
private handleTypedEvent;
|
|
56
82
|
private handleError;
|
package/artifacts/SSEClient.js
CHANGED
|
@@ -27,13 +27,16 @@ class SSEClient {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
async connect(operationId, fromSequence = 0) {
|
|
30
|
+
// Resolved per attempt so a rotated JWT is picked up by every connect,
|
|
31
|
+
// not just the first one after construction.
|
|
32
|
+
const token = await this.resolveToken();
|
|
30
33
|
return new Promise((resolve, reject) => {
|
|
31
34
|
let url = `${this.config.baseUrl}/v1/operations/${operationId}/stream?from_sequence=${fromSequence}`;
|
|
32
35
|
// Add JWT token as query parameter if provided
|
|
33
36
|
// WARNING: EventSource API doesn't support custom headers, so tokens are passed via query param
|
|
34
37
|
// This has security implications - see class documentation
|
|
35
|
-
if (
|
|
36
|
-
url += `&token=${encodeURIComponent(
|
|
38
|
+
if (token) {
|
|
39
|
+
url += `&token=${encodeURIComponent(token)}`;
|
|
37
40
|
}
|
|
38
41
|
this.eventSource = new EventSource(url, {
|
|
39
42
|
withCredentials: this.config.credentials === 'include',
|
|
@@ -83,6 +86,29 @@ class SSEClient {
|
|
|
83
86
|
});
|
|
84
87
|
});
|
|
85
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Credential for one connect attempt: the `tokenProvider` result when one
|
|
91
|
+
* is configured (`null`/`undefined` connects unauthenticated), otherwise
|
|
92
|
+
* the static `token`. A throwing provider fails the connect — matching the
|
|
93
|
+
* GraphQL client — because the caller intended to authenticate, and a
|
|
94
|
+
* silent unauthenticated attempt would surface as an unrelated-looking 401.
|
|
95
|
+
*/
|
|
96
|
+
async resolveToken() {
|
|
97
|
+
if (!this.config.tokenProvider) {
|
|
98
|
+
return this.config.token;
|
|
99
|
+
}
|
|
100
|
+
let token;
|
|
101
|
+
try {
|
|
102
|
+
token = await this.config.tokenProvider();
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
106
|
+
throw new Error(`RoboSystems SDK: tokenProvider threw while resolving the SSE credential (${detail}). ` +
|
|
107
|
+
'Fix the tokenProvider passed in the client config (or via setSDKClientConfig) so it ' +
|
|
108
|
+
'returns the current token, or null to connect without one.');
|
|
109
|
+
}
|
|
110
|
+
return token || undefined;
|
|
111
|
+
}
|
|
86
112
|
handleMessage(event) {
|
|
87
113
|
try {
|
|
88
114
|
const data = JSON.parse(event.data);
|
package/artifacts/SSEClient.ts
CHANGED
|
@@ -16,13 +16,32 @@
|
|
|
16
16
|
* - Implementing a WebSocket-based alternative
|
|
17
17
|
* - Using short-lived tokens that expire quickly
|
|
18
18
|
* - Ensuring all connections use HTTPS
|
|
19
|
+
*
|
|
20
|
+
* CREDENTIAL ROTATION: the stream endpoint authenticates the JWT it finds in
|
|
21
|
+
* the URL, and the backend revokes the previous JWT on every session refresh.
|
|
22
|
+
* A `token` captured once therefore goes dead the moment the session rotates
|
|
23
|
+
* (about every 25 minutes in the browser apps); pass a `tokenProvider` and it
|
|
24
|
+
* is consulted on every connect — including automatic reconnects — instead.
|
|
19
25
|
*/
|
|
20
26
|
|
|
27
|
+
import type { TokenProvider } from './graphql/client'
|
|
28
|
+
|
|
21
29
|
export interface SSEConfig {
|
|
22
30
|
baseUrl: string
|
|
23
31
|
credentials?: 'include' | 'same-origin' | 'omit'
|
|
24
32
|
headers?: Record<string, string>
|
|
25
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Static JWT captured at construction. Fine for long-lived API keys and
|
|
35
|
+
* server-side flows; browser sessions should use `tokenProvider`.
|
|
36
|
+
*/
|
|
37
|
+
token?: string
|
|
38
|
+
/**
|
|
39
|
+
* Dynamic credential callback, consulted on every `connect()` and
|
|
40
|
+
* preferred over `token` when both are set. Return `null` to connect
|
|
41
|
+
* without a token. See the class documentation for why a static token
|
|
42
|
+
* is not enough in the browser.
|
|
43
|
+
*/
|
|
44
|
+
tokenProvider?: TokenProvider
|
|
26
45
|
maxRetries?: number
|
|
27
46
|
retryDelay?: number
|
|
28
47
|
heartbeatInterval?: number
|
|
@@ -66,14 +85,18 @@ export class SSEClient {
|
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
async connect(operationId: string, fromSequence: number = 0): Promise<void> {
|
|
88
|
+
// Resolved per attempt so a rotated JWT is picked up by every connect,
|
|
89
|
+
// not just the first one after construction.
|
|
90
|
+
const token = await this.resolveToken()
|
|
91
|
+
|
|
69
92
|
return new Promise((resolve, reject) => {
|
|
70
93
|
let url = `${this.config.baseUrl}/v1/operations/${operationId}/stream?from_sequence=${fromSequence}`
|
|
71
94
|
|
|
72
95
|
// Add JWT token as query parameter if provided
|
|
73
96
|
// WARNING: EventSource API doesn't support custom headers, so tokens are passed via query param
|
|
74
97
|
// This has security implications - see class documentation
|
|
75
|
-
if (
|
|
76
|
-
url += `&token=${encodeURIComponent(
|
|
98
|
+
if (token) {
|
|
99
|
+
url += `&token=${encodeURIComponent(token)}`
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
this.eventSource = new EventSource(url, {
|
|
@@ -135,6 +158,31 @@ export class SSEClient {
|
|
|
135
158
|
})
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Credential for one connect attempt: the `tokenProvider` result when one
|
|
163
|
+
* is configured (`null`/`undefined` connects unauthenticated), otherwise
|
|
164
|
+
* the static `token`. A throwing provider fails the connect — matching the
|
|
165
|
+
* GraphQL client — because the caller intended to authenticate, and a
|
|
166
|
+
* silent unauthenticated attempt would surface as an unrelated-looking 401.
|
|
167
|
+
*/
|
|
168
|
+
private async resolveToken(): Promise<string | undefined> {
|
|
169
|
+
if (!this.config.tokenProvider) {
|
|
170
|
+
return this.config.token
|
|
171
|
+
}
|
|
172
|
+
let token: string | null | undefined
|
|
173
|
+
try {
|
|
174
|
+
token = await this.config.tokenProvider()
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
177
|
+
throw new Error(
|
|
178
|
+
`RoboSystems SDK: tokenProvider threw while resolving the SSE credential (${detail}). ` +
|
|
179
|
+
'Fix the tokenProvider passed in the client config (or via setSDKClientConfig) so it ' +
|
|
180
|
+
'returns the current token, or null to connect without one.'
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return token || undefined
|
|
184
|
+
}
|
|
185
|
+
|
|
138
186
|
private handleMessage(event: MessageEvent): void {
|
|
139
187
|
try {
|
|
140
188
|
const data = JSON.parse(event.data)
|
package/artifacts/hooks.js
CHANGED
|
@@ -45,6 +45,7 @@ function useQuery(graphId) {
|
|
|
45
45
|
credentials: sdkConfig.credentials,
|
|
46
46
|
headers: sdkConfig.headers,
|
|
47
47
|
token,
|
|
48
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
48
49
|
});
|
|
49
50
|
return () => {
|
|
50
51
|
clientRef.current?.close();
|
|
@@ -122,6 +123,7 @@ function useStreamingQuery(graphId) {
|
|
|
122
123
|
credentials: sdkConfig.credentials,
|
|
123
124
|
headers: sdkConfig.headers,
|
|
124
125
|
token: sdkConfig.token,
|
|
126
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
125
127
|
});
|
|
126
128
|
return () => {
|
|
127
129
|
clientRef.current?.close();
|
|
@@ -201,6 +203,7 @@ function useOperation(operationId) {
|
|
|
201
203
|
baseUrl: sdkConfig.baseUrl || clientConfig.baseUrl || 'http://localhost:8000',
|
|
202
204
|
credentials: sdkConfig.credentials,
|
|
203
205
|
token,
|
|
206
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
204
207
|
maxRetries: sdkConfig.maxRetries,
|
|
205
208
|
retryDelay: sdkConfig.retryDelay,
|
|
206
209
|
});
|
|
@@ -300,6 +303,7 @@ function useMultipleOperations() {
|
|
|
300
303
|
baseUrl: sdkConfig.baseUrl || clientConfig.baseUrl || 'http://localhost:8000',
|
|
301
304
|
credentials: sdkConfig.credentials,
|
|
302
305
|
token,
|
|
306
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
303
307
|
maxRetries: sdkConfig.maxRetries,
|
|
304
308
|
retryDelay: sdkConfig.retryDelay,
|
|
305
309
|
});
|
|
@@ -368,6 +372,7 @@ function useSDKClients() {
|
|
|
368
372
|
credentials: sdkConfig.credentials,
|
|
369
373
|
headers: sdkConfig.headers,
|
|
370
374
|
token,
|
|
375
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
371
376
|
};
|
|
372
377
|
const queryClient = new QueryClient_1.QueryClient(baseConfig);
|
|
373
378
|
const operationsClient = new OperationClient_1.OperationClient(baseConfig);
|
package/artifacts/hooks.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function useQuery(graphId: string) {
|
|
|
46
46
|
credentials: sdkConfig.credentials,
|
|
47
47
|
headers: sdkConfig.headers,
|
|
48
48
|
token,
|
|
49
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
49
50
|
})
|
|
50
51
|
|
|
51
52
|
return () => {
|
|
@@ -143,6 +144,7 @@ export function useStreamingQuery(graphId: string) {
|
|
|
143
144
|
credentials: sdkConfig.credentials,
|
|
144
145
|
headers: sdkConfig.headers,
|
|
145
146
|
token: sdkConfig.token,
|
|
147
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
146
148
|
})
|
|
147
149
|
|
|
148
150
|
return () => {
|
|
@@ -240,6 +242,7 @@ export function useOperation<T = any>(operationId?: string) {
|
|
|
240
242
|
baseUrl: sdkConfig.baseUrl || clientConfig.baseUrl || 'http://localhost:8000',
|
|
241
243
|
credentials: sdkConfig.credentials,
|
|
242
244
|
token,
|
|
245
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
243
246
|
maxRetries: sdkConfig.maxRetries,
|
|
244
247
|
retryDelay: sdkConfig.retryDelay,
|
|
245
248
|
})
|
|
@@ -353,6 +356,7 @@ export function useMultipleOperations<T = any>() {
|
|
|
353
356
|
baseUrl: sdkConfig.baseUrl || clientConfig.baseUrl || 'http://localhost:8000',
|
|
354
357
|
credentials: sdkConfig.credentials,
|
|
355
358
|
token,
|
|
359
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
356
360
|
maxRetries: sdkConfig.maxRetries,
|
|
357
361
|
retryDelay: sdkConfig.retryDelay,
|
|
358
362
|
})
|
|
@@ -439,6 +443,7 @@ export function useSDKClients() {
|
|
|
439
443
|
credentials: sdkConfig.credentials,
|
|
440
444
|
headers: sdkConfig.headers,
|
|
441
445
|
token,
|
|
446
|
+
tokenProvider: sdkConfig.tokenProvider,
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
const queryClient = new QueryClient(baseConfig)
|
package/artifacts/index.d.ts
CHANGED
|
@@ -22,9 +22,12 @@ export interface RoboSystemsClientConfig {
|
|
|
22
22
|
*/
|
|
23
23
|
token?: string;
|
|
24
24
|
/**
|
|
25
|
-
* Dynamic credential callback invoked on every GraphQL request
|
|
26
|
-
* When set, JWT refreshes are picked up
|
|
27
|
-
* to rebuild or clear cached clients after a
|
|
25
|
+
* Dynamic credential callback invoked on every GraphQL request and
|
|
26
|
+
* every SSE connect. When set, JWT refreshes are picked up
|
|
27
|
+
* automatically — no need to rebuild or clear cached clients after a
|
|
28
|
+
* refresh. Browser sessions need this: the backend revokes the previous
|
|
29
|
+
* JWT on each refresh, so a static `token` captured at construction
|
|
30
|
+
* stops opening progress streams the moment the session rotates.
|
|
28
31
|
*/
|
|
29
32
|
tokenProvider?: TokenProvider;
|
|
30
33
|
headers?: Record<string, string>;
|
package/artifacts/index.js
CHANGED
|
@@ -60,27 +60,33 @@ class RoboSystemsClients {
|
|
|
60
60
|
retryDelay: config.retryDelay || 1000,
|
|
61
61
|
timeout: config.timeout,
|
|
62
62
|
};
|
|
63
|
+
// Every client that opens an SSE stream gets the tokenProvider too:
|
|
64
|
+
// the stream endpoint authenticates the JWT from the URL, and a
|
|
65
|
+
// captured static token is dead after the first session refresh.
|
|
63
66
|
this.query = new QueryClient_1.QueryClient({
|
|
64
67
|
baseUrl: this.config.baseUrl,
|
|
65
68
|
credentials: this.config.credentials,
|
|
66
69
|
token: this.config.token,
|
|
70
|
+
tokenProvider: this.config.tokenProvider,
|
|
67
71
|
headers: this.config.headers,
|
|
68
72
|
});
|
|
69
73
|
this.operator = new OperatorClient_1.OperatorClient({
|
|
70
74
|
baseUrl: this.config.baseUrl,
|
|
71
75
|
credentials: this.config.credentials,
|
|
72
76
|
token: this.config.token,
|
|
77
|
+
tokenProvider: this.config.tokenProvider,
|
|
73
78
|
headers: this.config.headers,
|
|
74
79
|
});
|
|
75
80
|
this.operations = new OperationClient_1.OperationClient({
|
|
76
81
|
baseUrl: this.config.baseUrl,
|
|
77
82
|
credentials: this.config.credentials,
|
|
78
83
|
token: this.config.token,
|
|
84
|
+
tokenProvider: this.config.tokenProvider,
|
|
79
85
|
maxRetries: this.config.maxRetries,
|
|
80
86
|
retryDelay: this.config.retryDelay,
|
|
81
87
|
});
|
|
82
|
-
// LedgerClient / InvestorClient use GraphQL internally
|
|
83
|
-
//
|
|
88
|
+
// LedgerClient / InvestorClient / LibraryClient use GraphQL internally
|
|
89
|
+
// and consult the tokenProvider on every request.
|
|
84
90
|
this.ledger = new LedgerClient_1.LedgerClient({
|
|
85
91
|
baseUrl: this.config.baseUrl,
|
|
86
92
|
credentials: this.config.credentials,
|
|
@@ -125,6 +131,7 @@ class RoboSystemsClients {
|
|
|
125
131
|
baseUrl: this.config.baseUrl,
|
|
126
132
|
credentials: this.config.credentials,
|
|
127
133
|
token: this.config.token,
|
|
134
|
+
tokenProvider: this.config.tokenProvider,
|
|
128
135
|
headers: this.config.headers,
|
|
129
136
|
maxRetries: this.config.maxRetries,
|
|
130
137
|
retryDelay: this.config.retryDelay,
|
package/artifacts/index.ts
CHANGED
|
@@ -34,9 +34,12 @@ export interface RoboSystemsClientConfig {
|
|
|
34
34
|
*/
|
|
35
35
|
token?: string
|
|
36
36
|
/**
|
|
37
|
-
* Dynamic credential callback invoked on every GraphQL request
|
|
38
|
-
* When set, JWT refreshes are picked up
|
|
39
|
-
* to rebuild or clear cached clients after a
|
|
37
|
+
* Dynamic credential callback invoked on every GraphQL request and
|
|
38
|
+
* every SSE connect. When set, JWT refreshes are picked up
|
|
39
|
+
* automatically — no need to rebuild or clear cached clients after a
|
|
40
|
+
* refresh. Browser sessions need this: the backend revokes the previous
|
|
41
|
+
* JWT on each refresh, so a static `token` captured at construction
|
|
42
|
+
* stops opening progress streams the moment the session rotates.
|
|
40
43
|
*/
|
|
41
44
|
tokenProvider?: TokenProvider
|
|
42
45
|
headers?: Record<string, string>
|
|
@@ -98,10 +101,14 @@ export class RoboSystemsClients {
|
|
|
98
101
|
timeout: config.timeout,
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// Every client that opens an SSE stream gets the tokenProvider too:
|
|
105
|
+
// the stream endpoint authenticates the JWT from the URL, and a
|
|
106
|
+
// captured static token is dead after the first session refresh.
|
|
101
107
|
this.query = new QueryClient({
|
|
102
108
|
baseUrl: this.config.baseUrl,
|
|
103
109
|
credentials: this.config.credentials,
|
|
104
110
|
token: this.config.token,
|
|
111
|
+
tokenProvider: this.config.tokenProvider,
|
|
105
112
|
headers: this.config.headers,
|
|
106
113
|
})
|
|
107
114
|
|
|
@@ -109,6 +116,7 @@ export class RoboSystemsClients {
|
|
|
109
116
|
baseUrl: this.config.baseUrl,
|
|
110
117
|
credentials: this.config.credentials,
|
|
111
118
|
token: this.config.token,
|
|
119
|
+
tokenProvider: this.config.tokenProvider,
|
|
112
120
|
headers: this.config.headers,
|
|
113
121
|
})
|
|
114
122
|
|
|
@@ -116,12 +124,13 @@ export class RoboSystemsClients {
|
|
|
116
124
|
baseUrl: this.config.baseUrl,
|
|
117
125
|
credentials: this.config.credentials,
|
|
118
126
|
token: this.config.token,
|
|
127
|
+
tokenProvider: this.config.tokenProvider,
|
|
119
128
|
maxRetries: this.config.maxRetries,
|
|
120
129
|
retryDelay: this.config.retryDelay,
|
|
121
130
|
})
|
|
122
131
|
|
|
123
|
-
// LedgerClient / InvestorClient use GraphQL internally
|
|
124
|
-
//
|
|
132
|
+
// LedgerClient / InvestorClient / LibraryClient use GraphQL internally
|
|
133
|
+
// and consult the tokenProvider on every request.
|
|
125
134
|
this.ledger = new LedgerClient({
|
|
126
135
|
baseUrl: this.config.baseUrl,
|
|
127
136
|
credentials: this.config.credentials,
|
|
@@ -171,6 +180,7 @@ export class RoboSystemsClients {
|
|
|
171
180
|
baseUrl: this.config.baseUrl,
|
|
172
181
|
credentials: this.config.credentials,
|
|
173
182
|
token: this.config.token,
|
|
183
|
+
tokenProvider: this.config.tokenProvider,
|
|
174
184
|
headers: this.config.headers,
|
|
175
185
|
maxRetries: this.config.maxRetries,
|
|
176
186
|
retryDelay: this.config.retryDelay,
|