@morscherlab/mint-sdk 1.0.63 → 1.1.0-beta.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.
Files changed (165) hide show
  1. package/README.md +101 -2
  2. package/dist/EmptyState-D1c4RdC7.js +638 -0
  3. package/dist/EmptyState-D1c4RdC7.js.map +1 -0
  4. package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
  5. package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
  7. package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
  8. package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
  9. package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
  10. package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
  11. package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
  12. package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
  13. package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
  14. package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
  15. package/dist/__tests__/generatedUi.test.d.ts +1 -0
  16. package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
  17. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -0
  18. package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
  19. package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
  20. package/dist/chunk-BoAXSpZd.js +33 -0
  21. package/dist/components/AppSidebar.vue.d.ts +1 -1
  22. package/dist/components/AutoGroupModal.vue.d.ts +2 -2
  23. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  25. package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
  26. package/dist/components/ConfirmDialog.vue.d.ts +1 -1
  27. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  28. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  29. package/dist/components/FileUploader.vue.d.ts +4 -2
  30. package/dist/components/FitPanel.vue.d.ts +1 -1
  31. package/dist/components/FormActions.vue.d.ts +1 -1
  32. package/dist/components/FormBuilder.vue.d.ts +1 -1
  33. package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
  34. package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
  35. package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
  36. package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
  37. package/dist/components/JobsStatusTray.vue.d.ts +5 -1
  38. package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
  39. package/dist/components/SampleLegend.vue.d.ts +1 -1
  40. package/dist/components/SampleSelector.vue.d.ts +2 -2
  41. package/dist/components/SettingsModal.vue.d.ts +1 -1
  42. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  43. package/dist/components/index.d.ts +4 -0
  44. package/dist/components/index.js +8 -9
  45. package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
  46. package/dist/{components-DdzHh3a_.js → components-UfkPN1bK.js} +2255 -2303
  47. package/dist/components-UfkPN1bK.js.map +1 -0
  48. package/dist/composables/index.d.ts +2 -3
  49. package/dist/composables/index.js +10 -11
  50. package/dist/composables/platformContextHelpers.d.ts +0 -2
  51. package/dist/composables/useAnalysisArtifacts.d.ts +1 -3
  52. package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
  53. package/dist/composables/useJobsStatusTray.d.ts +48 -4
  54. package/dist/composables/usePlatformContext.d.ts +3 -0
  55. package/dist/composables/usePluginClient.d.ts +6 -3
  56. package/dist/{composables-BUUphBqg.js → composables-B512bWJP.js} +155 -74
  57. package/dist/composables-B512bWJP.js.map +1 -0
  58. package/dist/experiment-utils-CxkruZ3G.js +392 -0
  59. package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
  60. package/dist/generated/schema.d.ts +7 -0
  61. package/dist/generated-ui/main.d.ts +0 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.js +15 -18
  64. package/dist/index.js.map +1 -1
  65. package/dist/install.js +7 -8
  66. package/dist/install.js.map +1 -1
  67. package/dist/plotly.min-BR53hja4.js +180634 -0
  68. package/dist/plotly.min-BR53hja4.js.map +1 -0
  69. package/dist/stores/auth.d.ts +2 -2
  70. package/dist/stores/index.js +1 -1
  71. package/dist/styles.css +10543 -10404
  72. package/dist/templates/index.js +4 -3
  73. package/dist/templates/packs.d.ts +0 -1
  74. package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
  75. package/dist/templates-Cttbjbyc.js.map +1 -0
  76. package/dist/types/analysisArtifactTypes.d.ts +0 -8
  77. package/dist/types/form-builder.d.ts +1 -1
  78. package/dist/types/generated-ui.d.ts +170 -0
  79. package/dist/types/index.d.ts +2 -1
  80. package/dist/types/index.js +1 -1
  81. package/dist/types/instrument.d.ts +1 -0
  82. package/dist/types/jobs.d.ts +7 -1
  83. package/dist/types/platform.d.ts +2 -0
  84. package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
  85. package/dist/useControlSchema-Dallso-L.js.map +1 -0
  86. package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
  87. package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
  88. package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
  89. package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
  90. package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
  91. package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
  92. package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-BP7_4RGQ.js} +307 -475
  93. package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
  94. package/package.json +7 -3
  95. package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
  96. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
  98. package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
  99. package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
  100. package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
  101. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +1 -74
  102. package/src/__tests__/composables/useApi.test.ts +8 -0
  103. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
  104. package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
  105. package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
  106. package/src/__tests__/composables/usePluginClient.test.ts +366 -20
  107. package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
  108. package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
  109. package/src/__tests__/generatedUi.test.ts +119 -0
  110. package/src/__tests__/templates/templates.test.ts +1 -1
  111. package/src/components/FileUploader.vue +49 -11
  112. package/src/components/GeneratedPathInput.vue +218 -0
  113. package/src/components/GeneratedPlotlyResult.vue +87 -0
  114. package/src/components/GeneratedPluginView.vue +279 -0
  115. package/src/components/GeneratedResultRenderer.vue +137 -0
  116. package/src/components/InstrumentAlertLog.vue +2 -2
  117. package/src/components/JobsStatusTray.vue +14 -8
  118. package/src/components/index.ts +4 -0
  119. package/src/composables/formBuilderRegistry.ts +2 -0
  120. package/src/composables/index.ts +18 -5
  121. package/src/composables/platformContextHelpers.ts +3 -10
  122. package/src/composables/useAnalysisArtifacts.ts +0 -48
  123. package/src/composables/useApi.ts +1 -0
  124. package/src/composables/useGeneratedAnalysis.ts +1252 -0
  125. package/src/composables/useJobsStatusTray.ts +234 -45
  126. package/src/composables/usePluginClient.ts +152 -25
  127. package/src/composables/usePluginConfig.ts +156 -20
  128. package/src/generated/schema.ts +130 -0
  129. package/src/generated-ui/main.ts +21 -0
  130. package/src/index.ts +3 -0
  131. package/src/jobs.ts +5 -0
  132. package/src/plotly-dist.d.ts +3 -0
  133. package/src/templates/packs.ts +0 -3
  134. package/src/types/analysisArtifactTypes.ts +0 -9
  135. package/src/types/form-builder.ts +1 -0
  136. package/src/types/generated-ui.ts +176 -0
  137. package/src/types/index.ts +2 -1
  138. package/src/types/instrument.ts +1 -0
  139. package/src/types/jobs.ts +10 -1
  140. package/src/types/platform.ts +2 -0
  141. package/dist/BaseButton-Dgqrze41.js +0 -74
  142. package/dist/BaseButton-Dgqrze41.js.map +0 -1
  143. package/dist/BaseModal-B9UA8Y_I.js +0 -165
  144. package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
  145. package/dist/BaseSelect-ekgr9fDo.js +0 -179
  146. package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
  147. package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
  148. package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
  149. package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
  150. package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
  151. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +0 -1
  152. package/dist/components-DdzHh3a_.js.map +0 -1
  153. package/dist/composables/analysisArtifactTarget.d.ts +0 -5
  154. package/dist/composables-BUUphBqg.js.map +0 -1
  155. package/dist/experiment-utils-Bfa7CwPU.js +0 -108
  156. package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
  157. package/dist/templates-CNbPQNID.js.map +0 -1
  158. package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
  159. package/dist/useControlSchema-BZNdalmL.js.map +0 -1
  160. package/dist/useEventListener-CfVkP9Xz.js +0 -57
  161. package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
  162. package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
  163. package/src/__tests__/composables/analysisArtifactTarget.test.ts +0 -77
  164. package/src/composables/analysisArtifactTarget.ts +0 -53
  165. /package/dist/__tests__/{composables/analysisArtifactTarget.test.d.ts → components/GeneratedPathInput.test.d.ts} +0 -0
