@morscherlab/mint-sdk 1.0.63 → 1.1.0-beta.10

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