@morscherlab/mint-sdk 1.0.51 → 1.0.52
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/dist/__tests__/components/JobsStatusTray.test.d.ts +1 -0
- package/dist/__tests__/composables/useJobsStatusTray.test.d.ts +1 -0
- package/dist/__tests__/utils/jobs.test.d.ts +1 -0
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +26 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-C7UFkNIp.js → components-Lk_h_rON.js} +1663 -1339
- package/dist/components-Lk_h_rON.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +3 -3
- package/dist/composables/useJobsStatusTray.d.ts +43 -0
- package/dist/composables/usePluginClient.d.ts +4 -1
- package/dist/{composables-CpBhNKHf.js → composables-DyfGpYZw.js} +50 -17
- package/dist/{composables-CpBhNKHf.js.map → composables-DyfGpYZw.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -4
- package/dist/install.js +1 -1
- package/dist/jobs.d.ts +19 -0
- package/dist/styles.css +731 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/jobs.d.ts +49 -0
- package/dist/{useProtocolTemplates-C8-YlHj1.js → useJobsStatusTray-DAH999_N.js} +250 -2
- package/dist/useJobsStatusTray-DAH999_N.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/JobsStatusTray.test.ts +141 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +241 -0
- package/src/__tests__/composables/usePluginClient.test.ts +243 -0
- package/src/__tests__/utils/jobs.test.ts +95 -0
- package/src/components/JobsStatusTray.story.vue +120 -0
- package/src/components/JobsStatusTray.vue +385 -0
- package/src/components/index.ts +1 -0
- package/src/composables/index.ts +8 -0
- package/src/composables/useJobsStatusTray.ts +304 -0
- package/src/composables/usePluginClient.ts +65 -15
- package/src/index.ts +3 -0
- package/src/jobs.ts +75 -0
- package/src/styles/components/jobs-status-tray.css +420 -0
- package/src/styles/index.css +1 -0
- package/src/types/index.ts +10 -0
- package/src/types/jobs.ts +61 -0
- package/dist/components-C7UFkNIp.js.map +0 -1
- package/dist/useProtocolTemplates-C8-YlHj1.js.map +0 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onMounted,
|
|
4
|
+
onUnmounted,
|
|
5
|
+
ref,
|
|
6
|
+
shallowRef,
|
|
7
|
+
toValue,
|
|
8
|
+
watch,
|
|
9
|
+
type ComputedRef,
|
|
10
|
+
type MaybeRefOrGetter,
|
|
11
|
+
type Ref,
|
|
12
|
+
type ShallowRef,
|
|
13
|
+
} from 'vue'
|
|
14
|
+
|
|
15
|
+
import { isActiveJobStatus, normalizeJobState } from '../jobs'
|
|
16
|
+
import type {
|
|
17
|
+
JobListPayload,
|
|
18
|
+
JobState,
|
|
19
|
+
JobStateInput,
|
|
20
|
+
JobStreamData,
|
|
21
|
+
} from '../types/jobs'
|
|
22
|
+
import type {
|
|
23
|
+
PluginEventStreamMessage,
|
|
24
|
+
UsePluginEventStreamReturn,
|
|
25
|
+
} from './usePluginClient'
|
|
26
|
+
|
|
27
|
+
export type JobsStatusTrayTab = 'active' | 'history'
|
|
28
|
+
|
|
29
|
+
export interface JobsStatusTrayAdapter {
|
|
30
|
+
listJobs?: () => Promise<JobListPayload | readonly JobStateInput[]>
|
|
31
|
+
cancelJob?: (job: JobState) => Promise<JobStateInput | void>
|
|
32
|
+
deleteJob?: (job: JobState) => Promise<boolean | void>
|
|
33
|
+
clearFinishedJobs?: (jobs: readonly JobState[]) => Promise<number | void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseJobsStatusTrayOptions {
|
|
37
|
+
jobs?: MaybeRefOrGetter<readonly JobStateInput[] | undefined>
|
|
38
|
+
adapter?: MaybeRefOrGetter<JobsStatusTrayAdapter | undefined>
|
|
39
|
+
eventStream?: MaybeRefOrGetter<UsePluginEventStreamReturn<JobStreamData> | undefined>
|
|
40
|
+
loadOnMount?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JobStreamMessage {
|
|
44
|
+
event: string
|
|
45
|
+
data: JobStreamData
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseJobsStatusTrayReturn {
|
|
49
|
+
jobs: ShallowRef<JobState[]>
|
|
50
|
+
activeJobs: ComputedRef<JobState[]>
|
|
51
|
+
historyJobs: ComputedRef<JobState[]>
|
|
52
|
+
deletableJobs: ComputedRef<JobState[]>
|
|
53
|
+
featuredJob: ComputedRef<JobState | null>
|
|
54
|
+
isOpen: Ref<boolean>
|
|
55
|
+
activeTab: Ref<JobsStatusTrayTab>
|
|
56
|
+
isLoading: Ref<boolean>
|
|
57
|
+
error: ShallowRef<string | null>
|
|
58
|
+
pendingJobIds: Ref<Set<string>>
|
|
59
|
+
open: () => void
|
|
60
|
+
close: () => void
|
|
61
|
+
toggle: () => void
|
|
62
|
+
refresh: () => Promise<boolean>
|
|
63
|
+
applyStreamMessage: (message: JobStreamMessage) => void
|
|
64
|
+
cancelJob: (job: JobState) => Promise<boolean>
|
|
65
|
+
deleteJob: (job: JobState) => Promise<boolean>
|
|
66
|
+
clearFinished: () => Promise<boolean>
|
|
67
|
+
isPending: (job: JobState) => boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function responseJobs(
|
|
71
|
+
response: JobListPayload | readonly JobStateInput[],
|
|
72
|
+
): readonly JobStateInput[] {
|
|
73
|
+
return 'jobs' in response ? response.jobs : response
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSnapshotPayload(data: JobStreamData): data is { jobs: JobStateInput[] } {
|
|
77
|
+
return 'jobs' in data && Array.isArray(data.jobs)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
81
|
+
return error instanceof Error ? error.message : fallback
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Headless state and action controller for the standard SDK jobs tray. */
|
|
85
|
+
export function useJobsStatusTray(
|
|
86
|
+
options: UseJobsStatusTrayOptions = {},
|
|
87
|
+
): UseJobsStatusTrayReturn {
|
|
88
|
+
const jobs = shallowRef<JobState[]>([])
|
|
89
|
+
const isOpen = ref(false)
|
|
90
|
+
const activeTab = ref<JobsStatusTrayTab>('active')
|
|
91
|
+
const isLoading = ref(false)
|
|
92
|
+
const error = shallowRef<string | null>(null)
|
|
93
|
+
const pendingJobIds = ref(new Set<string>())
|
|
94
|
+
const jobRevisions = new Map<string, number>()
|
|
95
|
+
let stateRevision = 0
|
|
96
|
+
let requestGeneration = 0
|
|
97
|
+
let active = true
|
|
98
|
+
const adapter = computed(() => toValue(options.adapter))
|
|
99
|
+
|
|
100
|
+
const activeJobs = computed(() => jobs.value.filter(job => isActiveJobStatus(job.status)))
|
|
101
|
+
const historyJobs = computed(() => jobs.value.filter(job => !isActiveJobStatus(job.status)))
|
|
102
|
+
const deletableJobs = computed(() => historyJobs.value.filter(job => job.can_delete))
|
|
103
|
+
const featuredJob = computed(() => activeJobs.value[0] ?? historyJobs.value[0] ?? null)
|
|
104
|
+
|
|
105
|
+
function bumpJobRevision(jobId: string): void {
|
|
106
|
+
jobRevisions.set(jobId, (jobRevisions.get(jobId) ?? 0) + 1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function replaceJobs(nextJobs: readonly JobStateInput[]): void {
|
|
110
|
+
const normalized = nextJobs.map(normalizeJobState)
|
|
111
|
+
const touchedJobIds = new Set([
|
|
112
|
+
...jobs.value.map(job => job.job_id),
|
|
113
|
+
...normalized.map(job => job.job_id),
|
|
114
|
+
])
|
|
115
|
+
touchedJobIds.forEach(bumpJobRevision)
|
|
116
|
+
jobs.value = normalized
|
|
117
|
+
stateRevision += 1
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function upsertJob(nextJob: JobStateInput): void {
|
|
121
|
+
const normalized = normalizeJobState(nextJob)
|
|
122
|
+
const existingIndex = jobs.value.findIndex(job => job.job_id === normalized.job_id)
|
|
123
|
+
if (existingIndex === -1) {
|
|
124
|
+
jobs.value = [normalized, ...jobs.value]
|
|
125
|
+
} else {
|
|
126
|
+
const nextJobs = [...jobs.value]
|
|
127
|
+
nextJobs[existingIndex] = normalized
|
|
128
|
+
jobs.value = nextJobs
|
|
129
|
+
}
|
|
130
|
+
bumpJobRevision(normalized.job_id)
|
|
131
|
+
stateRevision += 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function removeJobs(jobIds: Set<string>): void {
|
|
135
|
+
jobs.value = jobs.value.filter(job => !jobIds.has(job.job_id))
|
|
136
|
+
jobIds.forEach(bumpJobRevision)
|
|
137
|
+
stateRevision += 1
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function setPending(jobId: string, pending: boolean): void {
|
|
141
|
+
const next = new Set(pendingJobIds.value)
|
|
142
|
+
if (pending) next.add(jobId)
|
|
143
|
+
else next.delete(jobId)
|
|
144
|
+
pendingJobIds.value = next
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyStreamMessage(message: JobStreamMessage): void {
|
|
148
|
+
if (!active) return
|
|
149
|
+
if (message.event === 'snapshot' && isSnapshotPayload(message.data)) {
|
|
150
|
+
replaceJobs(message.data.jobs)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
(message.event === 'job' || message.event === 'terminal')
|
|
155
|
+
&& !isSnapshotPayload(message.data)
|
|
156
|
+
) {
|
|
157
|
+
upsertJob(message.data)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function refresh(): Promise<boolean> {
|
|
162
|
+
const listJobs = adapter.value?.listJobs
|
|
163
|
+
if (!listJobs) return false
|
|
164
|
+
const generation = ++requestGeneration
|
|
165
|
+
const revision = stateRevision
|
|
166
|
+
isLoading.value = true
|
|
167
|
+
error.value = null
|
|
168
|
+
try {
|
|
169
|
+
const response = await listJobs()
|
|
170
|
+
if (!active || generation !== requestGeneration || revision !== stateRevision) {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
replaceJobs(responseJobs(response))
|
|
174
|
+
return true
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
if (active && generation === requestGeneration) {
|
|
177
|
+
error.value = errorMessage(cause, 'Failed to load jobs')
|
|
178
|
+
}
|
|
179
|
+
return false
|
|
180
|
+
} finally {
|
|
181
|
+
if (active && generation === requestGeneration) isLoading.value = false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function cancelJob(job: JobState): Promise<boolean> {
|
|
186
|
+
const cancel = adapter.value?.cancelJob
|
|
187
|
+
if (!job.can_cancel || !cancel || pendingJobIds.value.has(job.job_id)) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
const revision = jobRevisions.get(job.job_id) ?? 0
|
|
191
|
+
setPending(job.job_id, true)
|
|
192
|
+
error.value = null
|
|
193
|
+
try {
|
|
194
|
+
const updated = await cancel(job)
|
|
195
|
+
if (!active) return false
|
|
196
|
+
if (revision === (jobRevisions.get(job.job_id) ?? 0)) {
|
|
197
|
+
if (updated) upsertJob(updated)
|
|
198
|
+
else await refresh()
|
|
199
|
+
}
|
|
200
|
+
return true
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
if (active) error.value = errorMessage(cause, 'Failed to cancel job')
|
|
203
|
+
return false
|
|
204
|
+
} finally {
|
|
205
|
+
if (active) setPending(job.job_id, false)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function deleteJob(job: JobState): Promise<boolean> {
|
|
210
|
+
const deleteJobWithAdapter = adapter.value?.deleteJob
|
|
211
|
+
if (!job.can_delete || !deleteJobWithAdapter || pendingJobIds.value.has(job.job_id)) {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
setPending(job.job_id, true)
|
|
215
|
+
error.value = null
|
|
216
|
+
try {
|
|
217
|
+
const deleted = await deleteJobWithAdapter(job)
|
|
218
|
+
if (!active) return false
|
|
219
|
+
if (deleted === false) await refresh()
|
|
220
|
+
else removeJobs(new Set([job.job_id]))
|
|
221
|
+
return deleted !== false
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
if (active) error.value = errorMessage(cause, 'Failed to delete job')
|
|
224
|
+
return false
|
|
225
|
+
} finally {
|
|
226
|
+
if (active) setPending(job.job_id, false)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function clearFinished(): Promise<boolean> {
|
|
231
|
+
const targets = deletableJobs.value
|
|
232
|
+
if (targets.length === 0) return false
|
|
233
|
+
error.value = null
|
|
234
|
+
try {
|
|
235
|
+
const currentAdapter = adapter.value
|
|
236
|
+
if (currentAdapter?.clearFinishedJobs) {
|
|
237
|
+
const deleted = await currentAdapter.clearFinishedJobs(targets)
|
|
238
|
+
if (!active) return false
|
|
239
|
+
if (typeof deleted === 'number' && deleted !== targets.length) {
|
|
240
|
+
await refresh()
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
removeJobs(new Set(targets.map(job => job.job_id)))
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
if (!currentAdapter?.deleteJob) return false
|
|
247
|
+
const results = await Promise.all(targets.map(job => deleteJob(job)))
|
|
248
|
+
return results.every(Boolean)
|
|
249
|
+
} catch (cause) {
|
|
250
|
+
if (active) error.value = errorMessage(cause, 'Failed to clear jobs')
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (options.jobs !== undefined) {
|
|
256
|
+
watch(
|
|
257
|
+
() => toValue(options.jobs),
|
|
258
|
+
value => {
|
|
259
|
+
if (value) replaceJobs(value)
|
|
260
|
+
},
|
|
261
|
+
{ immediate: true, deep: true },
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (options.eventStream !== undefined) {
|
|
266
|
+
watch(
|
|
267
|
+
() => toValue(options.eventStream)?.lastMessage.value ?? null,
|
|
268
|
+
(message: PluginEventStreamMessage<JobStreamData> | null) => {
|
|
269
|
+
if (message) applyStreamMessage(message)
|
|
270
|
+
},
|
|
271
|
+
{ immediate: true },
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onMounted(() => {
|
|
276
|
+
if (options.loadOnMount !== false) void refresh()
|
|
277
|
+
})
|
|
278
|
+
onUnmounted(() => {
|
|
279
|
+
active = false
|
|
280
|
+
requestGeneration += 1
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
jobs,
|
|
285
|
+
activeJobs,
|
|
286
|
+
historyJobs,
|
|
287
|
+
deletableJobs,
|
|
288
|
+
featuredJob,
|
|
289
|
+
isOpen,
|
|
290
|
+
activeTab,
|
|
291
|
+
isLoading,
|
|
292
|
+
error,
|
|
293
|
+
pendingJobIds,
|
|
294
|
+
open: () => { isOpen.value = true },
|
|
295
|
+
close: () => { isOpen.value = false },
|
|
296
|
+
toggle: () => { isOpen.value = !isOpen.value },
|
|
297
|
+
refresh,
|
|
298
|
+
applyStreamMessage,
|
|
299
|
+
cancelJob,
|
|
300
|
+
deleteJob,
|
|
301
|
+
clearFinished,
|
|
302
|
+
isPending: job => pendingJobIds.value.has(job.job_id),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -31,6 +31,7 @@ export interface PluginEndpointContract {
|
|
|
31
31
|
queryParams?: PluginEndpointParamContract[]
|
|
32
32
|
requestType?: string | null
|
|
33
33
|
responseType?: string | null
|
|
34
|
+
eventStream?: boolean
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface PluginEndpointParamContract {
|
|
@@ -82,6 +83,8 @@ export interface PluginEndpointDefinition {
|
|
|
82
83
|
pathParams?: PluginEndpointParamDefinition[]
|
|
83
84
|
queryParams?: PluginEndpointParamDefinition[]
|
|
84
85
|
hasBody?: boolean
|
|
86
|
+
eventStream?: boolean
|
|
87
|
+
responseType?: string | null
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export type PluginEndpointParamDefinition = string | {
|
|
@@ -177,7 +180,7 @@ export interface PluginEventStreamOptions<TData = string> {
|
|
|
177
180
|
reconnect?: boolean
|
|
178
181
|
/** Delay before reconnecting after a stream failure or close. Defaults to 2000ms. */
|
|
179
182
|
reconnectDelayMs?: number
|
|
180
|
-
/** Parse `data:` as JSON
|
|
183
|
+
/** Parse `data:` as JSON. Defaults to true for typed endpoints and false otherwise. */
|
|
181
184
|
parseJson?: boolean
|
|
182
185
|
/** Fetch credentials mode. Defaults to same-origin. */
|
|
183
186
|
credentials?: RequestCredentials
|
|
@@ -662,9 +665,12 @@ export function usePluginEventStream<TData = string>(
|
|
|
662
665
|
const error = ref<string | null>(null)
|
|
663
666
|
const lastMessage = shallowRef<PluginEventStreamMessage<TData> | null>(null)
|
|
664
667
|
const lastEventId = ref<string | null>(null)
|
|
668
|
+
const responseType = endpoint.responseType?.trim()
|
|
669
|
+
const parseJson = options.parseJson ?? Boolean(responseType && responseType !== 'string')
|
|
665
670
|
let controller: AbortController | null = null
|
|
666
671
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
667
672
|
let stopped = true
|
|
673
|
+
let generation = 0
|
|
668
674
|
|
|
669
675
|
function clearReconnectTimer(): void {
|
|
670
676
|
if (reconnectTimer !== null) {
|
|
@@ -673,15 +679,31 @@ export function usePluginEventStream<TData = string>(
|
|
|
673
679
|
}
|
|
674
680
|
}
|
|
675
681
|
|
|
676
|
-
function
|
|
677
|
-
|
|
682
|
+
function ownsConnection(
|
|
683
|
+
connectionGeneration: number,
|
|
684
|
+
connectionController: AbortController,
|
|
685
|
+
): boolean {
|
|
686
|
+
return !stopped
|
|
687
|
+
&& generation === connectionGeneration
|
|
688
|
+
&& controller === connectionController
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function scheduleReconnect(connectionGeneration: number): void {
|
|
692
|
+
if (
|
|
693
|
+
stopped
|
|
694
|
+
|| generation !== connectionGeneration
|
|
695
|
+
|| options.reconnect === false
|
|
696
|
+
) return
|
|
678
697
|
clearReconnectTimer()
|
|
679
698
|
reconnectTimer = setTimeout(() => {
|
|
680
|
-
|
|
699
|
+
reconnectTimer = null
|
|
700
|
+
if (stopped || generation !== connectionGeneration) return
|
|
701
|
+
void connect(connectionGeneration)
|
|
681
702
|
}, options.reconnectDelayMs ?? 2000)
|
|
682
703
|
}
|
|
683
704
|
|
|
684
|
-
async function connect(): Promise<void> {
|
|
705
|
+
async function connect(connectionGeneration: number): Promise<void> {
|
|
706
|
+
if (stopped || generation !== connectionGeneration) return
|
|
685
707
|
if (endpoint.method !== 'get') {
|
|
686
708
|
throw new Error(`[MINT SDK] Plugin event streams must use GET; got ${endpoint.method}`)
|
|
687
709
|
}
|
|
@@ -690,9 +712,12 @@ export function usePluginEventStream<TData = string>(
|
|
|
690
712
|
}
|
|
691
713
|
|
|
692
714
|
controller?.abort()
|
|
693
|
-
|
|
715
|
+
const connectionController = new AbortController()
|
|
716
|
+
controller = connectionController
|
|
694
717
|
isConnecting.value = true
|
|
718
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
695
719
|
error.value = null
|
|
720
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
696
721
|
|
|
697
722
|
try {
|
|
698
723
|
const url = buildPluginEndpointUrl(contract, endpoint, payload, {
|
|
@@ -702,9 +727,11 @@ export function usePluginEventStream<TData = string>(
|
|
|
702
727
|
method: 'GET',
|
|
703
728
|
headers: streamHeaders(options as PluginEventStreamOptions<unknown>),
|
|
704
729
|
credentials: options.credentials ?? 'same-origin',
|
|
705
|
-
signal:
|
|
730
|
+
signal: connectionController.signal,
|
|
706
731
|
})
|
|
707
732
|
|
|
733
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
734
|
+
|
|
708
735
|
if (!response.ok) {
|
|
709
736
|
throw new Error(`Plugin event stream failed with HTTP ${response.status}`)
|
|
710
737
|
}
|
|
@@ -712,14 +739,17 @@ export function usePluginEventStream<TData = string>(
|
|
|
712
739
|
throw new Error('Plugin event stream response did not include a readable body.')
|
|
713
740
|
}
|
|
714
741
|
|
|
715
|
-
options.onOpen?.()
|
|
716
742
|
isConnected.value = true
|
|
743
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
744
|
+
options.onOpen?.()
|
|
745
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
717
746
|
const reader = response.body.getReader()
|
|
718
747
|
const decoder = new TextDecoder()
|
|
719
748
|
let buffer = ''
|
|
720
749
|
|
|
721
750
|
for (;;) {
|
|
722
751
|
const { value, done } = await reader.read()
|
|
752
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
723
753
|
if (done) break
|
|
724
754
|
buffer += decoder.decode(value, { stream: true })
|
|
725
755
|
|
|
@@ -727,41 +757,61 @@ export function usePluginEventStream<TData = string>(
|
|
|
727
757
|
while (boundary !== -1) {
|
|
728
758
|
const block = buffer.slice(0, boundary)
|
|
729
759
|
buffer = buffer.slice(buffer[boundary] === '\r' ? boundary + 4 : boundary + 2)
|
|
730
|
-
const message = parseSseEvent<TData>(block,
|
|
760
|
+
const message = parseSseEvent<TData>(block, parseJson)
|
|
731
761
|
if (message) {
|
|
732
762
|
lastMessage.value = message
|
|
763
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
733
764
|
if (message.id !== undefined) {
|
|
734
765
|
lastEventId.value = message.id
|
|
766
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
735
767
|
}
|
|
736
768
|
if (message.retry !== undefined) {
|
|
737
769
|
options.reconnectDelayMs = message.retry
|
|
738
770
|
}
|
|
771
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
739
772
|
options.onMessage?.(message)
|
|
773
|
+
if (!ownsConnection(connectionGeneration, connectionController)) return
|
|
740
774
|
}
|
|
741
775
|
boundary = buffer.search(/\r?\n\r?\n/)
|
|
742
776
|
}
|
|
743
777
|
}
|
|
744
778
|
} catch (err) {
|
|
745
|
-
if (
|
|
779
|
+
if (
|
|
780
|
+
ownsConnection(connectionGeneration, connectionController)
|
|
781
|
+
&& !(err instanceof DOMException && err.name === 'AbortError')
|
|
782
|
+
) {
|
|
746
783
|
const message = err instanceof Error ? err.message : 'Plugin event stream failed'
|
|
747
784
|
error.value = message
|
|
748
|
-
|
|
785
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
786
|
+
options.onError?.(err)
|
|
787
|
+
}
|
|
749
788
|
}
|
|
750
789
|
} finally {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
790
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
791
|
+
isConnecting.value = false
|
|
792
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
793
|
+
isConnected.value = false
|
|
794
|
+
if (ownsConnection(connectionGeneration, connectionController)) {
|
|
795
|
+
controller = null
|
|
796
|
+
scheduleReconnect(connectionGeneration)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
754
800
|
}
|
|
755
801
|
}
|
|
756
802
|
|
|
757
803
|
function start(): void {
|
|
804
|
+
if (!stopped) return
|
|
758
805
|
stopped = false
|
|
759
806
|
clearReconnectTimer()
|
|
760
|
-
|
|
807
|
+
generation += 1
|
|
808
|
+
void connect(generation)
|
|
761
809
|
}
|
|
762
810
|
|
|
763
811
|
function stop(): void {
|
|
812
|
+
if (stopped) return
|
|
764
813
|
stopped = true
|
|
814
|
+
generation += 1
|
|
765
815
|
clearReconnectTimer()
|
|
766
816
|
controller?.abort()
|
|
767
817
|
controller = null
|
package/src/index.ts
CHANGED
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { JobState, JobStateInput, JobStatus } from './types/jobs'
|
|
2
|
+
|
|
3
|
+
const ACTIVE_JOB_STATUSES = new Set<JobStatus>([
|
|
4
|
+
'awaiting_input',
|
|
5
|
+
'queued',
|
|
6
|
+
'running',
|
|
7
|
+
])
|
|
8
|
+
const TERMINAL_JOB_STATUSES = new Set<JobStatus>([
|
|
9
|
+
'completed',
|
|
10
|
+
'failed',
|
|
11
|
+
'cancelled',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
export interface JobCapabilityInput {
|
|
15
|
+
status: string
|
|
16
|
+
can_cancel?: boolean | null
|
|
17
|
+
can_delete?: boolean | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JobCapabilities {
|
|
21
|
+
can_cancel: boolean
|
|
22
|
+
can_delete: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve action support while retaining one-cycle legacy status inference. */
|
|
26
|
+
export function resolveJobCapabilities(job: JobCapabilityInput): JobCapabilities {
|
|
27
|
+
const legacyCanCancel = job.status === 'queued' || job.status === 'running'
|
|
28
|
+
const legacyCanDelete = TERMINAL_JOB_STATUSES.has(job.status as JobStatus)
|
|
29
|
+
return {
|
|
30
|
+
can_cancel: typeof job.can_cancel === 'boolean' ? job.can_cancel : legacyCanCancel,
|
|
31
|
+
can_delete: typeof job.can_delete === 'boolean' ? job.can_delete : legacyCanDelete,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Clamp invalid or out-of-range progress values for safe display. */
|
|
36
|
+
export function normalizeJobPercent(value: number): number {
|
|
37
|
+
if (!Number.isFinite(value)) return 0
|
|
38
|
+
return Math.min(100, Math.max(0, value))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create a detached canonical state without changing the transport input. */
|
|
42
|
+
export function normalizeJobState(job: JobStateInput): JobState {
|
|
43
|
+
const capabilities = resolveJobCapabilities(job)
|
|
44
|
+
return {
|
|
45
|
+
...job,
|
|
46
|
+
progress: {
|
|
47
|
+
...job.progress,
|
|
48
|
+
percent: normalizeJobPercent(job.progress.percent),
|
|
49
|
+
},
|
|
50
|
+
warnings: [...job.warnings],
|
|
51
|
+
folders: [...job.folders],
|
|
52
|
+
metadata: { ...job.metadata },
|
|
53
|
+
...capabilities,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isActiveJobStatus(status: JobStatus): boolean {
|
|
58
|
+
return ACTIVE_JOB_STATUSES.has(status)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isTerminalJobStatus(status: JobStatus): boolean {
|
|
62
|
+
return TERMINAL_JOB_STATUSES.has(status)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function jobStatusLabel(status: JobStatus): string {
|
|
66
|
+
const labels: Record<JobStatus, string> = {
|
|
67
|
+
awaiting_input: 'Waiting for input',
|
|
68
|
+
queued: 'Queued',
|
|
69
|
+
running: 'Running',
|
|
70
|
+
completed: 'Completed',
|
|
71
|
+
failed: 'Failed',
|
|
72
|
+
cancelled: 'Cancelled',
|
|
73
|
+
}
|
|
74
|
+
return labels[status]
|
|
75
|
+
}
|