@@ -0,0 +1,1252 @@
1
+ import {
2
+ computed,
3
+ onScopeDispose,
4
+ ref,
5
+ shallowRef,
6
+ type ComputedRef,
7
+ type Ref,
8
+ type ShallowRef,
9
+ } from 'vue'
10
+
11
+ import type {
12
+ JobsStatusTrayAdapter,
13
+ PluginJobCenterSource,
14
+ } from './useJobsStatusTray'
15
+ import { normalizeJobState } from '../jobs'
16
+ import type {
17
+ GeneratedAnalysisJob,
18
+ GeneratedAnalysisJobInput,
19
+ GeneratedAnalysisResult,
20
+ GeneratedSession,
21
+ GeneratedAnalysisManifest,
22
+ PluginJobPathInput,
23
+ PluginJobPathUpload,
24
+ PluginJobStagedPartInput,
25
+ PluginJobStagedPartUploadResult,
26
+ PluginJobStaging,
27
+ PluginJobStagingPagination,
28
+ } from '../types/generated-ui'
29
+
30
+ export type GeneratedAnalysisTransport = (
31
+ input: RequestInfo | URL,
32
+ init?: RequestInit,
33
+ ) => Promise<Response>
34
+
35
+ export interface GeneratedAnalysisTransportOptions {
36
+ fetcher?: typeof fetch
37
+ getAccessToken?: () => string | null | undefined
38
+ }
39
+
40
+ export interface UsePluginJobsOptions {
41
+ baseUrl: string
42
+ transport?: GeneratedAnalysisTransport
43
+ storage?: Storage
44
+ createId?: () => string
45
+ pollInterval?: number
46
+ stream?: boolean
47
+ streamReconnectDelay?: number
48
+ immediate?: boolean
49
+ }
50
+
51
+ export type GeneratedAnalysisOptions = UsePluginJobsOptions
52
+
53
+ export interface PluginJobWaitOptions {
54
+ signal?: AbortSignal
55
+ rejectOnFailure?: boolean
56
+ }
57
+
58
+ export interface PluginJobRequestOptions {
59
+ signal?: AbortSignal
60
+ }
61
+
62
+ interface GeneratedSessionStorage {
63
+ getItem: (key: string) => string | null
64
+ setItem: (key: string, value: string) => void
65
+ }
66
+
67
+ export interface UsePluginJobsReturn extends PluginJobCenterSource {
68
+ manifest: ShallowRef<GeneratedAnalysisManifest | null>
69
+ session: ShallowRef<GeneratedSession | null>
70
+ jobs: ShallowRef<GeneratedAnalysisJob[]>
71
+ latestResult: ComputedRef<GeneratedAnalysisResult | null>
72
+ isLoading: Ref<boolean>
73
+ isSubmitting: Ref<boolean>
74
+ error: ShallowRef<string | null>
75
+ status: ShallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>
76
+ initialize: () => Promise<void>
77
+ ready: () => Promise<void>
78
+ uploadPath: (input: PluginJobPathInput) => Promise<PluginJobPathUpload>
79
+ uploadStagedPart: <Metadata>(
80
+ input: PluginJobStagedPartInput<Metadata>,
81
+ ) => Promise<PluginJobStagedPartUploadResult<Metadata>>
82
+ getJobStaging: <Metadata = unknown>(
83
+ job: GeneratedAnalysisJob | string,
84
+ pagination?: PluginJobStagingPagination,
85
+ ) => Promise<PluginJobStaging<Metadata>>
86
+ sealJob: (job: GeneratedAnalysisJob | string) => Promise<GeneratedAnalysisJob>
87
+ submit: (definitionId: string, inputs: Record<string, unknown>) => Promise<GeneratedAnalysisJob>
88
+ loadResult: (job: GeneratedAnalysisJob) => Promise<GeneratedAnalysisResult | null>
89
+ resultForJob: (jobId: string) => GeneratedAnalysisResult | null
90
+ refreshJobs: () => Promise<GeneratedAnalysisJob[]>
91
+ refresh: () => Promise<GeneratedAnalysisJob[]>
92
+ getJob: (jobId: string, options?: PluginJobRequestOptions) => Promise<GeneratedAnalysisJob>
93
+ waitForJob: (
94
+ job: GeneratedAnalysisJob | string,
95
+ options?: PluginJobWaitOptions,
96
+ ) => Promise<GeneratedAnalysisJob>
97
+ jobsFor: (definitionId: string) => GeneratedAnalysisJob[]
98
+ cancelJob: (job: GeneratedAnalysisJob | string) => Promise<GeneratedAnalysisJob>
99
+ deleteJob: (job: GeneratedAnalysisJob | string) => Promise<boolean>
100
+ clearFinishedJobs: (definitionId?: string) => Promise<number>
101
+ pause: () => void
102
+ resume: () => void
103
+ dispose: () => void
104
+ /** @deprecated Pass the runtime itself to SDK job UI as its source. */
105
+ jobsAdapter: JobsStatusTrayAdapter
106
+ }
107
+
108
+ export type UseGeneratedAnalysisReturn = UsePluginJobsReturn
109
+
110
+ const ACTIVE_STATUSES = new Set(['awaiting_input', 'queued', 'running'])
111
+ const AUTH_TOKEN_KEY = 'mint-auth-token'
112
+ const SESSION_NOT_FOUND_DETAIL = 'Session not found'
113
+ const MAX_RESULT_CACHE_ENTRIES = 32
114
+ const MAX_RESULT_RETRIES = 3
115
+ const DOWNLOAD_URL_REFRESH_SKEW_MS = 30_000
116
+
117
+ interface PluginJobWaiter {
118
+ resolve: (job: GeneratedAnalysisJob) => void
119
+ reject: (cause: unknown) => void
120
+ rejectOnFailure: boolean
121
+ signal?: AbortSignal
122
+ abort?: () => void
123
+ }
124
+
125
+ class GeneratedAnalysisRequestError extends Error {
126
+ constructor(
127
+ readonly status: number,
128
+ readonly detail: unknown,
129
+ ) {
130
+ super(typeof detail === 'string' ? detail : `Request failed (${status})`)
131
+ this.name = 'GeneratedAnalysisRequestError'
132
+ }
133
+ }
134
+
135
+ export function createGeneratedAnalysisTransport(
136
+ options: GeneratedAnalysisTransportOptions = {},
137
+ ): GeneratedAnalysisTransport {
138
+ const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis)
139
+ const getAccessToken = options.getAccessToken ?? readStoredAccessToken
140
+
141
+ return async (input, init = {}) => {
142
+ const headers = new Headers(init.headers)
143
+ const accessToken = getAccessToken()
144
+ if (accessToken && !headers.has('Authorization')) {
145
+ headers.set('Authorization', `Bearer ${accessToken}`)
146
+ }
147
+ return await fetcher(input, {
148
+ credentials: 'same-origin',
149
+ ...init,
150
+ headers,
151
+ })
152
+ }
153
+ }
154
+
155
+ export function useGeneratedAnalysis(
156
+ options: GeneratedAnalysisOptions,
157
+ ): UseGeneratedAnalysisReturn {
158
+ return createPluginJobs(options, '/generated/manifest')
159
+ }
160
+
161
+ export function usePluginJobs(
162
+ options: UsePluginJobsOptions,
163
+ ): UsePluginJobsReturn {
164
+ return createPluginJobs(options)
165
+ }
166
+
167
+ function createPluginJobs(
168
+ options: UsePluginJobsOptions,
169
+ manifestPath?: string,
170
+ ): UsePluginJobsReturn {
171
+ const baseUrl = options.baseUrl.replace(/\/$/, '')
172
+ const transport = options.transport ?? createGeneratedAnalysisTransport()
173
+ const storage = createGeneratedSessionStorage(options.storage)
174
+ const createId = options.createId ?? (() => globalThis.crypto.randomUUID())
175
+ const pollInterval = options.pollInterval ?? 750
176
+ const streamEnabled = options.stream ?? true
177
+ const streamReconnectDelay = options.streamReconnectDelay ?? 1_500
178
+ const tabKey = `mint:generated:${baseUrl}:tab`
179
+ const sessionKey = `mint:generated:${baseUrl}:session`
180
+ const manifest = shallowRef<GeneratedAnalysisManifest | null>(null)
181
+ const session = shallowRef<GeneratedSession | null>(null)
182
+ const jobs = shallowRef<GeneratedAnalysisJob[]>([])
183
+ const jobRevisions = new Map<string, number>()
184
+ const pendingJobOperations = new Map<string, number>()
185
+ const resultCache = shallowRef<Record<string, GeneratedAnalysisResult>>({})
186
+ const isLoading = ref(false)
187
+ const isSubmitting = ref(false)
188
+ const error = shallowRef<string | null>(null)
189
+ const status = shallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>('idle')
190
+ let pollTimer: ReturnType<typeof setTimeout> | undefined
191
+ let streamReconnectTimer: ReturnType<typeof setTimeout> | undefined
192
+ let streamController: AbortController | undefined
193
+ let streamConnected = false
194
+ let streamConnecting = false
195
+ let disposed = false
196
+ let paused = false
197
+ let initialized = false
198
+ let initializePromise: Promise<void> | undefined
199
+ let refreshPromise: Promise<GeneratedAnalysisJob[]> | undefined
200
+ let initializeRefreshPromise: Promise<GeneratedAnalysisJob[]> | undefined
201
+ let jobsRevision = 0
202
+ let resultCacheOrder: string[] = []
203
+ const resultRequests = new Map<string, Promise<GeneratedAnalysisResult | null>>()
204
+ const jobWaiters = new Map<string, Set<PluginJobWaiter>>()
205
+ const resultRetryAttempts = new Map<string, number>()
206
+ const resultRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
207
+ const downloadRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
208
+
209
+ const latestResult = computed<GeneratedAnalysisResult | null>(() => {
210
+ const job = currentCompletedJob()
211
+ return job ? resultForJob(job.job_id) : null
212
+ })
213
+
214
+ function initialize(): Promise<void> {
215
+ if (disposed) return Promise.reject(new Error('Plugin jobs runtime is disposed'))
216
+ if (initialized) return Promise.resolve()
217
+ if (initializePromise) return initializePromise
218
+ isLoading.value = true
219
+ error.value = null
220
+ status.value = 'initializing'
221
+ const pending = (async () => {
222
+ if (manifestPath) {
223
+ const loadedManifest = await request<GeneratedAnalysisManifest>(manifestPath, { method: 'GET' })
224
+ if (!disposed) manifest.value = loadedManifest
225
+ }
226
+ const resolvedSession = await resolveSession()
227
+ if (disposed) return
228
+ session.value = resolvedSession
229
+ await refreshJobs()
230
+ if (disposed) return
231
+ initialized = true
232
+ status.value = paused ? 'paused' : 'ready'
233
+ if (streamEnabled && !paused) void connectJobStream()
234
+ })().catch((cause: unknown) => {
235
+ if (!disposed) {
236
+ error.value = errorMessage(cause, 'Failed to initialize generated analysis')
237
+ status.value = 'error'
238
+ }
239
+ throw cause
240
+ }).finally(() => {
241
+ if (initializePromise === pending) initializePromise = undefined
242
+ if (!disposed) isLoading.value = false
243
+ })
244
+ initializePromise = pending
245
+ return pending
246
+ }
247
+
248
+ async function resolveSession(): Promise<GeneratedSession> {
249
+ let tabId = storage.getItem(tabKey)
250
+ if (!tabId) {
251
+ tabId = createId()
252
+ storage.setItem(tabKey, tabId)
253
+ }
254
+ const storedSessionId = storage.getItem(sessionKey)
255
+ const payload = await request<{ sessions: GeneratedSession[] }>('/jobs/sessions', { method: 'GET' })
256
+ const existing = payload.sessions.find(candidate => (
257
+ candidate.id === storedSessionId && candidate.tab_id === tabId
258
+ ))
259
+ if (existing) return existing
260
+ const created = await request<GeneratedSession>('/jobs/sessions', {
261
+ method: 'POST',
262
+ body: JSON.stringify({ tab_id: tabId }),
263
+ })
264
+ storage.setItem(sessionKey, created.id)
265
+ return created
266
+ }
267
+
268
+ async function submit(
269
+ definitionId: string,
270
+ inputs: Record<string, unknown>,
271
+ ): Promise<GeneratedAnalysisJob> {
272
+ if (!session.value) throw new Error('Generated analysis session is not initialized')
273
+ const clientRequestId = createId()
274
+ isSubmitting.value = true
275
+ error.value = null
276
+ try {
277
+ let jobInput: GeneratedAnalysisJobInput
278
+ try {
279
+ jobInput = await submitToSession(
280
+ session.value.id,
281
+ clientRequestId,
282
+ await stagePathInputs(inputs, session.value.id),
283
+ )
284
+ } catch (cause) {
285
+ if (!isSessionNotFoundError(cause)) throw cause
286
+ session.value = await resolveSession()
287
+ jobInput = await submitToSession(
288
+ session.value.id,
289
+ clientRequestId,
290
+ await stagePathInputs(inputs, session.value.id),
291
+ )
292
+ }
293
+ const job = upsertJob(jobInput, definitionId)
294
+ if (!streamConnected) schedulePoll()
295
+ return job
296
+ } catch (cause) {
297
+ error.value = errorMessage(cause, 'Failed to submit analysis')
298
+ throw cause
299
+ } finally {
300
+ isSubmitting.value = false
301
+ }
302
+
303
+ function submitToSession(
304
+ sessionId: string,
305
+ requestId: string,
306
+ stagedInputs: Record<string, unknown>,
307
+ ): Promise<GeneratedAnalysisJobInput> {
308
+ return request<GeneratedAnalysisJobInput>('/jobs', {
309
+ method: 'POST',
310
+ body: JSON.stringify({
311
+ definition_id: definitionId,
312
+ inputs: stagedInputs,
313
+ session_id: sessionId,
314
+ client_request_id: requestId,
315
+ }),
316
+ })
317
+ }
318
+ }
319
+
320
+ async function uploadPath(input: PluginJobPathInput): Promise<PluginJobPathUpload> {
321
+ if (!session.value) throw new Error('Generated analysis session is not initialized')
322
+ return uploadPathForSession(input, session.value.id)
323
+ }
324
+
325
+ async function uploadStagedPart<Metadata>(
326
+ input: PluginJobStagedPartInput<Metadata>,
327
+ ): Promise<PluginJobStagedPartUploadResult<Metadata>> {
328
+ const jobId = jobIdFromReference(input.job)
329
+ const body = new FormData()
330
+ body.append('file', input.file)
331
+ body.append('metadata', JSON.stringify(input.metadata))
332
+ if (input.expectedSize !== undefined) {
333
+ body.append('expected_size', String(input.expectedSize))
334
+ }
335
+ if (input.expectedSha256 !== undefined) {
336
+ body.append('expected_sha256', input.expectedSha256)
337
+ }
338
+ return request<PluginJobStagedPartUploadResult<Metadata>>(
339
+ `/jobs/${encodeURIComponent(jobId)}/parts/${encodeStagedPartKey(input.key)}`,
340
+ { method: 'PUT', body, signal: input.signal },
341
+ )
342
+ }
343
+
344
+ function getJobStaging<Metadata = unknown>(
345
+ job: GeneratedAnalysisJob | string,
346
+ pagination: PluginJobStagingPagination = {},
347
+ ): Promise<PluginJobStaging<Metadata>> {
348
+ const jobId = jobIdFromReference(job)
349
+ const query = new URLSearchParams()
350
+ if (pagination.offset !== undefined) query.set('offset', String(pagination.offset))
351
+ if (pagination.limit !== undefined) query.set('limit', String(pagination.limit))
352
+ const suffix = query.size > 0 ? `?${query}` : ''
353
+ return request<PluginJobStaging<Metadata>>(
354
+ `/jobs/${encodeURIComponent(jobId)}/staging${suffix}`,
355
+ { method: 'GET' },
356
+ )
357
+ }
358
+
359
+ async function sealJob(
360
+ job: GeneratedAnalysisJob | string,
361
+ ): Promise<GeneratedAnalysisJob> {
362
+ const jobId = jobIdFromReference(job)
363
+ const revision = beginJobOperation(jobId)
364
+ try {
365
+ const updated = await request<GeneratedAnalysisJobInput>(
366
+ `/jobs/${encodeURIComponent(jobId)}/seal`,
367
+ { method: 'POST' },
368
+ )
369
+ return mergeJobResponse(updated, revision)
370
+ } finally {
371
+ finishJobOperation(jobId)
372
+ }
373
+ }
374
+
375
+ async function uploadPathForSession(
376
+ input: PluginJobPathInput,
377
+ sessionId: string,
378
+ ): Promise<PluginJobPathUpload> {
379
+ if (!input.files.length || input.files.length !== input.relativePaths.length) {
380
+ throw new Error('Path upload requires one relative path for each file')
381
+ }
382
+ const body = new FormData()
383
+ body.append('session_id', sessionId)
384
+ if (input.rootName) body.append('root_name', input.rootName)
385
+ for (let index = 0; index < input.files.length; index += 1) {
386
+ body.append('files', input.files[index]!)
387
+ body.append('relative_paths', input.relativePaths[index]!)
388
+ }
389
+ return request<PluginJobPathUpload>('/jobs/uploads', {
390
+ method: 'POST',
391
+ body,
392
+ signal: input.signal,
393
+ headers: { 'X-Mint-Job-Session': sessionId },
394
+ })
395
+ }
396
+
397
+ async function stagePathInputs(
398
+ inputs: Record<string, unknown>,
399
+ sessionId: string,
400
+ ): Promise<Record<string, unknown>> {
401
+ return await stagePathValue(inputs, sessionId) as Record<string, unknown>
402
+ }
403
+
404
+ async function stagePathValue(value: unknown, sessionId: string): Promise<unknown> {
405
+ if (typeof File !== 'undefined' && value instanceof File) {
406
+ const upload = await uploadPathForSession({
407
+ kind: 'path',
408
+ files: [value],
409
+ relativePaths: [value.name],
410
+ }, sessionId)
411
+ return { token: upload.token }
412
+ }
413
+ if (isPluginJobPathInput(value)) {
414
+ const upload = await uploadPathForSession(value, sessionId)
415
+ return { token: upload.token }
416
+ }
417
+ if (Array.isArray(value)) {
418
+ return await Promise.all(value.map(item => stagePathValue(item, sessionId)))
419
+ }
420
+ if (value && typeof value === 'object') {
421
+ const entries = await Promise.all(Object.entries(value).map(async ([key, child]) => (
422
+ [key, await stagePathValue(child, sessionId)] as const
423
+ )))
424
+ return Object.fromEntries(entries)
425
+ }
426
+ return value
427
+ }
428
+
429
+ function refreshJobs(): Promise<GeneratedAnalysisJob[]> {
430
+ if (disposed) return Promise.resolve(jobs.value)
431
+ if (refreshPromise) return refreshPromise
432
+ const revision = jobsRevision
433
+ const pending = request<{ jobs: GeneratedAnalysisJobInput[] }>('/jobs', { method: 'GET' })
434
+ .then((payload) => {
435
+ if (disposed || revision !== jobsRevision) return jobs.value
436
+ replaceJobs(payload.jobs)
437
+ error.value = null
438
+ if (!paused && !streamConnected && jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) {
439
+ schedulePoll()
440
+ }
441
+ return jobs.value
442
+ }).finally(() => {
443
+ if (refreshPromise === pending) refreshPromise = undefined
444
+ })
445
+ refreshPromise = pending
446
+ return pending
447
+ }
448
+
449
+ function refresh(): Promise<GeneratedAnalysisJob[]> {
450
+ if (disposed) return Promise.resolve(jobs.value)
451
+ if (initialized) return refreshJobs()
452
+ if (initializeRefreshPromise) return initializeRefreshPromise
453
+ const pending = initialize()
454
+ .then(() => jobs.value)
455
+ .finally(() => {
456
+ if (initializeRefreshPromise === pending) initializeRefreshPromise = undefined
457
+ })
458
+ initializeRefreshPromise = pending
459
+ return pending
460
+ }
461
+
462
+ async function getJob(
463
+ jobId: string,
464
+ options: PluginJobRequestOptions = {},
465
+ ): Promise<GeneratedAnalysisJob> {
466
+ const revision = beginJobOperation(jobId)
467
+ try {
468
+ const job = await request<GeneratedAnalysisJobInput>(
469
+ `/jobs/${encodeURIComponent(jobId)}`,
470
+ { method: 'GET', signal: options.signal },
471
+ )
472
+ return mergeJobResponse(job, revision)
473
+ } finally {
474
+ finishJobOperation(jobId)
475
+ }
476
+ }
477
+
478
+ function waitForJob(
479
+ job: GeneratedAnalysisJob | string,
480
+ options: PluginJobWaitOptions = {},
481
+ ): Promise<GeneratedAnalysisJob> {
482
+ if (disposed) return Promise.reject(new Error('Plugin jobs runtime is disposed'))
483
+ if (options.signal?.aborted) {
484
+ return Promise.reject(pluginJobWaitAbort(options.signal))
485
+ }
486
+ const jobId = jobIdFromReference(job)
487
+ const current = typeof job === 'string'
488
+ ? jobs.value.find(candidate => candidate.job_id === jobId)
489
+ : jobs.value.find(candidate => candidate.job_id === jobId) ?? job
490
+ if (current && !ACTIVE_STATUSES.has(current.status)) {
491
+ return terminalWaitResult(current, options.rejectOnFailure === true)
492
+ }
493
+ if (paused) return Promise.reject(new Error('Plugin jobs runtime is paused'))
494
+ return new Promise<GeneratedAnalysisJob>((resolve, reject) => {
495
+ const waiter: PluginJobWaiter = {
496
+ resolve,
497
+ reject,
498
+ rejectOnFailure: options.rejectOnFailure === true,
499
+ signal: options.signal,
500
+ }
501
+ if (options.signal) {
502
+ waiter.abort = () => {
503
+ removeJobWaiter(jobId, waiter)
504
+ reject(pluginJobWaitAbort(options.signal!))
505
+ }
506
+ options.signal.addEventListener('abort', waiter.abort, { once: true })
507
+ }
508
+ const waiting = jobWaiters.get(jobId) ?? new Set<PluginJobWaiter>()
509
+ waiting.add(waiter)
510
+ jobWaiters.set(jobId, waiting)
511
+ if (!paused) {
512
+ if (streamEnabled) void connectJobStream()
513
+ if (!streamConnected) schedulePoll()
514
+ }
515
+ void getJob(jobId, { signal: options.signal }).catch((cause: unknown) => {
516
+ if (current && isRetryableJobLookupError(cause)) {
517
+ if (!streamConnected) schedulePoll()
518
+ return
519
+ }
520
+ removeJobWaiter(jobId, waiter)
521
+ reject(cause)
522
+ })
523
+ })
524
+ }
525
+
526
+ function settleJobWaiters(job: GeneratedAnalysisJob): void {
527
+ if (ACTIVE_STATUSES.has(job.status)) return
528
+ const waiting = jobWaiters.get(job.job_id)
529
+ if (!waiting) return
530
+ jobWaiters.delete(job.job_id)
531
+ for (const waiter of waiting) {
532
+ if (waiter.abort && waiter.signal) {
533
+ waiter.signal.removeEventListener('abort', waiter.abort)
534
+ }
535
+ void terminalWaitResult(job, waiter.rejectOnFailure).then(
536
+ waiter.resolve,
537
+ waiter.reject,
538
+ )
539
+ }
540
+ }
541
+
542
+ function rejectJobWaiters(jobId: string, cause: unknown): void {
543
+ const waiting = jobWaiters.get(jobId)
544
+ if (!waiting) return
545
+ jobWaiters.delete(jobId)
546
+ for (const waiter of waiting) {
547
+ if (waiter.abort && waiter.signal) {
548
+ waiter.signal.removeEventListener('abort', waiter.abort)
549
+ }
550
+ waiter.reject(cause)
551
+ }
552
+ }
553
+
554
+ function removeJobWaiter(jobId: string, waiter: PluginJobWaiter): void {
555
+ const waiting = jobWaiters.get(jobId)
556
+ if (!waiting) return
557
+ waiting.delete(waiter)
558
+ if (waiting.size === 0) jobWaiters.delete(jobId)
559
+ if (waiter.abort && waiter.signal) {
560
+ waiter.signal.removeEventListener('abort', waiter.abort)
561
+ }
562
+ }
563
+
564
+ function jobsFor(definitionId: string): GeneratedAnalysisJob[] {
565
+ return jobs.value.filter(job => job.definition_id === definitionId)
566
+ }
567
+
568
+ function canonicalJob(
569
+ input: GeneratedAnalysisJobInput,
570
+ fallbackDefinitionId?: string,
571
+ ): GeneratedAnalysisJob {
572
+ const normalized = normalizeJobState(input)
573
+ const definitionId = normalized.definition_id ?? fallbackDefinitionId ?? null
574
+ return {
575
+ ...normalized,
576
+ definition_id: definitionId,
577
+ metadata: definitionId
578
+ ? { ...normalized.metadata, definition_id: definitionId }
579
+ : normalized.metadata,
580
+ }
581
+ }
582
+
583
+ function recordJobsMutation(jobIds: Iterable<string>): void {
584
+ jobsRevision += 1
585
+ for (const jobId of jobIds) {
586
+ jobRevisions.set(jobId, (jobRevisions.get(jobId) ?? 0) + 1)
587
+ }
588
+ }
589
+
590
+ function beginJobOperation(jobId: string): number {
591
+ pendingJobOperations.set(jobId, (pendingJobOperations.get(jobId) ?? 0) + 1)
592
+ return jobRevisions.get(jobId) ?? 0
593
+ }
594
+
595
+ function finishJobOperation(jobId: string): void {
596
+ const remaining = (pendingJobOperations.get(jobId) ?? 1) - 1
597
+ if (remaining > 0) pendingJobOperations.set(jobId, remaining)
598
+ else pendingJobOperations.delete(jobId)
599
+ pruneJobRevisions()
600
+ }
601
+
602
+ function pruneJobRevisions(): void {
603
+ const retained = new Set([
604
+ ...jobs.value.map(job => job.job_id),
605
+ ...pendingJobOperations.keys(),
606
+ ])
607
+ for (const jobId of jobRevisions.keys()) {
608
+ if (!retained.has(jobId)) jobRevisions.delete(jobId)
609
+ }
610
+ }
611
+
612
+ function replaceJobs(inputs: readonly GeneratedAnalysisJobInput[]): GeneratedAnalysisJob[] {
613
+ const previousJobs = new Map(jobs.value.map(job => [job.job_id, job]))
614
+ const previousJobIds = new Set(jobs.value.map(job => job.job_id))
615
+ const nextJobs = inputs.map((input) => {
616
+ const incoming = canonicalJob(input)
617
+ const current = previousJobs.get(incoming.job_id)
618
+ return current && shouldPreserveJobLifecycle(current, incoming) ? current : incoming
619
+ })
620
+ const nextJobIds = new Set(nextJobs.map(job => job.job_id))
621
+ recordJobsMutation(new Set([
622
+ ...previousJobIds,
623
+ ...nextJobIds,
624
+ ]))
625
+ jobs.value = nextJobs
626
+ for (const job of nextJobs) settleJobWaiters(job)
627
+ for (const jobId of previousJobIds) {
628
+ if (!nextJobIds.has(jobId)) {
629
+ rejectJobWaiters(jobId, new Error(`Job ${jobId} is no longer available`))
630
+ }
631
+ }
632
+ pruneResultState()
633
+ pruneJobRevisions()
634
+ return nextJobs
635
+ }
636
+
637
+ function upsertJob(
638
+ input: GeneratedAnalysisJobInput,
639
+ fallbackDefinitionId?: string,
640
+ ): GeneratedAnalysisJob {
641
+ const incoming = canonicalJob(input, fallbackDefinitionId)
642
+ const current = jobs.value.find(candidate => candidate.job_id === incoming.job_id)
643
+ const job = current && shouldPreserveJobLifecycle(current, incoming)
644
+ ? current
645
+ : incoming
646
+ if (job === current) {
647
+ settleJobWaiters(job)
648
+ pruneResultState()
649
+ pruneJobRevisions()
650
+ return job
651
+ }
652
+ jobs.value = [job, ...jobs.value.filter(candidate => candidate.job_id !== job.job_id)]
653
+ recordJobsMutation([job.job_id])
654
+ settleJobWaiters(job)
655
+ pruneResultState()
656
+ pruneJobRevisions()
657
+ return job
658
+ }
659
+
660
+ function mergeJobResponse(
661
+ input: GeneratedAnalysisJobInput,
662
+ expectedRevision: number,
663
+ ): GeneratedAnalysisJob {
664
+ const incoming = canonicalJob(input)
665
+ const current = jobs.value.find(candidate => candidate.job_id === incoming.job_id)
666
+ if (disposed) return current ?? incoming
667
+ if (current && shouldPreserveJobLifecycle(current, incoming)) return current
668
+ if (expectedRevision === (jobRevisions.get(incoming.job_id) ?? 0)) {
669
+ return upsertJob(incoming)
670
+ }
671
+ if (current && jobLifecycleRank(incoming.status) > jobLifecycleRank(current.status)) {
672
+ return upsertJob(incoming)
673
+ }
674
+ return current ?? incoming
675
+ }
676
+
677
+ function currentCompletedJob(): GeneratedAnalysisJob | undefined {
678
+ const activeSessionId = session.value?.id
679
+ return jobs.value.find(candidate => (
680
+ candidate.session_id === activeSessionId
681
+ && candidate.status === 'completed'
682
+ && candidate.result_status === 'available'
683
+ ))
684
+ }
685
+
686
+ function resultForJob(jobId: string): GeneratedAnalysisResult | null {
687
+ return resultCache.value[jobId] ?? null
688
+ }
689
+
690
+ function loadResult(job: GeneratedAnalysisJob): Promise<GeneratedAnalysisResult | null> {
691
+ return loadResultInternal(job)
692
+ }
693
+
694
+ function loadResultInternal(
695
+ job: GeneratedAnalysisJob,
696
+ forceRefresh = false,
697
+ ): Promise<GeneratedAnalysisResult | null> {
698
+ if (
699
+ disposed
700
+ || !isLoadableResultJob(job)
701
+ ) return Promise.resolve(null)
702
+
703
+ const cached = resultForJob(job.job_id)
704
+ if (cached && !forceRefresh && !managedDownloadNeedsRefresh(cached)) {
705
+ return Promise.resolve(cached)
706
+ }
707
+ const pending = resultRequests.get(job.job_id)
708
+ if (pending) return pending
709
+
710
+ const requestResult = request<GeneratedAnalysisResult>(
711
+ `/jobs/${encodeURIComponent(job.job_id)}/result`,
712
+ { method: 'GET' },
713
+ ).then((result) => {
714
+ if (!disposed && isCurrentResultJob(job.job_id)) {
715
+ cacheResult(job.job_id, result)
716
+ clearResultRetry(job.job_id)
717
+ error.value = null
718
+ }
719
+ return result
720
+ }).catch((cause: unknown) => {
721
+ if (!disposed && isCurrentResultJob(job.job_id)) {
722
+ error.value = errorMessage(cause, 'Failed to load analysis result')
723
+ if (cached && isManagedDownloadResult(cached)) {
724
+ handleManagedDownloadRefreshFailure(job, cause)
725
+ } else {
726
+ scheduleResultRetry(job, cause)
727
+ }
728
+ }
729
+ return null
730
+ }).finally(() => {
731
+ resultRequests.delete(job.job_id)
732
+ })
733
+ resultRequests.set(job.job_id, requestResult)
734
+ return requestResult
735
+ }
736
+
737
+ function cacheResult(jobId: string, result: GeneratedAnalysisResult): void {
738
+ resultCacheOrder = [jobId, ...resultCacheOrder.filter(candidate => candidate !== jobId)]
739
+ .slice(0, MAX_RESULT_CACHE_ENTRIES)
740
+ const retained = new Set(resultCacheOrder)
741
+ resultCache.value = Object.fromEntries([
742
+ ...Object.entries(resultCache.value)
743
+ .filter(([candidate]) => retained.has(candidate) && candidate !== jobId),
744
+ [jobId, result],
745
+ ])
746
+ for (const candidate of downloadRefreshTimers.keys()) {
747
+ if (!retained.has(candidate)) clearManagedDownloadRefresh(candidate)
748
+ }
749
+ scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result))
750
+ }
751
+
752
+ function pruneResultState(): void {
753
+ const loadableJobIds = new Set(jobs.value
754
+ .filter(job => isLoadableResultJob(job))
755
+ .map(job => job.job_id))
756
+ resultCacheOrder = resultCacheOrder
757
+ .filter(jobId => loadableJobIds.has(jobId))
758
+ .slice(0, MAX_RESULT_CACHE_ENTRIES)
759
+ const retained = new Set(resultCacheOrder)
760
+ resultCache.value = Object.fromEntries(
761
+ Object.entries(resultCache.value).filter(([jobId]) => retained.has(jobId)),
762
+ )
763
+ for (const jobId of resultRetryTimers.keys()) {
764
+ if (!loadableJobIds.has(jobId)) clearResultRetry(jobId)
765
+ }
766
+ for (const jobId of resultRetryAttempts.keys()) {
767
+ if (!loadableJobIds.has(jobId)) clearResultRetry(jobId)
768
+ }
769
+ for (const jobId of downloadRefreshTimers.keys()) {
770
+ if (!loadableJobIds.has(jobId) || !retained.has(jobId)) {
771
+ clearManagedDownloadRefresh(jobId)
772
+ }
773
+ }
774
+ }
775
+
776
+ function scheduleManagedDownloadRefresh(jobId: string, delay: number | null): void {
777
+ clearManagedDownloadRefresh(jobId)
778
+ if (delay === null || disposed || paused) return
779
+ downloadRefreshTimers.set(jobId, setTimeout(() => {
780
+ downloadRefreshTimers.delete(jobId)
781
+ if (disposed || paused) return
782
+ const current = jobs.value.find(candidate => candidate.job_id === jobId)
783
+ if (current && isLoadableResultJob(current)) {
784
+ void loadResultInternal(current, true)
785
+ }
786
+ }, Math.max(0, delay)))
787
+ }
788
+
789
+ function clearManagedDownloadRefresh(jobId: string): void {
790
+ const timer = downloadRefreshTimers.get(jobId)
791
+ if (timer !== undefined) clearTimeout(timer)
792
+ downloadRefreshTimers.delete(jobId)
793
+ }
794
+
795
+ function handleManagedDownloadRefreshFailure(
796
+ job: GeneratedAnalysisJob,
797
+ cause: unknown,
798
+ ): void {
799
+ if (cause instanceof GeneratedAnalysisRequestError) {
800
+ if (cause.status === 404 || cause.status === 410) {
801
+ discardCachedResult(job.job_id)
802
+ jobs.value = jobs.value.map(candidate => (
803
+ candidate.job_id === job.job_id
804
+ ? { ...candidate, result_status: 'expired' }
805
+ : candidate
806
+ ))
807
+ recordJobsMutation([job.job_id])
808
+ return
809
+ }
810
+ if (cause.status === 401 || cause.status === 403) {
811
+ discardCachedResult(job.job_id)
812
+ return
813
+ }
814
+ }
815
+ scheduleResultRetry(job, cause, true)
816
+ }
817
+
818
+ function discardCachedResult(jobId: string): void {
819
+ clearResultRetry(jobId)
820
+ clearManagedDownloadRefresh(jobId)
821
+ resultCacheOrder = resultCacheOrder.filter(candidate => candidate !== jobId)
822
+ const retainedResults = { ...resultCache.value }
823
+ delete retainedResults[jobId]
824
+ resultCache.value = retainedResults
825
+ }
826
+
827
+ function isLoadableResultJob(job: GeneratedAnalysisJob): boolean {
828
+ return job.status === 'completed'
829
+ && job.result_status === 'available'
830
+ }
831
+
832
+ function isCurrentResultJob(jobId: string): boolean {
833
+ const job = jobs.value.find(candidate => candidate.job_id === jobId)
834
+ return job !== undefined && isLoadableResultJob(job)
835
+ }
836
+
837
+ function scheduleResultRetry(
838
+ job: GeneratedAnalysisJob,
839
+ cause: unknown,
840
+ forceRefresh = false,
841
+ ): void {
842
+ if (
843
+ paused
844
+ || !isRetryableResultError(cause)
845
+ || resultRetryTimers.has(job.job_id)
846
+ ) return
847
+ const attempt = resultRetryAttempts.get(job.job_id) ?? 0
848
+ if (attempt >= MAX_RESULT_RETRIES) return
849
+ resultRetryAttempts.set(job.job_id, attempt + 1)
850
+ resultRetryTimers.set(job.job_id, setTimeout(() => {
851
+ resultRetryTimers.delete(job.job_id)
852
+ const current = jobs.value.find(candidate => candidate.job_id === job.job_id)
853
+ if (!paused && current && isLoadableResultJob(current)) {
854
+ void loadResultInternal(current, forceRefresh)
855
+ }
856
+ }, pollInterval * (2 ** attempt)))
857
+ }
858
+
859
+ function clearResultRetry(jobId: string): void {
860
+ const timer = resultRetryTimers.get(jobId)
861
+ if (timer !== undefined) clearTimeout(timer)
862
+ resultRetryTimers.delete(jobId)
863
+ resultRetryAttempts.delete(jobId)
864
+ }
865
+
866
+ function schedulePoll(): void {
867
+ if (disposed || paused || streamConnected || pollTimer !== undefined) return
868
+ pollTimer = setTimeout(async () => {
869
+ pollTimer = undefined
870
+ if (disposed) return
871
+ try {
872
+ await refreshJobs()
873
+ } catch (cause) {
874
+ error.value = errorMessage(cause, 'Failed to refresh analysis jobs')
875
+ if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
876
+ }
877
+ }, pollInterval)
878
+ }
879
+
880
+ async function connectJobStream(): Promise<void> {
881
+ if (disposed || paused || streamConnecting || streamConnected) return
882
+ streamConnecting = true
883
+ streamController = new AbortController()
884
+ try {
885
+ const response = await transport(`${baseUrl}/jobs/events`, {
886
+ method: 'GET',
887
+ headers: { Accept: 'text/event-stream' },
888
+ signal: streamController.signal,
889
+ })
890
+ if (!response.ok) throw new Error(`Job stream failed (${response.status})`)
891
+ if (!response.body) throw new Error('Job stream response has no readable body')
892
+ streamConnected = true
893
+ if (pollTimer !== undefined) {
894
+ clearTimeout(pollTimer)
895
+ pollTimer = undefined
896
+ }
897
+ await consumeJobStream(response.body)
898
+ } catch (cause) {
899
+ if (!isAbortError(cause) && !disposed) {
900
+ if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
901
+ }
902
+ } finally {
903
+ streamConnected = false
904
+ streamConnecting = false
905
+ streamController = undefined
906
+ if (!disposed && !paused) scheduleStreamReconnect()
907
+ }
908
+ }
909
+
910
+ async function consumeJobStream(body: ReadableStream<Uint8Array>): Promise<void> {
911
+ const reader = body.getReader()
912
+ const decoder = new TextDecoder()
913
+ let buffer = ''
914
+ try {
915
+ while (!disposed) {
916
+ const { done, value } = await reader.read()
917
+ buffer += decoder.decode(value, { stream: !done })
918
+ const blocks = buffer.split(/\r?\n\r?\n/)
919
+ buffer = blocks.pop() ?? ''
920
+ for (const block of blocks) applyJobStreamEvent(block)
921
+ if (done) {
922
+ if (buffer.trim()) applyJobStreamEvent(buffer)
923
+ return
924
+ }
925
+ }
926
+ } finally {
927
+ reader.releaseLock()
928
+ }
929
+ }
930
+
931
+ function applyJobStreamEvent(block: string): void {
932
+ let event = 'message'
933
+ const data: string[] = []
934
+ for (const line of block.split(/\r?\n/)) {
935
+ if (!line || line.startsWith(':')) continue
936
+ if (line.startsWith('event:')) event = line.slice('event:'.length).trim()
937
+ if (line.startsWith('data:')) data.push(line.slice('data:'.length).trimStart())
938
+ }
939
+ if (!data.length) return
940
+ const payload = JSON.parse(data.join('\n')) as (
941
+ GeneratedAnalysisJobInput | { jobs: GeneratedAnalysisJobInput[] }
942
+ )
943
+ if (event === 'snapshot' && 'jobs' in payload) {
944
+ replaceJobs(payload.jobs)
945
+ } else if (event === 'job' || event === 'terminal') {
946
+ upsertJob(payload as GeneratedAnalysisJobInput)
947
+ }
948
+ error.value = null
949
+ }
950
+
951
+ function scheduleStreamReconnect(): void {
952
+ if (disposed || paused || streamReconnectTimer !== undefined) return
953
+ streamReconnectTimer = setTimeout(() => {
954
+ streamReconnectTimer = undefined
955
+ void connectJobStream()
956
+ }, streamReconnectDelay)
957
+ }
958
+
959
+ async function cancelJob(job: GeneratedAnalysisJob | string): Promise<GeneratedAnalysisJob> {
960
+ const jobId = typeof job === 'string' ? job : job.job_id
961
+ const revision = beginJobOperation(jobId)
962
+ try {
963
+ const updated = await request<GeneratedAnalysisJobInput>(
964
+ `/jobs/${encodeURIComponent(jobId)}/cancel`,
965
+ { method: 'POST' },
966
+ )
967
+ return mergeJobResponse(updated, revision)
968
+ } finally {
969
+ finishJobOperation(jobId)
970
+ }
971
+ }
972
+
973
+ async function deleteJob(job: GeneratedAnalysisJob | string): Promise<boolean> {
974
+ const jobId = typeof job === 'string' ? job : job.job_id
975
+ await request<{ status: 'deleted' }>(
976
+ `/jobs/${encodeURIComponent(jobId)}`,
977
+ { method: 'DELETE' },
978
+ )
979
+ jobs.value = jobs.value.filter(candidate => candidate.job_id !== jobId)
980
+ recordJobsMutation([jobId])
981
+ rejectJobWaiters(jobId, new Error(`Job ${jobId} was deleted`))
982
+ discardCachedResult(jobId)
983
+ pruneJobRevisions()
984
+ return true
985
+ }
986
+
987
+ async function clearFinishedJobs(definitionId?: string): Promise<number> {
988
+ const query = definitionId === undefined
989
+ ? ''
990
+ : `?definition_id=${encodeURIComponent(definitionId)}`
991
+ const payload = await request<{ deleted: number }>(`/jobs${query}`, { method: 'DELETE' })
992
+ if (payload.deleted > 0) await refreshJobs()
993
+ return payload.deleted
994
+ }
995
+
996
+ const jobsAdapter: JobsStatusTrayAdapter = {
997
+ listJobs: async () => ({ jobs: await refreshJobs() }),
998
+ cancelJob: async job => cancelJob(job as GeneratedAnalysisJob),
999
+ deleteJob: async job => deleteJob(job as GeneratedAnalysisJob),
1000
+ clearFinishedJobs: async () => clearFinishedJobs(),
1001
+ }
1002
+ function pause(): void {
1003
+ if (disposed || paused) return
1004
+ paused = true
1005
+ status.value = 'paused'
1006
+ if (pollTimer !== undefined) clearTimeout(pollTimer)
1007
+ if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
1008
+ pollTimer = undefined
1009
+ streamReconnectTimer = undefined
1010
+ for (const jobId of resultRetryTimers.keys()) clearResultRetry(jobId)
1011
+ for (const jobId of downloadRefreshTimers.keys()) {
1012
+ clearManagedDownloadRefresh(jobId)
1013
+ }
1014
+ streamController?.abort()
1015
+ }
1016
+
1017
+ function resume(): void {
1018
+ if (disposed || !paused) return
1019
+ paused = false
1020
+ status.value = initialized ? 'ready' : 'idle'
1021
+ if (!initialized) return
1022
+ for (const [jobId, result] of Object.entries(resultCache.value)) {
1023
+ scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result))
1024
+ }
1025
+ if (streamEnabled) void connectJobStream()
1026
+ else if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
1027
+ }
1028
+
1029
+ function dispose(): void {
1030
+ if (disposed) return
1031
+ disposed = true
1032
+ paused = true
1033
+ status.value = 'disposed'
1034
+ if (pollTimer !== undefined) clearTimeout(pollTimer)
1035
+ if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
1036
+ pollTimer = undefined
1037
+ streamReconnectTimer = undefined
1038
+ for (const timer of resultRetryTimers.values()) clearTimeout(timer)
1039
+ resultRetryTimers.clear()
1040
+ resultRetryAttempts.clear()
1041
+ for (const timer of downloadRefreshTimers.values()) clearTimeout(timer)
1042
+ downloadRefreshTimers.clear()
1043
+ const cause = new Error('Plugin jobs runtime is disposed')
1044
+ for (const jobId of jobWaiters.keys()) rejectJobWaiters(jobId, cause)
1045
+ jobRevisions.clear()
1046
+ pendingJobOperations.clear()
1047
+ streamController?.abort()
1048
+ }
1049
+
1050
+ async function request<T>(path: string, init: RequestInit): Promise<T> {
1051
+ const response = await transport(`${baseUrl}${path}`, {
1052
+ ...init,
1053
+ headers: {
1054
+ Accept: 'application/json',
1055
+ ...(init.body && !(init.body instanceof FormData)
1056
+ ? { 'Content-Type': 'application/json' }
1057
+ : {}),
1058
+ ...init.headers,
1059
+ },
1060
+ })
1061
+ if (!response.ok) {
1062
+ const payload = await response.json().catch(() => null) as { detail?: unknown } | null
1063
+ throw new GeneratedAnalysisRequestError(response.status, payload?.detail)
1064
+ }
1065
+ return await response.json() as T
1066
+ }
1067
+
1068
+ onScopeDispose(dispose)
1069
+
1070
+ if (options.immediate) void initialize().catch(() => undefined)
1071
+
1072
+ return {
1073
+ manifest,
1074
+ session,
1075
+ jobs,
1076
+ latestResult,
1077
+ isLoading,
1078
+ isSubmitting,
1079
+ error,
1080
+ status,
1081
+ initialize,
1082
+ ready: initialize,
1083
+ uploadPath,
1084
+ uploadStagedPart,
1085
+ getJobStaging,
1086
+ sealJob,
1087
+ submit,
1088
+ loadResult,
1089
+ resultForJob,
1090
+ refreshJobs,
1091
+ refresh,
1092
+ getJob,
1093
+ waitForJob,
1094
+ jobsFor,
1095
+ cancelJob,
1096
+ deleteJob,
1097
+ clearFinishedJobs,
1098
+ pause,
1099
+ resume,
1100
+ dispose,
1101
+ jobsAdapter,
1102
+ }
1103
+ }
1104
+
1105
+ function jobIdFromReference(job: GeneratedAnalysisJob | string): string {
1106
+ return typeof job === 'string' ? job : job.job_id
1107
+ }
1108
+
1109
+ function encodeStagedPartKey(key: string): string {
1110
+ return key.split('/').map(segment => encodeURIComponent(segment)).join('/')
1111
+ }
1112
+
1113
+ function jobLifecycleRank(status: GeneratedAnalysisJob['status']): number {
1114
+ if (status === 'awaiting_input') return 0
1115
+ if (status === 'queued') return 1
1116
+ if (status === 'running') return 2
1117
+ return 3
1118
+ }
1119
+
1120
+ function shouldPreserveJobLifecycle(
1121
+ current: GeneratedAnalysisJob,
1122
+ incoming: GeneratedAnalysisJob,
1123
+ ): boolean {
1124
+ const currentRank = jobLifecycleRank(current.status)
1125
+ const incomingRank = jobLifecycleRank(incoming.status)
1126
+ return incomingRank < currentRank
1127
+ || (currentRank === 3 && incomingRank === 3 && current.status !== incoming.status)
1128
+ }
1129
+
1130
+ function terminalWaitResult(
1131
+ job: GeneratedAnalysisJob,
1132
+ rejectOnFailure: boolean,
1133
+ ): Promise<GeneratedAnalysisJob> {
1134
+ if (rejectOnFailure && (job.status === 'failed' || job.status === 'cancelled')) {
1135
+ return Promise.reject(new Error(job.error ?? `Job ${job.job_id} ${job.status}`))
1136
+ }
1137
+ return Promise.resolve(job)
1138
+ }
1139
+
1140
+ function pluginJobWaitAbort(signal: AbortSignal): unknown {
1141
+ if (signal.reason !== undefined) return signal.reason
1142
+ const cause = new Error('Waiting for job was aborted')
1143
+ cause.name = 'AbortError'
1144
+ return cause
1145
+ }
1146
+
1147
+ function isPluginJobPathInput(value: unknown): value is PluginJobPathInput {
1148
+ return Boolean(
1149
+ value
1150
+ && typeof value === 'object'
1151
+ && 'kind' in value
1152
+ && value.kind === 'path'
1153
+ && 'files' in value
1154
+ && Array.isArray(value.files)
1155
+ && 'relativePaths' in value
1156
+ && Array.isArray(value.relativePaths),
1157
+ )
1158
+ }
1159
+
1160
+ function readStoredAccessToken(): string | null {
1161
+ try {
1162
+ return globalThis.localStorage?.getItem(AUTH_TOKEN_KEY) ?? null
1163
+ } catch {
1164
+ return null
1165
+ }
1166
+ }
1167
+
1168
+ function createGeneratedSessionStorage(candidate?: Storage): GeneratedSessionStorage {
1169
+ const fallback = new Map<string, string>()
1170
+ let delegate: Storage | undefined
1171
+ let available = true
1172
+ try {
1173
+ delegate = candidate ?? globalThis.sessionStorage
1174
+ } catch {
1175
+ available = false
1176
+ }
1177
+ return {
1178
+ getItem(key) {
1179
+ if (available && delegate) {
1180
+ try {
1181
+ return delegate.getItem(key) ?? fallback.get(key) ?? null
1182
+ } catch {
1183
+ available = false
1184
+ }
1185
+ }
1186
+ return fallback.get(key) ?? null
1187
+ },
1188
+ setItem(key, value) {
1189
+ fallback.set(key, value)
1190
+ if (available && delegate) {
1191
+ try {
1192
+ delegate.setItem(key, value)
1193
+ } catch {
1194
+ available = false
1195
+ }
1196
+ }
1197
+ },
1198
+ }
1199
+ }
1200
+
1201
+ function isAbortError(error: unknown): boolean {
1202
+ return error instanceof DOMException && error.name === 'AbortError'
1203
+ }
1204
+
1205
+ function isSessionNotFoundError(error: unknown): boolean {
1206
+ return error instanceof GeneratedAnalysisRequestError
1207
+ && error.status === 404
1208
+ && error.detail === SESSION_NOT_FOUND_DETAIL
1209
+ }
1210
+
1211
+ function isRetryableResultError(error: unknown): boolean {
1212
+ return !(error instanceof GeneratedAnalysisRequestError)
1213
+ || error.status === 409
1214
+ || error.status === 429
1215
+ || error.status >= 500
1216
+ }
1217
+
1218
+ function isRetryableJobLookupError(error: unknown): boolean {
1219
+ return !(error instanceof GeneratedAnalysisRequestError)
1220
+ || error.status === 408
1221
+ || error.status === 429
1222
+ || error.status >= 500
1223
+ }
1224
+
1225
+ function isManagedDownloadResult(
1226
+ result: GeneratedAnalysisResult,
1227
+ ): result is Extract<GeneratedAnalysisResult, { kind: 'artifact' }> {
1228
+ return result.kind === 'artifact' && result.managed === true
1229
+ }
1230
+
1231
+ function managedDownloadNeedsRefresh(result: GeneratedAnalysisResult): boolean {
1232
+ if (!isManagedDownloadResult(result)) return false
1233
+ if (!result.download_url || !result.download_expires_at) return true
1234
+ const expiresAt = Date.parse(result.download_expires_at)
1235
+ return !Number.isFinite(expiresAt)
1236
+ || expiresAt <= Date.now() + DOWNLOAD_URL_REFRESH_SKEW_MS
1237
+ }
1238
+
1239
+ function managedDownloadRefreshDelay(result: GeneratedAnalysisResult): number | null {
1240
+ if (!isManagedDownloadResult(result)) return null
1241
+ if (!result.download_url || !result.download_expires_at) return null
1242
+ const expiresAt = Date.parse(result.download_expires_at)
1243
+ if (!Number.isFinite(expiresAt)) return null
1244
+ const remaining = expiresAt - Date.now()
1245
+ return remaining <= DOWNLOAD_URL_REFRESH_SKEW_MS
1246
+ ? remaining
1247
+ : remaining - DOWNLOAD_URL_REFRESH_SKEW_MS
1248
+ }
1249
+
1250
+ function errorMessage(error: unknown, fallback: string): string {
1251
+ return error instanceof Error ? error.message : fallback
1252
+ }