@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.
- package/README.md +101 -2
- package/dist/EmptyState-D1c4RdC7.js +638 -0
- package/dist/EmptyState-D1c4RdC7.js.map +1 -0
- package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
- package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
- package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
- package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
- package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
- package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
- package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
- package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
- package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
- package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
- package/dist/__tests__/generatedUi.test.d.ts +1 -0
- package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
- package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -0
- package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
- package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
- package/dist/chunk-BoAXSpZd.js +33 -0
- package/dist/components/AppSidebar.vue.d.ts +1 -1
- package/dist/components/AutoGroupModal.vue.d.ts +2 -2
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/CollapsibleCard.vue.d.ts +1 -1
- package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
- package/dist/components/ConfirmDialog.vue.d.ts +1 -1
- package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
- package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
- package/dist/components/FileUploader.vue.d.ts +4 -2
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/components/FormActions.vue.d.ts +1 -1
- package/dist/components/FormBuilder.vue.d.ts +1 -1
- package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
- package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
- package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
- package/dist/components/JobsStatusTray.vue.d.ts +5 -1
- package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +2 -2
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +8 -9
- package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
- package/dist/{components-DdzHh3a_.js → components-UfkPN1bK.js} +2255 -2303
- package/dist/components-UfkPN1bK.js.map +1 -0
- package/dist/composables/index.d.ts +2 -3
- package/dist/composables/index.js +10 -11
- package/dist/composables/platformContextHelpers.d.ts +0 -2
- package/dist/composables/useAnalysisArtifacts.d.ts +1 -3
- package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
- package/dist/composables/useJobsStatusTray.d.ts +48 -4
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/usePluginClient.d.ts +6 -3
- package/dist/{composables-BUUphBqg.js → composables-B512bWJP.js} +155 -74
- package/dist/composables-B512bWJP.js.map +1 -0
- package/dist/experiment-utils-CxkruZ3G.js +392 -0
- package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
- package/dist/generated/schema.d.ts +7 -0
- package/dist/generated-ui/main.d.ts +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -18
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -8
- package/dist/install.js.map +1 -1
- package/dist/plotly.min-BR53hja4.js +180634 -0
- package/dist/plotly.min-BR53hja4.js.map +1 -0
- package/dist/stores/auth.d.ts +2 -2
- package/dist/stores/index.js +1 -1
- package/dist/styles.css +10543 -10404
- package/dist/templates/index.js +4 -3
- package/dist/templates/packs.d.ts +0 -1
- package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
- package/dist/templates-Cttbjbyc.js.map +1 -0
- package/dist/types/analysisArtifactTypes.d.ts +0 -8
- package/dist/types/form-builder.d.ts +1 -1
- package/dist/types/generated-ui.d.ts +170 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.js +1 -1
- package/dist/types/instrument.d.ts +1 -0
- package/dist/types/jobs.d.ts +7 -1
- package/dist/types/platform.d.ts +2 -0
- package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
- package/dist/useControlSchema-Dallso-L.js.map +1 -0
- package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
- package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
- package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
- package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
- package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
- package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
- package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-BP7_4RGQ.js} +307 -475
- package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
- package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
- package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
- package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
- package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
- package/src/__tests__/composables/useAnalysisArtifacts.test.ts +1 -74
- package/src/__tests__/composables/useApi.test.ts +8 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
- package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
- package/src/__tests__/composables/usePluginClient.test.ts +366 -20
- package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
- package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
- package/src/__tests__/generatedUi.test.ts +119 -0
- package/src/__tests__/templates/templates.test.ts +1 -1
- package/src/components/FileUploader.vue +49 -11
- package/src/components/GeneratedPathInput.vue +218 -0
- package/src/components/GeneratedPlotlyResult.vue +87 -0
- package/src/components/GeneratedPluginView.vue +279 -0
- package/src/components/GeneratedResultRenderer.vue +137 -0
- package/src/components/InstrumentAlertLog.vue +2 -2
- package/src/components/JobsStatusTray.vue +14 -8
- package/src/components/index.ts +4 -0
- package/src/composables/formBuilderRegistry.ts +2 -0
- package/src/composables/index.ts +18 -5
- package/src/composables/platformContextHelpers.ts +3 -10
- package/src/composables/useAnalysisArtifacts.ts +0 -48
- package/src/composables/useApi.ts +1 -0
- package/src/composables/useGeneratedAnalysis.ts +1252 -0
- package/src/composables/useJobsStatusTray.ts +234 -45
- package/src/composables/usePluginClient.ts +152 -25
- package/src/composables/usePluginConfig.ts +156 -20
- package/src/generated/schema.ts +130 -0
- package/src/generated-ui/main.ts +21 -0
- package/src/index.ts +3 -0
- package/src/jobs.ts +5 -0
- package/src/plotly-dist.d.ts +3 -0
- package/src/templates/packs.ts +0 -3
- package/src/types/analysisArtifactTypes.ts +0 -9
- package/src/types/form-builder.ts +1 -0
- package/src/types/generated-ui.ts +176 -0
- package/src/types/index.ts +2 -1
- package/src/types/instrument.ts +1 -0
- package/src/types/jobs.ts +10 -1
- package/src/types/platform.ts +2 -0
- package/dist/BaseButton-Dgqrze41.js +0 -74
- package/dist/BaseButton-Dgqrze41.js.map +0 -1
- package/dist/BaseModal-B9UA8Y_I.js +0 -165
- package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
- package/dist/BaseSelect-ekgr9fDo.js +0 -179
- package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
- package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
- package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
- package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
- package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
- package/dist/analysisArtifactTypes-Cu40dzSG.js.map +0 -1
- package/dist/components-DdzHh3a_.js.map +0 -1
- package/dist/composables/analysisArtifactTarget.d.ts +0 -5
- package/dist/composables-BUUphBqg.js.map +0 -1
- package/dist/experiment-utils-Bfa7CwPU.js +0 -108
- package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
- package/dist/templates-CNbPQNID.js.map +0 -1
- package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
- package/dist/useControlSchema-BZNdalmL.js.map +0 -1
- package/dist/useEventListener-CfVkP9Xz.js +0 -57
- package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
- package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
- package/src/__tests__/composables/analysisArtifactTarget.test.ts +0 -77
- package/src/composables/analysisArtifactTarget.ts +0 -53
- /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
|
+
}
|