@morscherlab/mint-sdk 1.0.51 → 1.0.53

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.
Files changed (43) hide show
  1. package/dist/__tests__/components/JobsStatusTray.test.d.ts +1 -0
  2. package/dist/__tests__/composables/useJobsStatusTray.test.d.ts +1 -0
  3. package/dist/__tests__/utils/jobs.test.d.ts +1 -0
  4. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  5. package/dist/components/JobsStatusTray.vue.d.ts +26 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.js +2 -2
  8. package/dist/{components-C7UFkNIp.js → components-Lk_h_rON.js} +1663 -1339
  9. package/dist/components-Lk_h_rON.js.map +1 -0
  10. package/dist/composables/index.d.ts +1 -0
  11. package/dist/composables/index.js +3 -3
  12. package/dist/composables/useJobsStatusTray.d.ts +43 -0
  13. package/dist/composables/usePluginClient.d.ts +4 -1
  14. package/dist/{composables-CpBhNKHf.js → composables-DyfGpYZw.js} +50 -17
  15. package/dist/{composables-CpBhNKHf.js.map → composables-DyfGpYZw.js.map} +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +4 -4
  18. package/dist/install.js +1 -1
  19. package/dist/jobs.d.ts +19 -0
  20. package/dist/styles.css +731 -0
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/jobs.d.ts +49 -0
  23. package/dist/{useProtocolTemplates-C8-YlHj1.js → useJobsStatusTray-DAH999_N.js} +250 -2
  24. package/dist/useJobsStatusTray-DAH999_N.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/components/JobsStatusTray.test.ts +141 -0
  27. package/src/__tests__/composables/useJobsStatusTray.test.ts +241 -0
  28. package/src/__tests__/composables/usePluginClient.test.ts +291 -0
  29. package/src/__tests__/utils/jobs.test.ts +95 -0
  30. package/src/components/JobsStatusTray.story.vue +120 -0
  31. package/src/components/JobsStatusTray.vue +385 -0
  32. package/src/components/index.ts +1 -0
  33. package/src/composables/index.ts +8 -0
  34. package/src/composables/useJobsStatusTray.ts +304 -0
  35. package/src/composables/usePluginClient.ts +65 -15
  36. package/src/index.ts +3 -0
  37. package/src/jobs.ts +75 -0
  38. package/src/styles/components/jobs-status-tray.css +420 -0
  39. package/src/styles/index.css +1 -0
  40. package/src/types/index.ts +10 -0
  41. package/src/types/jobs.ts +61 -0
  42. package/dist/components-C7UFkNIp.js.map +0 -1
  43. 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 before calling onMessage. */
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 scheduleReconnect(): void {
677
- if (stopped || options.reconnect === false) return
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
- void connect()
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
- controller = new AbortController()
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: controller.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, options.parseJson === true)
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 (!stopped && !(err instanceof DOMException && err.name === 'AbortError')) {
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
- options.onError?.(err)
785
+ if (ownsConnection(connectionGeneration, connectionController)) {
786
+ options.onError?.(err)
787
+ }
749
788
  }
750
789
  } finally {
751
- isConnecting.value = false
752
- isConnected.value = false
753
- scheduleReconnect()
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
- void connect()
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
@@ -25,6 +25,9 @@ export * from './permissions'
25
25
  // Instrument monitoring
26
26
  export * from './instrument'
27
27
 
28
+ // Job state helpers
29
+ export * from './jobs'
30
+
28
31
  // LCMS sequence helpers
29
32
  export * from './lcms'
30
33
 
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
+ }