@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/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
+ }