@morscherlab/mint-sdk 1.1.24 → 1.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DyPAUCLw.js} +3 -3
- package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DyPAUCLw.js.map} +1 -1
- package/dist/{SettingsModal-DoS2fzyz.js → SettingsModal-BgKY93zh.js} +5 -4
- package/dist/SettingsModal-BgKY93zh.js.map +1 -0
- package/dist/__tests__/composables/useEventStream.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +29 -10
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/{components-CW3hE1T3.js → components-DkHddRMl.js} +39 -40
- package/dist/components-DkHddRMl.js.map +1 -0
- package/dist/composables/index.d.ts +2 -3
- package/dist/composables/index.js +8 -8
- package/dist/composables/useEventStream.d.ts +43 -0
- package/dist/composables/useGeneratedAnalysis.d.ts +1 -3
- package/dist/composables/useJobsStatusTray.d.ts +1 -25
- package/dist/composables/usePluginClient.d.ts +3 -18
- package/dist/{composables-CkXDe7nm.js → composables-C_i90sGS.js} +32 -308
- package/dist/composables-C_i90sGS.js.map +1 -0
- package/dist/{experiment-Y9Iz7KYi.js → experiment-Cq145oEW.js} +2 -2
- package/dist/{experiment-Y9Iz7KYi.js.map → experiment-Cq145oEW.js.map} +1 -1
- package/dist/index.js +12 -12
- package/dist/install.js +4 -4
- package/dist/stores/auth.d.ts +0 -6
- package/dist/stores/index.js +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/{templates-CovvRZ-9.js → templates-DsP51i-S.js} +2 -2
- package/dist/{templates-CovvRZ-9.js.map → templates-DsP51i-S.js.map} +1 -1
- package/dist/types/auth.d.ts +0 -1
- package/dist/{useApi-CDBq6z23.js → useApi-J6zFkHmn.js} +1 -2
- package/dist/useApi-J6zFkHmn.js.map +1 -0
- package/dist/{useControlSchema-BhoJzWzx.js → useControlSchema-BDx2Olha.js} +239 -103
- package/dist/useControlSchema-BDx2Olha.js.map +1 -0
- package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-CV4iNfMS.js} +2 -2
- package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-CV4iNfMS.js.map} +1 -1
- package/dist/{useFormBuilder-DiEcDis2.js → useFormBuilder-BLxG3BOX.js} +2 -2
- package/dist/{useFormBuilder-DiEcDis2.js.map → useFormBuilder-BLxG3BOX.js.map} +1 -1
- package/dist/{useProtocolTemplates-DHwSveE6.js → useProtocolTemplates-CS_SQl6U.js} +22 -113
- package/dist/useProtocolTemplates-CS_SQl6U.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/GeneratedPluginView.test.ts +0 -5
- package/src/__tests__/components/JobsStatusTray.test.ts +43 -19
- package/src/__tests__/composables/useAuth.test.ts +23 -0
- package/src/__tests__/composables/useEventStream.test.ts +140 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +103 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +25 -161
- package/src/__tests__/composables/usePluginClient.test.ts +8 -13
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +22 -1
- package/src/components/JobsStatusTray.story.vue +16 -10
- package/src/components/JobsStatusTray.vue +22 -28
- package/src/components/SettingsModal.schema.ts +3 -0
- package/src/composables/index.ts +0 -7
- package/src/composables/useAuth.ts +0 -3
- package/src/composables/useEventStream.ts +314 -0
- package/src/composables/useGeneratedAnalysis.ts +40 -109
- package/src/composables/useJobsStatusTray.ts +17 -184
- package/src/composables/usePluginClient.ts +45 -249
- package/src/stores/auth.ts +0 -1
- package/src/types/auth.ts +0 -1
- package/dist/SettingsModal-DoS2fzyz.js.map +0 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
- package/dist/components-CW3hE1T3.js.map +0 -1
- package/dist/composables/usePluginConfig.d.ts +0 -25
- package/dist/composables-CkXDe7nm.js.map +0 -1
- package/dist/useApi-CDBq6z23.js.map +0 -1
- package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
- package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
- package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
- package/src/composables/usePluginConfig.ts +0 -250
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentInstance,
|
|
3
|
+
getCurrentScope,
|
|
4
|
+
onMounted,
|
|
5
|
+
onScopeDispose,
|
|
6
|
+
ref,
|
|
7
|
+
shallowRef,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from 'vue'
|
|
10
|
+
|
|
11
|
+
export type EventStreamTransport = (
|
|
12
|
+
input: RequestInfo | URL,
|
|
13
|
+
init?: RequestInit,
|
|
14
|
+
) => Promise<Response>
|
|
15
|
+
|
|
16
|
+
export interface EventStreamMessage<TData = string> {
|
|
17
|
+
event: string
|
|
18
|
+
data: TData
|
|
19
|
+
id?: string
|
|
20
|
+
retry?: number
|
|
21
|
+
raw: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EventStreamOptions<TData = string> {
|
|
25
|
+
transport?: EventStreamTransport
|
|
26
|
+
headers?: HeadersInit
|
|
27
|
+
credentials?: RequestCredentials
|
|
28
|
+
immediate?: boolean
|
|
29
|
+
reconnect?: boolean
|
|
30
|
+
reconnectDelayMs?: number
|
|
31
|
+
parseJson?: boolean
|
|
32
|
+
shouldReconnect?: (cause?: unknown) => boolean
|
|
33
|
+
onOpen?: () => void
|
|
34
|
+
onMessage?: (message: EventStreamMessage<TData>) => void
|
|
35
|
+
onError?: (cause: unknown) => void
|
|
36
|
+
onClose?: (cause?: unknown) => void
|
|
37
|
+
errorLabel?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseEventStreamReturn<TData = string> {
|
|
41
|
+
isConnected: Ref<boolean>
|
|
42
|
+
isConnecting: Ref<boolean>
|
|
43
|
+
error: Ref<string | null>
|
|
44
|
+
lastMessage: Ref<EventStreamMessage<TData> | null>
|
|
45
|
+
lastEventId: Ref<string | null>
|
|
46
|
+
start: () => void
|
|
47
|
+
stop: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ParsedEventStreamBlock<TData> {
|
|
51
|
+
message?: EventStreamMessage<TData>
|
|
52
|
+
id?: string
|
|
53
|
+
retry?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class EventStreamResponseError extends Error {
|
|
57
|
+
constructor(readonly response: Response, label: string) {
|
|
58
|
+
super(`${label} failed with HTTP ${response.status}`)
|
|
59
|
+
this.name = 'EventStreamResponseError'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Parse one complete SSE block without owning connection policy. */
|
|
64
|
+
export function parseEventStreamBlock<TData>(
|
|
65
|
+
block: string,
|
|
66
|
+
parseJson: boolean,
|
|
67
|
+
): ParsedEventStreamBlock<TData> | null {
|
|
68
|
+
let event = 'message'
|
|
69
|
+
let id: string | undefined
|
|
70
|
+
let retry: number | undefined
|
|
71
|
+
const dataLines: string[] = []
|
|
72
|
+
|
|
73
|
+
for (const line of block.split(/\r\n|\r|\n/)) {
|
|
74
|
+
if (!line || line.startsWith(':')) continue
|
|
75
|
+
const separator = line.indexOf(':')
|
|
76
|
+
const field = separator === -1 ? line : line.slice(0, separator)
|
|
77
|
+
const value = separator === -1
|
|
78
|
+
? ''
|
|
79
|
+
: line.slice(separator + 1).replace(/^ /, '')
|
|
80
|
+
|
|
81
|
+
if (field === 'event') event = value || 'message'
|
|
82
|
+
if (field === 'data') dataLines.push(value)
|
|
83
|
+
if (field === 'id' && !value.includes('\0')) id = value
|
|
84
|
+
if (field === 'retry' && /^\d+$/.test(value)) retry = Number(value)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!dataLines.length && id === undefined && retry === undefined) return null
|
|
88
|
+
const data = dataLines.join('\n')
|
|
89
|
+
return {
|
|
90
|
+
message: dataLines.length
|
|
91
|
+
? {
|
|
92
|
+
event,
|
|
93
|
+
data: (parseJson && data ? JSON.parse(data) : data) as TData,
|
|
94
|
+
id,
|
|
95
|
+
retry,
|
|
96
|
+
raw: block,
|
|
97
|
+
}
|
|
98
|
+
: undefined,
|
|
99
|
+
id,
|
|
100
|
+
retry,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Shared fetch-based SSE lifecycle with cancellation and reconnect safety. */
|
|
105
|
+
export function useEventStream<TData = string>(
|
|
106
|
+
url: string | (() => string),
|
|
107
|
+
options: EventStreamOptions<TData> = {},
|
|
108
|
+
): UseEventStreamReturn<TData> {
|
|
109
|
+
const isConnected = ref(false)
|
|
110
|
+
const isConnecting = ref(false)
|
|
111
|
+
const error = ref<string | null>(null)
|
|
112
|
+
const lastMessage = shallowRef<EventStreamMessage<TData> | null>(null)
|
|
113
|
+
const lastEventId = ref<string | null>(null)
|
|
114
|
+
let controller: AbortController | null = null
|
|
115
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
|
|
116
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
117
|
+
let reconnectDelayMs = options.reconnectDelayMs ?? 2_000
|
|
118
|
+
let stopped = true
|
|
119
|
+
let generation = 0
|
|
120
|
+
|
|
121
|
+
function clearReconnectTimer(): void {
|
|
122
|
+
if (reconnectTimer === null) return
|
|
123
|
+
clearTimeout(reconnectTimer)
|
|
124
|
+
reconnectTimer = null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ownsConnection(
|
|
128
|
+
connectionGeneration: number,
|
|
129
|
+
connectionController: AbortController,
|
|
130
|
+
): boolean {
|
|
131
|
+
return !stopped
|
|
132
|
+
&& generation === connectionGeneration
|
|
133
|
+
&& controller === connectionController
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function scheduleReconnect(connectionGeneration: number, cause?: unknown): void {
|
|
137
|
+
if (stopped || generation !== connectionGeneration) return
|
|
138
|
+
if (options.reconnect === false || options.shouldReconnect?.(cause) === false) {
|
|
139
|
+
stopped = true
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
clearReconnectTimer()
|
|
143
|
+
reconnectTimer = setTimeout(() => {
|
|
144
|
+
reconnectTimer = null
|
|
145
|
+
if (stopped || generation !== connectionGeneration) return
|
|
146
|
+
void connect(connectionGeneration)
|
|
147
|
+
}, reconnectDelayMs)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function dispatchBlock(
|
|
151
|
+
block: string,
|
|
152
|
+
connectionGeneration: number,
|
|
153
|
+
connectionController: AbortController,
|
|
154
|
+
): void {
|
|
155
|
+
const parsed = parseEventStreamBlock<TData>(block, options.parseJson ?? false)
|
|
156
|
+
if (!parsed || !ownsConnection(connectionGeneration, connectionController)) return
|
|
157
|
+
if (parsed.id !== undefined) {
|
|
158
|
+
lastEventId.value = parsed.id
|
|
159
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
160
|
+
}
|
|
161
|
+
if (parsed.retry !== undefined) reconnectDelayMs = parsed.retry
|
|
162
|
+
const message = parsed.message
|
|
163
|
+
if (!message || !ownsConnection(connectionGeneration, connectionController)) return
|
|
164
|
+
lastMessage.value = message
|
|
165
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
166
|
+
options.onMessage?.(message)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function consume(
|
|
170
|
+
body: ReadableStream<Uint8Array>,
|
|
171
|
+
connectionGeneration: number,
|
|
172
|
+
connectionController: AbortController,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
const connectionReader = body.getReader()
|
|
175
|
+
reader = connectionReader
|
|
176
|
+
const decoder = new TextDecoder()
|
|
177
|
+
let buffer = ''
|
|
178
|
+
try {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const { value, done } = await connectionReader.read()
|
|
181
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
182
|
+
buffer += decoder.decode(value, { stream: !done })
|
|
183
|
+
|
|
184
|
+
let boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
|
|
185
|
+
while (boundary?.index !== undefined) {
|
|
186
|
+
const block = buffer.slice(0, boundary.index)
|
|
187
|
+
buffer = buffer.slice(boundary.index + boundary[0].length)
|
|
188
|
+
dispatchBlock(block, connectionGeneration, connectionController)
|
|
189
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
190
|
+
boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!done) continue
|
|
194
|
+
if (buffer) dispatchBlock(buffer, connectionGeneration, connectionController)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
if (reader === connectionReader) reader = null
|
|
199
|
+
connectionReader.releaseLock()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function connect(connectionGeneration: number): Promise<void> {
|
|
204
|
+
if (stopped || generation !== connectionGeneration) return
|
|
205
|
+
controller?.abort()
|
|
206
|
+
const connectionController = new AbortController()
|
|
207
|
+
controller = connectionController
|
|
208
|
+
isConnecting.value = true
|
|
209
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
210
|
+
error.value = null
|
|
211
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
212
|
+
|
|
213
|
+
let cause: unknown
|
|
214
|
+
try {
|
|
215
|
+
const headers = new Headers(options.headers)
|
|
216
|
+
headers.set('Accept', 'text/event-stream')
|
|
217
|
+
if (lastEventId.value && !headers.has('Last-Event-ID')) {
|
|
218
|
+
headers.set('Last-Event-ID', lastEventId.value)
|
|
219
|
+
}
|
|
220
|
+
const transport = options.transport ?? globalThis.fetch?.bind(globalThis)
|
|
221
|
+
if (!transport) throw new Error('[MINT SDK] fetch is required for event streams.')
|
|
222
|
+
const response = await transport(typeof url === 'function' ? url() : url, {
|
|
223
|
+
method: 'GET',
|
|
224
|
+
headers,
|
|
225
|
+
credentials: options.credentials ?? 'same-origin',
|
|
226
|
+
signal: connectionController.signal,
|
|
227
|
+
})
|
|
228
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw new EventStreamResponseError(response, options.errorLabel ?? 'Event stream')
|
|
231
|
+
}
|
|
232
|
+
if (!response.body) {
|
|
233
|
+
throw new Error(`${options.errorLabel ?? 'Event stream'} response did not include a readable body.`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
isConnected.value = true
|
|
237
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
238
|
+
isConnecting.value = false
|
|
239
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
240
|
+
options.onOpen?.()
|
|
241
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
242
|
+
await consume(response.body, connectionGeneration, connectionController)
|
|
243
|
+
} catch (errorCause) {
|
|
244
|
+
cause = errorCause
|
|
245
|
+
if (
|
|
246
|
+
ownsConnection(connectionGeneration, connectionController)
|
|
247
|
+
&& !isAbortError(errorCause)
|
|
248
|
+
) {
|
|
249
|
+
error.value = errorCause instanceof Error
|
|
250
|
+
? errorCause.message
|
|
251
|
+
: `${options.errorLabel ?? 'Event stream'} failed`
|
|
252
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
253
|
+
options.onError?.(errorCause)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
258
|
+
isConnecting.value = false
|
|
259
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
260
|
+
isConnected.value = false
|
|
261
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
262
|
+
controller = null
|
|
263
|
+
options.onClose?.(cause)
|
|
264
|
+
if (!stopped && generation === connectionGeneration) {
|
|
265
|
+
scheduleReconnect(connectionGeneration, cause)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function start(): void {
|
|
272
|
+
if (!stopped) return
|
|
273
|
+
stopped = false
|
|
274
|
+
clearReconnectTimer()
|
|
275
|
+
generation += 1
|
|
276
|
+
void connect(generation)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stop(): void {
|
|
280
|
+
if (stopped) return
|
|
281
|
+
stopped = true
|
|
282
|
+
generation += 1
|
|
283
|
+
clearReconnectTimer()
|
|
284
|
+
controller?.abort()
|
|
285
|
+
controller = null
|
|
286
|
+
if (reader) void reader.cancel().catch(() => undefined)
|
|
287
|
+
reader = null
|
|
288
|
+
isConnecting.value = false
|
|
289
|
+
isConnected.value = false
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const currentInstance = getCurrentInstance()
|
|
293
|
+
if (currentInstance && options.immediate !== false && typeof window === 'undefined') {
|
|
294
|
+
onMounted(start)
|
|
295
|
+
}
|
|
296
|
+
if (getCurrentScope()) onScopeDispose(stop)
|
|
297
|
+
if (options.immediate !== false && (typeof window !== 'undefined' || !currentInstance)) {
|
|
298
|
+
start()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
isConnected,
|
|
303
|
+
isConnecting,
|
|
304
|
+
error,
|
|
305
|
+
lastMessage,
|
|
306
|
+
lastEventId,
|
|
307
|
+
start,
|
|
308
|
+
stop,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isAbortError(cause: unknown): boolean {
|
|
313
|
+
return cause instanceof DOMException && cause.name === 'AbortError'
|
|
314
|
+
}
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
type ShallowRef,
|
|
9
9
|
} from 'vue'
|
|
10
10
|
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
PluginJobCenterSource,
|
|
14
|
-
} from './useJobsStatusTray'
|
|
11
|
+
import type { PluginJobCenterSource } from './useJobsStatusTray'
|
|
12
|
+
import { useEventStream, type EventStreamMessage } from './useEventStream'
|
|
15
13
|
import { normalizeJobState } from '../jobs'
|
|
16
14
|
import type {
|
|
17
15
|
GeneratedAnalysisJob,
|
|
@@ -104,8 +102,6 @@ export interface UsePluginJobsReturn extends PluginJobCenterSource {
|
|
|
104
102
|
pause: () => void
|
|
105
103
|
resume: () => void
|
|
106
104
|
dispose: () => void
|
|
107
|
-
/** @deprecated Pass the runtime itself to SDK job UI as its source. */
|
|
108
|
-
jobsAdapter: JobsStatusTrayAdapter
|
|
109
105
|
}
|
|
110
106
|
|
|
111
107
|
export type UseGeneratedAnalysisReturn = UsePluginJobsReturn
|
|
@@ -125,6 +121,10 @@ interface PluginJobWaiter {
|
|
|
125
121
|
abort?: () => void
|
|
126
122
|
}
|
|
127
123
|
|
|
124
|
+
type PluginJobStreamData = GeneratedAnalysisJobInput | {
|
|
125
|
+
jobs: GeneratedAnalysisJobInput[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
128
|
class GeneratedAnalysisRequestError extends Error {
|
|
129
129
|
constructor(
|
|
130
130
|
readonly status: number,
|
|
@@ -200,10 +200,6 @@ function createPluginJobs(
|
|
|
200
200
|
const error = shallowRef<string | null>(null)
|
|
201
201
|
const status = shallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>('idle')
|
|
202
202
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
203
|
-
let streamReconnectTimer: ReturnType<typeof setTimeout> | undefined
|
|
204
|
-
let streamController: AbortController | undefined
|
|
205
|
-
let streamConnected = false
|
|
206
|
-
let streamConnecting = false
|
|
207
203
|
let disposed = false
|
|
208
204
|
let paused = false
|
|
209
205
|
let initialized = false
|
|
@@ -217,6 +213,17 @@ function createPluginJobs(
|
|
|
217
213
|
const resultRetryAttempts = new Map<string, number>()
|
|
218
214
|
const resultRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
219
215
|
const downloadRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
216
|
+
const jobStream = useEventStream<PluginJobStreamData>(`${baseUrl}/jobs/events`, {
|
|
217
|
+
transport,
|
|
218
|
+
immediate: false,
|
|
219
|
+
reconnectDelayMs: streamReconnectDelay,
|
|
220
|
+
parseJson: true,
|
|
221
|
+
errorLabel: 'Job stream',
|
|
222
|
+
onOpen: clearPoll,
|
|
223
|
+
onMessage: applyJobStreamMessage,
|
|
224
|
+
onError: schedulePollForActiveJobs,
|
|
225
|
+
onClose: schedulePollForActiveJobs,
|
|
226
|
+
})
|
|
220
227
|
|
|
221
228
|
const latestResult = computed<GeneratedAnalysisResult | null>(() => {
|
|
222
229
|
const job = currentCompletedJob()
|
|
@@ -242,7 +249,7 @@ function createPluginJobs(
|
|
|
242
249
|
if (disposed) return
|
|
243
250
|
initialized = true
|
|
244
251
|
status.value = paused ? 'paused' : 'ready'
|
|
245
|
-
if (streamEnabled && !paused)
|
|
252
|
+
if (streamEnabled && !paused) jobStream.start()
|
|
246
253
|
})().catch((cause: unknown) => {
|
|
247
254
|
if (!disposed) {
|
|
248
255
|
error.value = errorMessage(cause, 'Failed to initialize generated analysis')
|
|
@@ -303,7 +310,7 @@ function createPluginJobs(
|
|
|
303
310
|
)
|
|
304
311
|
}
|
|
305
312
|
const job = upsertJob(jobInput, definitionId)
|
|
306
|
-
if (!
|
|
313
|
+
if (!jobStream.isConnected.value) schedulePoll()
|
|
307
314
|
return job
|
|
308
315
|
} catch (cause) {
|
|
309
316
|
error.value = errorMessage(cause, 'Failed to submit analysis')
|
|
@@ -466,7 +473,7 @@ function createPluginJobs(
|
|
|
466
473
|
}
|
|
467
474
|
replaceJobs(payload.jobs)
|
|
468
475
|
error.value = null
|
|
469
|
-
if (!paused && !
|
|
476
|
+
if (!paused && !jobStream.isConnected.value && jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) {
|
|
470
477
|
schedulePoll()
|
|
471
478
|
}
|
|
472
479
|
return jobs.value
|
|
@@ -540,12 +547,12 @@ function createPluginJobs(
|
|
|
540
547
|
waiting.add(waiter)
|
|
541
548
|
jobWaiters.set(jobId, waiting)
|
|
542
549
|
if (!paused) {
|
|
543
|
-
if (streamEnabled)
|
|
544
|
-
if (!
|
|
550
|
+
if (streamEnabled) jobStream.start()
|
|
551
|
+
if (!jobStream.isConnected.value) schedulePoll()
|
|
545
552
|
}
|
|
546
553
|
void getJob(jobId, { signal: options.signal }).catch((cause: unknown) => {
|
|
547
554
|
if (current && isRetryableJobLookupError(cause)) {
|
|
548
|
-
if (!
|
|
555
|
+
if (!jobStream.isConnected.value) schedulePoll()
|
|
549
556
|
return
|
|
550
557
|
}
|
|
551
558
|
removeJobWaiter(jobId, waiter)
|
|
@@ -895,7 +902,7 @@ function createPluginJobs(
|
|
|
895
902
|
}
|
|
896
903
|
|
|
897
904
|
function schedulePoll(): void {
|
|
898
|
-
if (disposed || paused ||
|
|
905
|
+
if (disposed || paused || jobStream.isConnected.value || pollTimer !== undefined) return
|
|
899
906
|
pollTimer = setTimeout(async () => {
|
|
900
907
|
pollTimer = undefined
|
|
901
908
|
if (disposed) return
|
|
@@ -908,85 +915,26 @@ function createPluginJobs(
|
|
|
908
915
|
}, pollInterval)
|
|
909
916
|
}
|
|
910
917
|
|
|
911
|
-
|
|
912
|
-
if (
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
try {
|
|
916
|
-
const response = await transport(`${baseUrl}/jobs/events`, {
|
|
917
|
-
method: 'GET',
|
|
918
|
-
headers: { Accept: 'text/event-stream' },
|
|
919
|
-
signal: streamController.signal,
|
|
920
|
-
})
|
|
921
|
-
if (!response.ok) throw new Error(`Job stream failed (${response.status})`)
|
|
922
|
-
if (!response.body) throw new Error('Job stream response has no readable body')
|
|
923
|
-
streamConnected = true
|
|
924
|
-
if (pollTimer !== undefined) {
|
|
925
|
-
clearTimeout(pollTimer)
|
|
926
|
-
pollTimer = undefined
|
|
927
|
-
}
|
|
928
|
-
await consumeJobStream(response.body)
|
|
929
|
-
} catch (cause) {
|
|
930
|
-
if (!isAbortError(cause) && !disposed) {
|
|
931
|
-
if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
|
|
932
|
-
}
|
|
933
|
-
} finally {
|
|
934
|
-
streamConnected = false
|
|
935
|
-
streamConnecting = false
|
|
936
|
-
streamController = undefined
|
|
937
|
-
if (!disposed && !paused) scheduleStreamReconnect()
|
|
938
|
-
}
|
|
918
|
+
function clearPoll(): void {
|
|
919
|
+
if (pollTimer === undefined) return
|
|
920
|
+
clearTimeout(pollTimer)
|
|
921
|
+
pollTimer = undefined
|
|
939
922
|
}
|
|
940
923
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const decoder = new TextDecoder()
|
|
944
|
-
let buffer = ''
|
|
945
|
-
try {
|
|
946
|
-
while (!disposed) {
|
|
947
|
-
const { done, value } = await reader.read()
|
|
948
|
-
buffer += decoder.decode(value, { stream: !done })
|
|
949
|
-
const blocks = buffer.split(/\r?\n\r?\n/)
|
|
950
|
-
buffer = blocks.pop() ?? ''
|
|
951
|
-
for (const block of blocks) applyJobStreamEvent(block)
|
|
952
|
-
if (done) {
|
|
953
|
-
if (buffer.trim()) applyJobStreamEvent(buffer)
|
|
954
|
-
return
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
} finally {
|
|
958
|
-
reader.releaseLock()
|
|
959
|
-
}
|
|
924
|
+
function schedulePollForActiveJobs(): void {
|
|
925
|
+
if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
|
|
960
926
|
}
|
|
961
927
|
|
|
962
|
-
function
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
for (const line of block.split(/\r?\n/)) {
|
|
966
|
-
if (!line || line.startsWith(':')) continue
|
|
967
|
-
if (line.startsWith('event:')) event = line.slice('event:'.length).trim()
|
|
968
|
-
if (line.startsWith('data:')) data.push(line.slice('data:'.length).trimStart())
|
|
969
|
-
}
|
|
970
|
-
if (!data.length) return
|
|
971
|
-
const payload = JSON.parse(data.join('\n')) as (
|
|
972
|
-
GeneratedAnalysisJobInput | { jobs: GeneratedAnalysisJobInput[] }
|
|
973
|
-
)
|
|
974
|
-
if (event === 'snapshot' && 'jobs' in payload) {
|
|
928
|
+
function applyJobStreamMessage(message: EventStreamMessage<PluginJobStreamData>): void {
|
|
929
|
+
if (message.event === 'snapshot' && 'jobs' in message.data) {
|
|
930
|
+
const payload = message.data
|
|
975
931
|
replaceJobs(payload.jobs)
|
|
976
|
-
} else if (event === 'job' || event === 'terminal') {
|
|
977
|
-
upsertJob(
|
|
932
|
+
} else if (message.event === 'job' || message.event === 'terminal') {
|
|
933
|
+
upsertJob(message.data as GeneratedAnalysisJobInput)
|
|
978
934
|
}
|
|
979
935
|
error.value = null
|
|
980
936
|
}
|
|
981
937
|
|
|
982
|
-
function scheduleStreamReconnect(): void {
|
|
983
|
-
if (disposed || paused || streamReconnectTimer !== undefined) return
|
|
984
|
-
streamReconnectTimer = setTimeout(() => {
|
|
985
|
-
streamReconnectTimer = undefined
|
|
986
|
-
void connectJobStream()
|
|
987
|
-
}, streamReconnectDelay)
|
|
988
|
-
}
|
|
989
|
-
|
|
990
938
|
async function cancelJob(job: GeneratedAnalysisJob | string): Promise<GeneratedAnalysisJob> {
|
|
991
939
|
const jobId = typeof job === 'string' ? job : job.job_id
|
|
992
940
|
const revision = beginJobOperation(jobId)
|
|
@@ -1024,25 +972,16 @@ function createPluginJobs(
|
|
|
1024
972
|
return payload.deleted
|
|
1025
973
|
}
|
|
1026
974
|
|
|
1027
|
-
const jobsAdapter: JobsStatusTrayAdapter = {
|
|
1028
|
-
listJobs: async () => ({ jobs: await refreshJobs() }),
|
|
1029
|
-
cancelJob: async job => cancelJob(job as GeneratedAnalysisJob),
|
|
1030
|
-
deleteJob: async job => deleteJob(job as GeneratedAnalysisJob),
|
|
1031
|
-
clearFinishedJobs: async () => clearFinishedJobs(),
|
|
1032
|
-
}
|
|
1033
975
|
function pause(): void {
|
|
1034
976
|
if (disposed || paused) return
|
|
1035
977
|
paused = true
|
|
1036
978
|
status.value = 'paused'
|
|
1037
|
-
|
|
1038
|
-
if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
|
|
1039
|
-
pollTimer = undefined
|
|
1040
|
-
streamReconnectTimer = undefined
|
|
979
|
+
clearPoll()
|
|
1041
980
|
for (const jobId of resultRetryTimers.keys()) clearResultRetry(jobId)
|
|
1042
981
|
for (const jobId of downloadRefreshTimers.keys()) {
|
|
1043
982
|
clearManagedDownloadRefresh(jobId)
|
|
1044
983
|
}
|
|
1045
|
-
|
|
984
|
+
jobStream.stop()
|
|
1046
985
|
}
|
|
1047
986
|
|
|
1048
987
|
function resume(): void {
|
|
@@ -1053,7 +992,7 @@ function createPluginJobs(
|
|
|
1053
992
|
for (const [jobId, result] of Object.entries(resultCache.value)) {
|
|
1054
993
|
scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result))
|
|
1055
994
|
}
|
|
1056
|
-
if (streamEnabled)
|
|
995
|
+
if (streamEnabled) jobStream.start()
|
|
1057
996
|
else if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
|
|
1058
997
|
}
|
|
1059
998
|
|
|
@@ -1062,10 +1001,7 @@ function createPluginJobs(
|
|
|
1062
1001
|
disposed = true
|
|
1063
1002
|
paused = true
|
|
1064
1003
|
status.value = 'disposed'
|
|
1065
|
-
|
|
1066
|
-
if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
|
|
1067
|
-
pollTimer = undefined
|
|
1068
|
-
streamReconnectTimer = undefined
|
|
1004
|
+
clearPoll()
|
|
1069
1005
|
for (const timer of resultRetryTimers.values()) clearTimeout(timer)
|
|
1070
1006
|
resultRetryTimers.clear()
|
|
1071
1007
|
resultRetryAttempts.clear()
|
|
@@ -1075,7 +1011,7 @@ function createPluginJobs(
|
|
|
1075
1011
|
for (const jobId of jobWaiters.keys()) rejectJobWaiters(jobId, cause)
|
|
1076
1012
|
jobRevisions.clear()
|
|
1077
1013
|
pendingJobOperations.clear()
|
|
1078
|
-
|
|
1014
|
+
jobStream.stop()
|
|
1079
1015
|
}
|
|
1080
1016
|
|
|
1081
1017
|
async function request<T>(path: string, init: RequestInit): Promise<T> {
|
|
@@ -1129,7 +1065,6 @@ function createPluginJobs(
|
|
|
1129
1065
|
pause,
|
|
1130
1066
|
resume,
|
|
1131
1067
|
dispose,
|
|
1132
|
-
jobsAdapter,
|
|
1133
1068
|
}
|
|
1134
1069
|
}
|
|
1135
1070
|
|
|
@@ -1229,10 +1164,6 @@ function createGeneratedSessionStorage(candidate?: Storage): GeneratedSessionSto
|
|
|
1229
1164
|
}
|
|
1230
1165
|
}
|
|
1231
1166
|
|
|
1232
|
-
function isAbortError(error: unknown): boolean {
|
|
1233
|
-
return error instanceof DOMException && error.name === 'AbortError'
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
1167
|
function isSessionNotFoundError(error: unknown): boolean {
|
|
1237
1168
|
return error instanceof GeneratedAnalysisRequestError
|
|
1238
1169
|
&& error.status === 404
|