@aipoch/open-science 0.0.0-stage → 1.0.2
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/CLI.md +814 -0
- package/LICENSE +201 -0
- package/README.md +284 -2
- package/appimage-launcher.mjs +90 -0
- package/browser-import.d.ts +66 -0
- package/browser-import.mjs +389 -0
- package/cli.mjs +1845 -0
- package/codex-login.mjs +298 -0
- package/config-root.mjs +65 -0
- package/index.d.ts +677 -0
- package/index.mjs +761 -0
- package/installation-startup.cjs +205 -0
- package/locate-app.mjs +95 -0
- package/locate-backend.mjs +63 -0
- package/package.json +45 -4
- package/runtime-control.mjs +51 -0
- package/runtime-package.mjs +19 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
|
|
3
|
+
import { findServiceState, readWebToken } from './config-root.mjs'
|
|
4
|
+
|
|
5
|
+
const defaultSleep = (milliseconds) =>
|
|
6
|
+
new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))
|
|
7
|
+
|
|
8
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
|
9
|
+
const DEFAULT_EVENT_IDLE_TIMEOUT_MS = 30_000
|
|
10
|
+
const EVENT_RECONNECT_DELAY_MS = 100
|
|
11
|
+
const MAX_BUFFERED_EVENTS = 1_024
|
|
12
|
+
const TASK_EVENT_STREAM_PROTOCOL_VERSION = 1
|
|
13
|
+
const NORMAL_EVENT_STREAM_CLOSE_CODES = new Set([1000])
|
|
14
|
+
const FAILED_EVENT_STREAM_CLOSE_CODES = new Set([1002, 1003, 1007, 1008, 1009])
|
|
15
|
+
|
|
16
|
+
export class OpenScienceApiError extends Error {
|
|
17
|
+
constructor(message, { code = 'request_failed', status } = {}) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'OpenScienceApiError'
|
|
20
|
+
this.code = code
|
|
21
|
+
this.status = status
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const resolveRequestTimeout = (defaultTimeoutMs, timeoutMs) => {
|
|
26
|
+
const resolved = timeoutMs ?? defaultTimeoutMs
|
|
27
|
+
if (!Number.isFinite(resolved) || resolved <= 0) {
|
|
28
|
+
throw new TypeError('timeoutMs must be a positive number.')
|
|
29
|
+
}
|
|
30
|
+
return resolved
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const createRequestLifecycle = ({ defaultTimeoutMs, signal, timeoutMs }) => {
|
|
34
|
+
signal?.throwIfAborted()
|
|
35
|
+
const resolvedTimeoutMs = resolveRequestTimeout(defaultTimeoutMs, timeoutMs)
|
|
36
|
+
const timeoutController = new AbortController()
|
|
37
|
+
const timeoutError = new OpenScienceApiError(
|
|
38
|
+
`Open-Science request timed out after ${resolvedTimeoutMs} milliseconds.`,
|
|
39
|
+
{ code: 'timeout' }
|
|
40
|
+
)
|
|
41
|
+
const timeout = setTimeout(() => timeoutController.abort(timeoutError), resolvedTimeoutMs)
|
|
42
|
+
const requestSignal = signal
|
|
43
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
44
|
+
: timeoutController.signal
|
|
45
|
+
let finalized = false
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
signal: requestSignal,
|
|
49
|
+
finalize() {
|
|
50
|
+
if (finalized) return
|
|
51
|
+
finalized = true
|
|
52
|
+
clearTimeout(timeout)
|
|
53
|
+
},
|
|
54
|
+
normalizeError(error) {
|
|
55
|
+
if (signal?.aborted) return signal.reason
|
|
56
|
+
if (timeoutController.signal.aborted) return timeoutError
|
|
57
|
+
return error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const withRequestTimeout = async (options, operation) => {
|
|
63
|
+
const lifecycle = createRequestLifecycle(options)
|
|
64
|
+
try {
|
|
65
|
+
return await operation(lifecycle.signal)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw lifecycle.normalizeError(error)
|
|
68
|
+
} finally {
|
|
69
|
+
lifecycle.finalize()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const wrapResponseBodyLifecycle = (response, lifecycle) => {
|
|
74
|
+
if (!response.body) {
|
|
75
|
+
lifecycle.finalize()
|
|
76
|
+
return response
|
|
77
|
+
}
|
|
78
|
+
const reader = response.body.getReader()
|
|
79
|
+
const body = new ReadableStream({
|
|
80
|
+
async pull(controller) {
|
|
81
|
+
try {
|
|
82
|
+
const chunk = await reader.read()
|
|
83
|
+
if (chunk.done) {
|
|
84
|
+
lifecycle.finalize()
|
|
85
|
+
controller.close()
|
|
86
|
+
} else {
|
|
87
|
+
controller.enqueue(chunk.value)
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
lifecycle.finalize()
|
|
91
|
+
controller.error(lifecycle.normalizeError(error))
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
async cancel(reason) {
|
|
95
|
+
try {
|
|
96
|
+
await reader.cancel(reason)
|
|
97
|
+
} finally {
|
|
98
|
+
lifecycle.finalize()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
const wrapped = new Response(body, {
|
|
103
|
+
status: response.status,
|
|
104
|
+
statusText: response.statusText,
|
|
105
|
+
headers: response.headers
|
|
106
|
+
})
|
|
107
|
+
Object.defineProperties(wrapped, {
|
|
108
|
+
redirected: { value: response.redirected },
|
|
109
|
+
type: { value: response.type },
|
|
110
|
+
url: { value: response.url }
|
|
111
|
+
})
|
|
112
|
+
return wrapped
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sleepWithSignal = async (sleep, milliseconds, signal) => {
|
|
116
|
+
signal?.throwIfAborted()
|
|
117
|
+
if (!signal) {
|
|
118
|
+
await sleep(milliseconds)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
let settled = false
|
|
123
|
+
const finish = (callback, value) => {
|
|
124
|
+
if (settled) return
|
|
125
|
+
settled = true
|
|
126
|
+
signal.removeEventListener('abort', abort)
|
|
127
|
+
callback(value)
|
|
128
|
+
}
|
|
129
|
+
const abort = () => finish(reject, signal.reason)
|
|
130
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
131
|
+
Promise.resolve()
|
|
132
|
+
.then(() => sleep(milliseconds))
|
|
133
|
+
.then(
|
|
134
|
+
() => finish(resolve),
|
|
135
|
+
(error) => finish(reject, error)
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class OpenScienceClient {
|
|
141
|
+
constructor({
|
|
142
|
+
baseUrl,
|
|
143
|
+
token,
|
|
144
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
145
|
+
sleep = defaultSleep,
|
|
146
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS
|
|
147
|
+
}) {
|
|
148
|
+
if (!baseUrl) throw new Error('Open-Science baseUrl is required.')
|
|
149
|
+
if (!token) throw new Error('Open-Science token is required.')
|
|
150
|
+
if (!fetchImpl) throw new Error('A Fetch implementation is required.')
|
|
151
|
+
resolveRequestTimeout(requestTimeoutMs)
|
|
152
|
+
this.baseUrl = baseUrl.replace(/\/$/, '')
|
|
153
|
+
this.token = token
|
|
154
|
+
this.fetch = fetchImpl
|
|
155
|
+
this.sleep = sleep
|
|
156
|
+
this.requestTimeoutMs = requestTimeoutMs
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async health(options = {}) {
|
|
160
|
+
return withRequestTimeout(
|
|
161
|
+
{
|
|
162
|
+
defaultTimeoutMs: this.requestTimeoutMs,
|
|
163
|
+
signal: options.signal,
|
|
164
|
+
timeoutMs: options.timeoutMs
|
|
165
|
+
},
|
|
166
|
+
async (signal) => {
|
|
167
|
+
const response = await this.fetch(`${this.baseUrl}/api/bootstrap`, {
|
|
168
|
+
headers: { authorization: `Bearer ${this.token}`, accept: 'application/json' },
|
|
169
|
+
signal
|
|
170
|
+
})
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new OpenScienceApiError('Open-Science is not running.', {
|
|
173
|
+
code: 'daemon_unavailable',
|
|
174
|
+
status: response.status
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
return await response.json()
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
bootstrap(request, options) {
|
|
183
|
+
return this.request('/api/v1/bootstrap', { ...options, method: 'POST', body: request })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
installCli(options) {
|
|
187
|
+
return this.request('/api/v1/cli/install', { ...options, method: 'POST' })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
doctor(options) {
|
|
191
|
+
return this.request('/api/v1/doctor', { ...options, method: 'GET' })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
listRuntimes(options) {
|
|
195
|
+
return this.request('/api/v1/runtimes', { ...options, method: 'GET' })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
listConnectors(options) {
|
|
199
|
+
return this.request(`/api/v1/connectors`, { ...options, method: 'GET' })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
getConnector(id, options) {
|
|
203
|
+
return this.request(`/api/v1/connectors/${encodeURIComponent(id)}`, {
|
|
204
|
+
...options,
|
|
205
|
+
method: 'GET'
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
setConnectorEnabled(id, enabled, options) {
|
|
210
|
+
return this.request(`/api/v1/connectors/${encodeURIComponent(id)}/enabled`, {
|
|
211
|
+
...options,
|
|
212
|
+
method: 'PUT',
|
|
213
|
+
body: { enabled }
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
addConnector(request, options) {
|
|
218
|
+
return this.request(`/api/v1/connectors`, { ...options, method: 'POST', body: request })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
updateConnector(id, request, options) {
|
|
222
|
+
return this.request(`/api/v1/connectors/${encodeURIComponent(id)}`, {
|
|
223
|
+
...options,
|
|
224
|
+
method: 'PATCH',
|
|
225
|
+
body: request
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
removeConnector(id, options) {
|
|
230
|
+
return this.request(`/api/v1/connectors/${encodeURIComponent(id)}`, {
|
|
231
|
+
...options,
|
|
232
|
+
method: 'DELETE'
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
testConnector(id, options) {
|
|
237
|
+
return this.request(`/api/v1/connectors/${encodeURIComponent(id)}/test`, {
|
|
238
|
+
...options,
|
|
239
|
+
method: 'POST'
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
listCredentials(options) {
|
|
244
|
+
return this.request(`/api/v1/credentials`, { ...options, method: 'GET' })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
createCredential(request, options) {
|
|
248
|
+
return this.request(`/api/v1/credentials`, { ...options, method: 'POST', body: request })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
updateCredential(id, request, options) {
|
|
252
|
+
return this.request(`/api/v1/credentials/${encodeURIComponent(id)}`, {
|
|
253
|
+
...options,
|
|
254
|
+
method: 'PATCH',
|
|
255
|
+
body: request
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
listProjects(options) {
|
|
260
|
+
return this.request('/api/v1/projects', options)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
createProject({ name, description, agentContext }, options) {
|
|
264
|
+
return this.request('/api/v1/projects', {
|
|
265
|
+
...options,
|
|
266
|
+
method: 'POST',
|
|
267
|
+
body: {
|
|
268
|
+
name,
|
|
269
|
+
...(description === undefined ? {} : { description }),
|
|
270
|
+
...(agentContext === undefined ? {} : { agentContext })
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
updateProject(projectId, request, options) {
|
|
276
|
+
return this.request(`/api/v1/projects/${encodeURIComponent(projectId)}`, {
|
|
277
|
+
...options,
|
|
278
|
+
method: 'PATCH',
|
|
279
|
+
body: request
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
getProjectSessionDefaults(projectId, options) {
|
|
284
|
+
return this.request(
|
|
285
|
+
`/api/v1/projects/${encodeURIComponent(projectId)}/session-defaults`,
|
|
286
|
+
options
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
updateProjectSessionDefaults(projectId, request, options) {
|
|
291
|
+
return this.request(`/api/v1/projects/${encodeURIComponent(projectId)}/session-defaults`, {
|
|
292
|
+
...options,
|
|
293
|
+
method: 'PATCH',
|
|
294
|
+
body: request
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
listSessions(projectId, options) {
|
|
299
|
+
const query = projectId ? `?project=${encodeURIComponent(projectId)}` : ''
|
|
300
|
+
return this.request(`/api/v1/sessions${query}`, options)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
getSession(sessionId, options) {
|
|
304
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, options)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getSessionConfiguration(sessionId, options) {
|
|
308
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, options)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
updateSessionConfiguration(sessionId, request, options) {
|
|
312
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, {
|
|
313
|
+
...options,
|
|
314
|
+
method: 'PATCH',
|
|
315
|
+
body: request
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
getAgentRouting(options) {
|
|
320
|
+
return this.request('/api/v1/settings/agent-routing', options)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
updateAgentRouting(request, options) {
|
|
324
|
+
return this.request('/api/v1/settings/agent-routing', {
|
|
325
|
+
...options,
|
|
326
|
+
method: 'PATCH',
|
|
327
|
+
body: request
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
getSessionPlan(sessionId, options) {
|
|
332
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/plan`, options)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
respondSessionPlan(sessionId, response, options) {
|
|
336
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/plan/respond`, {
|
|
337
|
+
...options,
|
|
338
|
+
method: 'POST',
|
|
339
|
+
body: response
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async startRun(request, options) {
|
|
344
|
+
if (request.permissionPrompts === 'none') {
|
|
345
|
+
const bootstrap = await this.health(options)
|
|
346
|
+
if (!bootstrap.taskCapabilities?.includes('permission-prompts-none')) {
|
|
347
|
+
throw new OpenScienceApiError(
|
|
348
|
+
'This Open-Science server does not support unattended permission handling. Update the server before starting this run.',
|
|
349
|
+
{ code: 'unsupported_capability' }
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return this.request('/api/v1/runs', { ...options, method: 'POST', body: request })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
getRun(runId, options) {
|
|
357
|
+
return this.request(`/api/v1/runs/${encodeURIComponent(runId)}`, options)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
cancelRun(runId, options) {
|
|
361
|
+
return this.request(`/api/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
362
|
+
...options,
|
|
363
|
+
method: 'POST'
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async waitForRun(
|
|
368
|
+
runId,
|
|
369
|
+
{ pollIntervalMs = 250, returnOnAttention = false, signal, timeoutMs } = {}
|
|
370
|
+
) {
|
|
371
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
372
|
+
throw new TypeError('timeoutMs must be a positive number.')
|
|
373
|
+
}
|
|
374
|
+
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
|
|
375
|
+
throw new TypeError('pollIntervalMs must be a positive number.')
|
|
376
|
+
}
|
|
377
|
+
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs
|
|
378
|
+
for (;;) {
|
|
379
|
+
signal?.throwIfAborted()
|
|
380
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
381
|
+
throw new OpenScienceApiError(`Timed out waiting for run ${runId}.`, { code: 'timeout' })
|
|
382
|
+
}
|
|
383
|
+
const remainingMs = deadline === undefined ? undefined : Math.max(1, deadline - Date.now())
|
|
384
|
+
let run
|
|
385
|
+
try {
|
|
386
|
+
run = await this.getRun(runId, {
|
|
387
|
+
signal,
|
|
388
|
+
timeoutMs: Math.min(this.requestTimeoutMs, remainingMs ?? this.requestTimeoutMs)
|
|
389
|
+
})
|
|
390
|
+
} catch (error) {
|
|
391
|
+
signal?.throwIfAborted()
|
|
392
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
393
|
+
throw new OpenScienceApiError(`Timed out waiting for run ${runId}.`, { code: 'timeout' })
|
|
394
|
+
}
|
|
395
|
+
throw error
|
|
396
|
+
}
|
|
397
|
+
if (run.status !== 'running') return run
|
|
398
|
+
if (returnOnAttention && run.attention) return run
|
|
399
|
+
const sleepMs =
|
|
400
|
+
deadline === undefined
|
|
401
|
+
? pollIntervalMs
|
|
402
|
+
: Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))
|
|
403
|
+
await sleepWithSignal(this.sleep, sleepMs, signal)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
listArtifacts(sessionId, options) {
|
|
408
|
+
return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/artifacts`, options)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async downloadArtifact(artifactId, { signal, timeoutMs } = {}) {
|
|
412
|
+
const lifecycle = createRequestLifecycle({
|
|
413
|
+
defaultTimeoutMs: this.requestTimeoutMs,
|
|
414
|
+
signal,
|
|
415
|
+
timeoutMs
|
|
416
|
+
})
|
|
417
|
+
try {
|
|
418
|
+
const response = await this.fetch(
|
|
419
|
+
`${this.baseUrl}/api/v1/artifacts/${encodeURIComponent(artifactId)}/content`,
|
|
420
|
+
{
|
|
421
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
422
|
+
signal: lifecycle.signal
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
await this.throwResponseError(response)
|
|
427
|
+
}
|
|
428
|
+
return wrapResponseBodyLifecycle(response, lifecycle)
|
|
429
|
+
} catch (error) {
|
|
430
|
+
lifecycle.finalize()
|
|
431
|
+
throw lifecycle.normalizeError(error)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
events({
|
|
436
|
+
idleTimeoutMs = DEFAULT_EVENT_IDLE_TIMEOUT_MS,
|
|
437
|
+
signal,
|
|
438
|
+
WebSocket: WebSocketImpl = globalThis.WebSocket
|
|
439
|
+
} = {}) {
|
|
440
|
+
if (!WebSocketImpl) throw new Error('A WebSocket implementation is required.')
|
|
441
|
+
signal?.throwIfAborted()
|
|
442
|
+
resolveRequestTimeout(DEFAULT_EVENT_IDLE_TIMEOUT_MS, idleTimeoutMs)
|
|
443
|
+
const clientId = `sdk-${globalThis.crypto.randomUUID()}`
|
|
444
|
+
const queue = []
|
|
445
|
+
const waiters = []
|
|
446
|
+
let finished = false
|
|
447
|
+
let failure
|
|
448
|
+
let socket
|
|
449
|
+
let resolveReady
|
|
450
|
+
let rejectReady
|
|
451
|
+
let readySettled = false
|
|
452
|
+
let streamId
|
|
453
|
+
let lastSequence = 0
|
|
454
|
+
let idleTimer
|
|
455
|
+
const ready = new Promise((resolve, reject) => {
|
|
456
|
+
resolveReady = resolve
|
|
457
|
+
rejectReady = reject
|
|
458
|
+
})
|
|
459
|
+
// Iteration without awaiting `ready` still receives the same terminal error through `next()`;
|
|
460
|
+
// keep the parallel readiness promise from becoming an unhandled rejection in that usage.
|
|
461
|
+
void ready.catch(() => undefined)
|
|
462
|
+
|
|
463
|
+
const flush = () => {
|
|
464
|
+
while (waiters.length && queue.length) waiters.shift().resolve(queue.shift())
|
|
465
|
+
if (!finished || !waiters.length) return
|
|
466
|
+
for (const waiter of waiters.splice(0)) {
|
|
467
|
+
if (failure) waiter.reject(failure)
|
|
468
|
+
else waiter.resolve(undefined)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const settleReady = (error) => {
|
|
472
|
+
if (readySettled) return
|
|
473
|
+
readySettled = true
|
|
474
|
+
if (error) rejectReady(error)
|
|
475
|
+
else resolveReady()
|
|
476
|
+
}
|
|
477
|
+
const finish = ({ error, closeSocket = false, discardQueue = false } = {}) => {
|
|
478
|
+
if (finished) return
|
|
479
|
+
finished = true
|
|
480
|
+
failure = error
|
|
481
|
+
clearTimeout(idleTimer)
|
|
482
|
+
if (discardQueue) queue.splice(0)
|
|
483
|
+
if (!readySettled) {
|
|
484
|
+
settleReady(
|
|
485
|
+
error ??
|
|
486
|
+
new OpenScienceApiError('Open-Science event stream closed before it was ready.', {
|
|
487
|
+
code: 'event_stream_failed'
|
|
488
|
+
})
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
signal?.removeEventListener('abort', abort)
|
|
492
|
+
if (closeSocket) socket?.close()
|
|
493
|
+
flush()
|
|
494
|
+
}
|
|
495
|
+
const fail = (message, code) => {
|
|
496
|
+
finish({
|
|
497
|
+
error: new OpenScienceApiError(message, { code }),
|
|
498
|
+
closeSocket: true,
|
|
499
|
+
discardQueue: true
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
const armIdleTimeout = () => {
|
|
503
|
+
clearTimeout(idleTimer)
|
|
504
|
+
idleTimer = setTimeout(
|
|
505
|
+
() =>
|
|
506
|
+
fail(
|
|
507
|
+
`Open-Science event stream timed out after ${idleTimeoutMs} milliseconds.`,
|
|
508
|
+
'timeout'
|
|
509
|
+
),
|
|
510
|
+
idleTimeoutMs
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
const enqueue = (event) => {
|
|
514
|
+
if (queue.length >= MAX_BUFFERED_EVENTS) {
|
|
515
|
+
fail(
|
|
516
|
+
'Open-Science event stream exceeded its buffered event limit.',
|
|
517
|
+
'event_stream_overflow'
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
queue.push(event)
|
|
522
|
+
flush()
|
|
523
|
+
}
|
|
524
|
+
const scheduleReconnect = () => {
|
|
525
|
+
void sleepWithSignal(this.sleep, EVENT_RECONNECT_DELAY_MS, signal).then(
|
|
526
|
+
() => {
|
|
527
|
+
if (!finished) connect()
|
|
528
|
+
},
|
|
529
|
+
(error) => {
|
|
530
|
+
if (!finished) finish({ error, discardQueue: true })
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
const connect = () => {
|
|
535
|
+
const endpoint = new URL('/api/v1/events', this.baseUrl)
|
|
536
|
+
endpoint.protocol = endpoint.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
537
|
+
endpoint.searchParams.set('token', this.token)
|
|
538
|
+
endpoint.searchParams.set('client', clientId)
|
|
539
|
+
endpoint.searchParams.set('liveness', '1')
|
|
540
|
+
endpoint.searchParams.set('eventProtocol', String(TASK_EVENT_STREAM_PROTOCOL_VERSION))
|
|
541
|
+
if (streamId !== undefined) {
|
|
542
|
+
endpoint.searchParams.set('stream', streamId)
|
|
543
|
+
endpoint.searchParams.set('after', String(lastSequence))
|
|
544
|
+
}
|
|
545
|
+
const current = new WebSocketImpl(endpoint)
|
|
546
|
+
socket = current
|
|
547
|
+
let connectionOpened = false
|
|
548
|
+
current.addEventListener('message', (event) => {
|
|
549
|
+
if (finished || socket !== current) return
|
|
550
|
+
armIdleTimeout()
|
|
551
|
+
let parsed
|
|
552
|
+
try {
|
|
553
|
+
parsed = JSON.parse(String(event.data))
|
|
554
|
+
} catch {
|
|
555
|
+
fail(
|
|
556
|
+
'Open-Science event stream returned an invalid message.',
|
|
557
|
+
'event_stream_invalid_message'
|
|
558
|
+
)
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
if (parsed?.type === 'connection.heartbeat') return
|
|
562
|
+
if (parsed?.type === 'stream.ready') {
|
|
563
|
+
if (
|
|
564
|
+
parsed.data?.protocolVersion !== TASK_EVENT_STREAM_PROTOCOL_VERSION ||
|
|
565
|
+
typeof parsed.data?.streamId !== 'string' ||
|
|
566
|
+
!Number.isSafeInteger(parsed.data?.latestSequence)
|
|
567
|
+
) {
|
|
568
|
+
fail(
|
|
569
|
+
'Open-Science event stream returned an invalid message.',
|
|
570
|
+
'event_stream_invalid_message'
|
|
571
|
+
)
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
streamId = parsed.data.streamId
|
|
575
|
+
lastSequence = parsed.data.latestSequence
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
if (parsed?.type === 'stream.resync-required') {
|
|
579
|
+
if (
|
|
580
|
+
parsed.data?.protocolVersion !== TASK_EVENT_STREAM_PROTOCOL_VERSION ||
|
|
581
|
+
typeof parsed.data?.streamId !== 'string' ||
|
|
582
|
+
!Number.isSafeInteger(parsed.data?.latestSequence)
|
|
583
|
+
) {
|
|
584
|
+
fail(
|
|
585
|
+
'Open-Science event stream returned an invalid message.',
|
|
586
|
+
'event_stream_invalid_message'
|
|
587
|
+
)
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
streamId = parsed.data.streamId
|
|
591
|
+
lastSequence = parsed.data.latestSequence
|
|
592
|
+
enqueue(parsed)
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (Number.isSafeInteger(parsed?.sequence)) {
|
|
596
|
+
if (parsed.sequence <= lastSequence) return
|
|
597
|
+
lastSequence = parsed.sequence
|
|
598
|
+
}
|
|
599
|
+
enqueue(parsed)
|
|
600
|
+
})
|
|
601
|
+
current.addEventListener('open', () => {
|
|
602
|
+
if (finished || socket !== current) return
|
|
603
|
+
connectionOpened = true
|
|
604
|
+
armIdleTimeout()
|
|
605
|
+
settleReady()
|
|
606
|
+
})
|
|
607
|
+
current.addEventListener('error', () => {
|
|
608
|
+
if (finished || socket !== current) return
|
|
609
|
+
if (!readySettled) {
|
|
610
|
+
fail('Open-Science event stream failed.', 'event_stream_failed')
|
|
611
|
+
} else {
|
|
612
|
+
current.close()
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
current.addEventListener('close', (event) => {
|
|
616
|
+
if (finished || socket !== current) return
|
|
617
|
+
if (!connectionOpened && !readySettled) {
|
|
618
|
+
finish({
|
|
619
|
+
error: new OpenScienceApiError(
|
|
620
|
+
'Open-Science event stream closed before it was ready.',
|
|
621
|
+
{ code: 'event_stream_failed' }
|
|
622
|
+
),
|
|
623
|
+
discardQueue: true
|
|
624
|
+
})
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
if (NORMAL_EVENT_STREAM_CLOSE_CODES.has(event.code)) {
|
|
628
|
+
finish()
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
if (FAILED_EVENT_STREAM_CLOSE_CODES.has(event.code)) {
|
|
632
|
+
finish({
|
|
633
|
+
error: new OpenScienceApiError(
|
|
634
|
+
event.code === 1008
|
|
635
|
+
? 'Open-Science event stream access was revoked.'
|
|
636
|
+
: 'Open-Science event stream closed permanently.',
|
|
637
|
+
{ code: 'event_stream_failed' }
|
|
638
|
+
),
|
|
639
|
+
discardQueue: true
|
|
640
|
+
})
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
scheduleReconnect()
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
const abort = () => {
|
|
647
|
+
if (!readySettled) {
|
|
648
|
+
finish({ error: signal.reason, closeSocket: true, discardQueue: true })
|
|
649
|
+
} else {
|
|
650
|
+
finish({ closeSocket: true })
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
654
|
+
armIdleTimeout()
|
|
655
|
+
connect()
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
ready,
|
|
659
|
+
[Symbol.asyncIterator]() {
|
|
660
|
+
return this
|
|
661
|
+
},
|
|
662
|
+
async next() {
|
|
663
|
+
if (queue.length) return { value: queue.shift(), done: false }
|
|
664
|
+
if (finished) {
|
|
665
|
+
if (failure) throw failure
|
|
666
|
+
return { value: undefined, done: true }
|
|
667
|
+
}
|
|
668
|
+
const value = await new Promise((resolve, reject) => waiters.push({ resolve, reject }))
|
|
669
|
+
return value === undefined ? { value: undefined, done: true } : { value, done: false }
|
|
670
|
+
},
|
|
671
|
+
async return() {
|
|
672
|
+
finish({ closeSocket: true })
|
|
673
|
+
return { value: undefined, done: true }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async request(path, { method = 'GET', body, idempotencyKey, signal, timeoutMs } = {}) {
|
|
679
|
+
const headers = {
|
|
680
|
+
authorization: `Bearer ${this.token}`,
|
|
681
|
+
accept: 'application/json'
|
|
682
|
+
}
|
|
683
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
684
|
+
if (idempotencyKey !== undefined) headers['idempotency-key'] = idempotencyKey
|
|
685
|
+
return withRequestTimeout(
|
|
686
|
+
{ defaultTimeoutMs: this.requestTimeoutMs, signal, timeoutMs },
|
|
687
|
+
async (requestSignal) => {
|
|
688
|
+
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
689
|
+
method,
|
|
690
|
+
headers,
|
|
691
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
692
|
+
signal: requestSignal
|
|
693
|
+
})
|
|
694
|
+
if (!response.ok) await this.throwResponseError(response)
|
|
695
|
+
const payload = await response.json()
|
|
696
|
+
return payload.data
|
|
697
|
+
}
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async throwResponseError(response) {
|
|
702
|
+
let error
|
|
703
|
+
try {
|
|
704
|
+
error = (await response.json()).error
|
|
705
|
+
} catch {
|
|
706
|
+
error = undefined
|
|
707
|
+
}
|
|
708
|
+
throw new OpenScienceApiError(
|
|
709
|
+
error?.message ?? `Open-Science request failed (${response.status}).`,
|
|
710
|
+
{
|
|
711
|
+
code: error?.code,
|
|
712
|
+
status: response.status
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export const connectToOpenScience = async ({
|
|
719
|
+
configRoot,
|
|
720
|
+
env,
|
|
721
|
+
fetch,
|
|
722
|
+
requestTimeoutMs,
|
|
723
|
+
signal
|
|
724
|
+
} = {}) => {
|
|
725
|
+
let client
|
|
726
|
+
let lastError
|
|
727
|
+
await findServiceState({
|
|
728
|
+
override: configRoot,
|
|
729
|
+
env,
|
|
730
|
+
accept: async (state) => {
|
|
731
|
+
signal?.throwIfAborted()
|
|
732
|
+
try {
|
|
733
|
+
const candidate = new OpenScienceClient({
|
|
734
|
+
baseUrl: `http://127.0.0.1:${state.port}`,
|
|
735
|
+
token: await readWebToken(state.configRoot),
|
|
736
|
+
fetch,
|
|
737
|
+
requestTimeoutMs
|
|
738
|
+
})
|
|
739
|
+
await candidate.health({ signal })
|
|
740
|
+
client = candidate
|
|
741
|
+
return true
|
|
742
|
+
} catch (error) {
|
|
743
|
+
signal?.throwIfAborted()
|
|
744
|
+
// A stale candidate must not hide an earlier authorization or configuration failure.
|
|
745
|
+
const transportCode = error?.cause?.code ?? error?.code
|
|
746
|
+
if (transportCode !== 'ECONNREFUSED') lastError = error
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
if (!client) {
|
|
752
|
+
if (lastError) throw lastError
|
|
753
|
+
throw new OpenScienceApiError(
|
|
754
|
+
'Open-Science is not running. Start it with "open-science start".',
|
|
755
|
+
{
|
|
756
|
+
code: 'daemon_unavailable'
|
|
757
|
+
}
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
return client
|
|
761
|
+
}
|