import { computed, onScopeDispose, ref, shallowRef, type ComputedRef, type Ref, type ShallowRef, } from 'vue' import type { JobsStatusTrayAdapter, PluginJobCenterSource, } from './useJobsStatusTray' import { useEventStream, type EventStreamMessage } from './useEventStream' import { isActiveJobStatus, normalizeJobState } from '../jobs' import { MintApiError, mintApiErrorFromResponse } from '../apiErrors' import type { GeneratedAnalysisJob, GeneratedAnalysisJobInput, GeneratedAnalysisResult, GeneratedSession, GeneratedAnalysisManifest, PluginJobArtifactPathInput, PluginJobPathInput, PluginJobPathUpload, PluginJobStagedPartInput, PluginJobStagedPartUploadResult, PluginJobStaging, PluginJobStagingPagination, } from '../types/generated-ui' export type GeneratedAnalysisTransport = ( input: RequestInfo | URL, init?: RequestInit, ) => Promise export interface GeneratedAnalysisTransportOptions { fetcher?: typeof fetch getAccessToken?: () => string | null | undefined } export interface UsePluginJobsOptions { baseUrl: string transport?: GeneratedAnalysisTransport storage?: Storage createId?: () => string pollInterval?: number stream?: boolean streamReconnectDelay?: number immediate?: boolean } export type GeneratedAnalysisOptions = UsePluginJobsOptions export interface PluginJobWaitOptions { signal?: AbortSignal rejectOnFailure?: boolean } export interface PluginJobRequestOptions { signal?: AbortSignal } interface GeneratedSessionStorage { getItem: (key: string) => string | null setItem: (key: string, value: string) => void } export interface UsePluginJobsReturn extends PluginJobCenterSource { manifest: ShallowRef session: ShallowRef jobs: ShallowRef latestResult: ComputedRef isLoading: Ref isSubmitting: Ref error: ShallowRef status: ShallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'> initialize: () => Promise ready: () => Promise uploadPath: ( input: PluginJobPathInput, definitionId?: string, ) => Promise uploadStagedPart: ( input: PluginJobStagedPartInput, ) => Promise> getJobStaging: ( job: GeneratedAnalysisJob | string, pagination?: PluginJobStagingPagination, ) => Promise> sealJob: (job: GeneratedAnalysisJob | string) => Promise submit: (definitionId: string, inputs: Record) => Promise loadResult: (job: GeneratedAnalysisJob) => Promise resultForJob: (jobId: string) => GeneratedAnalysisResult | null refreshJobs: () => Promise refresh: () => Promise getJob: (jobId: string, options?: PluginJobRequestOptions) => Promise waitForJob: ( job: GeneratedAnalysisJob | string, options?: PluginJobWaitOptions, ) => Promise jobsFor: (definitionId: string) => GeneratedAnalysisJob[] cancelJob: (job: GeneratedAnalysisJob | string) => Promise deleteJob: (job: GeneratedAnalysisJob | string) => Promise clearFinishedJobs: (definitionId?: string) => Promise pause: () => void resume: () => void dispose: () => void /** @deprecated Pass the runtime itself to SDK job UI as its source. */ jobsAdapter: JobsStatusTrayAdapter } export type UseGeneratedAnalysisReturn = UsePluginJobsReturn const AUTH_TOKEN_KEY = 'mint-auth-token' const SESSION_NOT_FOUND_DETAIL = 'Session not found' const MAX_RESULT_CACHE_ENTRIES = 32 const MAX_RESULT_RETRIES = 3 const DOWNLOAD_URL_REFRESH_SKEW_MS = 30_000 interface PluginJobWaiter { resolve: (job: GeneratedAnalysisJob) => void reject: (cause: unknown) => void rejectOnFailure: boolean signal?: AbortSignal abort?: () => void } type PluginJobStreamData = GeneratedAnalysisJobInput | { jobs: GeneratedAnalysisJobInput[] } export function createGeneratedAnalysisTransport( options: GeneratedAnalysisTransportOptions = {}, ): GeneratedAnalysisTransport { const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis) const getAccessToken = options.getAccessToken ?? readStoredAccessToken return async (input, init = {}) => { const headers = new Headers(init.headers) const accessToken = getAccessToken() if (accessToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${accessToken}`) } return await fetcher(input, { credentials: 'same-origin', ...init, headers, }) } } export function useGeneratedAnalysis( options: GeneratedAnalysisOptions, ): UseGeneratedAnalysisReturn { return createPluginJobs(options, '/generated/manifest') } /** * Connect a custom plugin frontend to the SDK-owned job lifecycle. * * @example Recommended * ```ts * const jobs = usePluginJobs({ baseUrl: '/api/leaf/jobs' }) * await jobs.initialize() * ``` */ export function usePluginJobs( options: UsePluginJobsOptions, ): UsePluginJobsReturn { return createPluginJobs(options) } function createPluginJobs( options: UsePluginJobsOptions, manifestPath?: string, ): UsePluginJobsReturn { const baseUrl = options.baseUrl.replace(/\/$/, '') const transport = options.transport ?? createGeneratedAnalysisTransport() const storage = createGeneratedSessionStorage(options.storage) const createId = options.createId ?? (() => globalThis.crypto.randomUUID()) const pollInterval = options.pollInterval ?? 750 const streamEnabled = options.stream ?? true const streamReconnectDelay = options.streamReconnectDelay ?? 1_500 const tabKey = `mint:generated:${baseUrl}:tab` const sessionKey = `mint:generated:${baseUrl}:session` const manifest = shallowRef(null) const session = shallowRef(null) const jobs = shallowRef([]) const jobRevisions = new Map() const pendingJobOperations = new Map() const resultCache = shallowRef>({}) const isLoading = ref(false) const isSubmitting = ref(false) const error = shallowRef(null) const status = shallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>('idle') let pollTimer: ReturnType | undefined let disposed = false let paused = false let initialized = false let initializePromise: Promise | undefined let refreshPromise: Promise | undefined let initializeRefreshPromise: Promise | undefined let jobsRevision = 0 let resultCacheOrder: string[] = [] const resultRequests = new Map>() const jobWaiters = new Map>() const resultRetryAttempts = new Map() const resultRetryTimers = new Map>() const downloadRefreshTimers = new Map>() const jobStream = useEventStream(`${baseUrl}/jobs/events`, { transport, immediate: false, reconnectDelayMs: streamReconnectDelay, parseJson: true, errorLabel: 'Job stream', onOpen: clearPoll, onMessage: applyJobStreamMessage, onError: schedulePollForActiveJobs, onClose: schedulePollForActiveJobs, }) const latestResult = computed(() => { const job = currentCompletedJob() return job ? resultForJob(job.job_id) : null }) function initialize(): Promise { if (disposed) return Promise.reject(new Error('Plugin jobs runtime is disposed')) if (initialized) return Promise.resolve() if (initializePromise) return initializePromise isLoading.value = true error.value = null status.value = 'initializing' const pending = (async () => { if (manifestPath) { const loadedManifest = await request(manifestPath, { method: 'GET' }) if (!disposed) manifest.value = loadedManifest } const resolvedSession = await resolveSession() if (disposed) return session.value = resolvedSession await refreshJobs() if (disposed) return initialized = true status.value = paused ? 'paused' : 'ready' if (streamEnabled && !paused) jobStream.start() })().catch((cause: unknown) => { if (!disposed) { error.value = errorMessage(cause, 'Failed to initialize generated analysis') status.value = 'error' } throw cause }).finally(() => { if (initializePromise === pending) initializePromise = undefined if (!disposed) isLoading.value = false }) initializePromise = pending return pending } async function resolveSession(): Promise { let tabId = storage.getItem(tabKey) if (!tabId) { tabId = createId() storage.setItem(tabKey, tabId) } const storedSessionId = storage.getItem(sessionKey) const payload = await request<{ sessions: GeneratedSession[] }>('/jobs/sessions', { method: 'GET' }) const existing = payload.sessions.find(candidate => ( candidate.id === storedSessionId && candidate.tab_id === tabId )) if (existing) return existing const created = await request('/jobs/sessions', { method: 'POST', body: JSON.stringify({ tab_id: tabId }), }) storage.setItem(sessionKey, created.id) return created } async function submit( definitionId: string, inputs: Record, ): Promise { if (!session.value) throw new Error('Generated analysis session is not initialized') const clientRequestId = createId() isSubmitting.value = true error.value = null try { let jobInput: GeneratedAnalysisJobInput try { jobInput = await submitToSession( session.value.id, clientRequestId, await stagePathInputs(inputs, session.value.id, definitionId), ) } catch (cause) { if (!isSessionNotFoundError(cause)) throw cause session.value = await resolveSession() jobInput = await submitToSession( session.value.id, clientRequestId, await stagePathInputs(inputs, session.value.id, definitionId), ) } const job = upsertJob(jobInput, definitionId) if (!jobStream.isConnected.value) schedulePoll() return job } catch (cause) { error.value = errorMessage(cause, 'Failed to submit analysis') throw cause } finally { isSubmitting.value = false } function submitToSession( sessionId: string, requestId: string, stagedInputs: Record, ): Promise { return request('/jobs', { method: 'POST', body: JSON.stringify({ definition_id: definitionId, inputs: stagedInputs, session_id: sessionId, client_request_id: requestId, }), }) } } async function uploadPath( input: PluginJobPathInput, definitionId?: string, ): Promise { if (!session.value) throw new Error('Generated analysis session is not initialized') return uploadPathForSession(input, session.value.id, definitionId) } async function uploadStagedPart( input: PluginJobStagedPartInput, ): Promise> { const jobId = jobIdFromReference(input.job) const body = new FormData() body.append('file', input.file) body.append('metadata', JSON.stringify(input.metadata)) if (input.expectedSize !== undefined) { body.append('expected_size', String(input.expectedSize)) } if (input.expectedSha256 !== undefined) { body.append('expected_sha256', input.expectedSha256) } return request>( `/jobs/${encodeURIComponent(jobId)}/parts/${encodeStagedPartKey(input.key)}`, { method: 'PUT', body, signal: input.signal }, ) } function getJobStaging( job: GeneratedAnalysisJob | string, pagination: PluginJobStagingPagination = {}, ): Promise> { const jobId = jobIdFromReference(job) const query = new URLSearchParams() if (pagination.offset !== undefined) query.set('offset', String(pagination.offset)) if (pagination.limit !== undefined) query.set('limit', String(pagination.limit)) const suffix = query.size > 0 ? `?${query}` : '' return request>( `/jobs/${encodeURIComponent(jobId)}/staging${suffix}`, { method: 'GET' }, ) } async function sealJob( job: GeneratedAnalysisJob | string, ): Promise { const jobId = jobIdFromReference(job) const revision = beginJobOperation(jobId) try { const updated = await request( `/jobs/${encodeURIComponent(jobId)}/seal`, { method: 'POST' }, ) return mergeJobResponse(updated, revision) } finally { finishJobOperation(jobId) } } async function uploadPathForSession( input: PluginJobPathInput, sessionId: string, definitionId?: string, ): Promise { if (!input.files.length || input.files.length !== input.relativePaths.length) { throw new Error('Path upload requires one relative path for each file') } const body = new FormData() body.append('session_id', sessionId) if (input.rootName) body.append('root_name', input.rootName) for (let index = 0; index < input.files.length; index += 1) { body.append('files', input.files[index]!) body.append('relative_paths', input.relativePaths[index]!) } const headers: Record = { 'X-Mint-Job-Session': sessionId, } if (definitionId) headers['X-Mint-Job-Definition'] = definitionId return request('/jobs/uploads', { method: 'POST', body, signal: input.signal, headers, }) } async function stageArtifactPathForSession( input: PluginJobArtifactPathInput, sessionId: string, definitionId: string, ): Promise { return request('/jobs/artifacts', { method: 'POST', body: JSON.stringify({ experiment_id: input.experimentId, artifact_id: input.artifactId, session_id: sessionId, }), signal: input.signal, headers: { 'X-Mint-Job-Session': sessionId, 'X-Mint-Job-Definition': definitionId, }, }) } async function stagePathInputs( inputs: Record, sessionId: string, definitionId: string, ): Promise> { return await stagePathValue(inputs, sessionId, definitionId) as Record } async function stagePathValue( value: unknown, sessionId: string, definitionId: string, ): Promise { if (typeof File !== 'undefined' && value instanceof File) { const upload = await uploadPathForSession({ kind: 'path', files: [value], relativePaths: [value.name], }, sessionId, definitionId) return { token: upload.token } } if (isPluginJobPathInput(value)) { const upload = await uploadPathForSession(value, sessionId, definitionId) return { token: upload.token } } if (isPluginJobArtifactPathInput(value)) { const upload = await stageArtifactPathForSession(value, sessionId, definitionId) return { token: upload.token } } if (Array.isArray(value)) { return await Promise.all(value.map( item => stagePathValue(item, sessionId, definitionId), )) } if (value && typeof value === 'object') { const entries = await Promise.all(Object.entries(value).map(async ([key, child]) => ( [key, await stagePathValue(child, sessionId, definitionId)] as const ))) return Object.fromEntries(entries) } return value } function refreshJobs(): Promise { if (disposed) return Promise.resolve(jobs.value) if (refreshPromise) return refreshPromise const revision = jobsRevision const pending = request<{ jobs: GeneratedAnalysisJobInput[] }>('/jobs', { method: 'GET' }) .then((payload) => { if (disposed) return jobs.value if (revision !== jobsRevision) { if (jobs.value.some(job => isActiveJobStatus(job.status))) schedulePoll() return jobs.value } replaceJobs(payload.jobs) error.value = null if (!paused && !jobStream.isConnected.value && jobs.value.some(job => isActiveJobStatus(job.status))) { schedulePoll() } return jobs.value }).finally(() => { if (refreshPromise === pending) refreshPromise = undefined }) refreshPromise = pending return pending } function refresh(): Promise { if (disposed) return Promise.resolve(jobs.value) if (initialized) return refreshJobs() if (initializeRefreshPromise) return initializeRefreshPromise const pending = initialize() .then(() => jobs.value) .finally(() => { if (initializeRefreshPromise === pending) initializeRefreshPromise = undefined }) initializeRefreshPromise = pending return pending } async function getJob( jobId: string, options: PluginJobRequestOptions = {}, ): Promise { const revision = beginJobOperation(jobId) try { const job = await request( `/jobs/${encodeURIComponent(jobId)}`, { method: 'GET', signal: options.signal }, ) return mergeJobResponse(job, revision) } finally { finishJobOperation(jobId) } } function waitForJob( job: GeneratedAnalysisJob | string, options: PluginJobWaitOptions = {}, ): Promise { if (disposed) return Promise.reject(new Error('Plugin jobs runtime is disposed')) if (options.signal?.aborted) { return Promise.reject(pluginJobWaitAbort(options.signal)) } const jobId = jobIdFromReference(job) const current = typeof job === 'string' ? jobs.value.find(candidate => candidate.job_id === jobId) : jobs.value.find(candidate => candidate.job_id === jobId) ?? job if (current && !isActiveJobStatus(current.status)) { return terminalWaitResult(current, options.rejectOnFailure === true) } if (paused) return Promise.reject(new Error('Plugin jobs runtime is paused')) return new Promise((resolve, reject) => { const waiter: PluginJobWaiter = { resolve, reject, rejectOnFailure: options.rejectOnFailure === true, signal: options.signal, } if (options.signal) { waiter.abort = () => { removeJobWaiter(jobId, waiter) reject(pluginJobWaitAbort(options.signal!)) } options.signal.addEventListener('abort', waiter.abort, { once: true }) } const waiting = jobWaiters.get(jobId) ?? new Set() waiting.add(waiter) jobWaiters.set(jobId, waiting) if (!paused) { if (streamEnabled) jobStream.start() if (!jobStream.isConnected.value) schedulePoll() } void getJob(jobId, { signal: options.signal }).catch((cause: unknown) => { if (current && isRetryableJobLookupError(cause)) { if (!jobStream.isConnected.value) schedulePoll() return } removeJobWaiter(jobId, waiter) reject(cause) }) }) } function settleJobWaiters(job: GeneratedAnalysisJob): void { if (isActiveJobStatus(job.status)) return const waiting = jobWaiters.get(job.job_id) if (!waiting) return jobWaiters.delete(job.job_id) for (const waiter of waiting) { if (waiter.abort && waiter.signal) { waiter.signal.removeEventListener('abort', waiter.abort) } void terminalWaitResult(job, waiter.rejectOnFailure).then( waiter.resolve, waiter.reject, ) } } function rejectJobWaiters(jobId: string, cause: unknown): void { const waiting = jobWaiters.get(jobId) if (!waiting) return jobWaiters.delete(jobId) for (const waiter of waiting) { if (waiter.abort && waiter.signal) { waiter.signal.removeEventListener('abort', waiter.abort) } waiter.reject(cause) } } function removeJobWaiter(jobId: string, waiter: PluginJobWaiter): void { const waiting = jobWaiters.get(jobId) if (!waiting) return waiting.delete(waiter) if (waiting.size === 0) jobWaiters.delete(jobId) if (waiter.abort && waiter.signal) { waiter.signal.removeEventListener('abort', waiter.abort) } } function jobsFor(definitionId: string): GeneratedAnalysisJob[] { return jobs.value.filter(job => job.definition_id === definitionId) } function canonicalJob( input: GeneratedAnalysisJobInput, fallbackDefinitionId?: string, ): GeneratedAnalysisJob { const normalized = normalizeJobState(input) const definitionId = normalized.definition_id ?? fallbackDefinitionId ?? null return { ...normalized, definition_id: definitionId, metadata: definitionId ? { ...normalized.metadata, definition_id: definitionId } : normalized.metadata, } } function recordJobsMutation(jobIds: Iterable): void { jobsRevision += 1 for (const jobId of jobIds) { jobRevisions.set(jobId, (jobRevisions.get(jobId) ?? 0) + 1) } } function beginJobOperation(jobId: string): number { pendingJobOperations.set(jobId, (pendingJobOperations.get(jobId) ?? 0) + 1) return jobRevisions.get(jobId) ?? 0 } function finishJobOperation(jobId: string): void { const remaining = (pendingJobOperations.get(jobId) ?? 1) - 1 if (remaining > 0) pendingJobOperations.set(jobId, remaining) else pendingJobOperations.delete(jobId) pruneJobRevisions() } function pruneJobRevisions(): void { const retained = new Set([ ...jobs.value.map(job => job.job_id), ...pendingJobOperations.keys(), ]) for (const jobId of jobRevisions.keys()) { if (!retained.has(jobId)) jobRevisions.delete(jobId) } } function replaceJobs(inputs: readonly GeneratedAnalysisJobInput[]): GeneratedAnalysisJob[] { const previousJobs = new Map(jobs.value.map(job => [job.job_id, job])) const previousJobIds = new Set(jobs.value.map(job => job.job_id)) const nextJobs = inputs.map((input) => { const incoming = canonicalJob(input) const current = previousJobs.get(incoming.job_id) return current && shouldPreserveJobLifecycle(current, incoming) ? current : incoming }) const nextJobIds = new Set(nextJobs.map(job => job.job_id)) recordJobsMutation(new Set([ ...previousJobIds, ...nextJobIds, ])) jobs.value = nextJobs for (const job of nextJobs) settleJobWaiters(job) for (const jobId of previousJobIds) { if (!nextJobIds.has(jobId)) { rejectJobWaiters(jobId, new Error(`Job ${jobId} is no longer available`)) } } pruneResultState() pruneJobRevisions() return nextJobs } function upsertJob( input: GeneratedAnalysisJobInput, fallbackDefinitionId?: string, ): GeneratedAnalysisJob { const incoming = canonicalJob(input, fallbackDefinitionId) const current = jobs.value.find(candidate => candidate.job_id === incoming.job_id) const job = current && shouldPreserveJobLifecycle(current, incoming) ? current : incoming if (job === current) { settleJobWaiters(job) pruneResultState() pruneJobRevisions() return job } jobs.value = [job, ...jobs.value.filter(candidate => candidate.job_id !== job.job_id)] recordJobsMutation([job.job_id]) settleJobWaiters(job) pruneResultState() pruneJobRevisions() return job } function mergeJobResponse( input: GeneratedAnalysisJobInput, expectedRevision: number, ): GeneratedAnalysisJob { const incoming = canonicalJob(input) const current = jobs.value.find(candidate => candidate.job_id === incoming.job_id) if (disposed) return current ?? incoming if (current && shouldPreserveJobLifecycle(current, incoming)) return current if (expectedRevision === (jobRevisions.get(incoming.job_id) ?? 0)) { return upsertJob(incoming) } if (current && jobLifecycleRank(incoming.status) > jobLifecycleRank(current.status)) { return upsertJob(incoming) } return current ?? incoming } function currentCompletedJob(): GeneratedAnalysisJob | undefined { const activeSessionId = session.value?.id return jobs.value.find(candidate => ( candidate.session_id === activeSessionId && candidate.status === 'completed' && candidate.result_status === 'available' )) } function resultForJob(jobId: string): GeneratedAnalysisResult | null { return resultCache.value[jobId] ?? null } function loadResult(job: GeneratedAnalysisJob): Promise { return loadResultInternal(job) } function loadResultInternal( job: GeneratedAnalysisJob, forceRefresh = false, ): Promise { if ( disposed || !isLoadableResultJob(job) ) return Promise.resolve(null) const cached = resultForJob(job.job_id) if (cached && !forceRefresh && !managedDownloadNeedsRefresh(cached)) { return Promise.resolve(cached) } const pending = resultRequests.get(job.job_id) if (pending) return pending const requestResult = request( `/jobs/${encodeURIComponent(job.job_id)}/result`, { method: 'GET' }, ).then((result) => { if (!disposed && isCurrentResultJob(job.job_id)) { cacheResult(job.job_id, result) clearResultRetry(job.job_id) error.value = null } return result }).catch((cause: unknown) => { if (!disposed && isCurrentResultJob(job.job_id)) { error.value = errorMessage(cause, 'Failed to load analysis result') if (cached && isManagedDownloadResult(cached)) { handleManagedDownloadRefreshFailure(job, cause) } else { scheduleResultRetry(job, cause) } } return null }).finally(() => { resultRequests.delete(job.job_id) }) resultRequests.set(job.job_id, requestResult) return requestResult } function cacheResult(jobId: string, result: GeneratedAnalysisResult): void { resultCacheOrder = [jobId, ...resultCacheOrder.filter(candidate => candidate !== jobId)] .slice(0, MAX_RESULT_CACHE_ENTRIES) const retained = new Set(resultCacheOrder) resultCache.value = Object.fromEntries([ ...Object.entries(resultCache.value) .filter(([candidate]) => retained.has(candidate) && candidate !== jobId), [jobId, result], ]) for (const candidate of downloadRefreshTimers.keys()) { if (!retained.has(candidate)) clearManagedDownloadRefresh(candidate) } scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result)) } function pruneResultState(): void { const loadableJobIds = new Set(jobs.value .filter(job => isLoadableResultJob(job)) .map(job => job.job_id)) resultCacheOrder = resultCacheOrder .filter(jobId => loadableJobIds.has(jobId)) .slice(0, MAX_RESULT_CACHE_ENTRIES) const retained = new Set(resultCacheOrder) resultCache.value = Object.fromEntries( Object.entries(resultCache.value).filter(([jobId]) => retained.has(jobId)), ) for (const jobId of resultRetryTimers.keys()) { if (!loadableJobIds.has(jobId)) clearResultRetry(jobId) } for (const jobId of resultRetryAttempts.keys()) { if (!loadableJobIds.has(jobId)) clearResultRetry(jobId) } for (const jobId of downloadRefreshTimers.keys()) { if (!loadableJobIds.has(jobId) || !retained.has(jobId)) { clearManagedDownloadRefresh(jobId) } } } function scheduleManagedDownloadRefresh(jobId: string, delay: number | null): void { clearManagedDownloadRefresh(jobId) if (delay === null || disposed || paused) return downloadRefreshTimers.set(jobId, setTimeout(() => { downloadRefreshTimers.delete(jobId) if (disposed || paused) return const current = jobs.value.find(candidate => candidate.job_id === jobId) if (current && isLoadableResultJob(current)) { void loadResultInternal(current, true) } }, Math.max(0, delay))) } function clearManagedDownloadRefresh(jobId: string): void { const timer = downloadRefreshTimers.get(jobId) if (timer !== undefined) clearTimeout(timer) downloadRefreshTimers.delete(jobId) } function handleManagedDownloadRefreshFailure( job: GeneratedAnalysisJob, cause: unknown, ): void { if (cause instanceof MintApiError) { if (cause.status === 404 || cause.status === 410) { discardCachedResult(job.job_id) jobs.value = jobs.value.map(candidate => ( candidate.job_id === job.job_id ? { ...candidate, result_status: 'expired' } : candidate )) recordJobsMutation([job.job_id]) return } if (cause.status === 401 || cause.status === 403) { discardCachedResult(job.job_id) return } } scheduleResultRetry(job, cause, true) } function discardCachedResult(jobId: string): void { clearResultRetry(jobId) clearManagedDownloadRefresh(jobId) resultCacheOrder = resultCacheOrder.filter(candidate => candidate !== jobId) const retainedResults = { ...resultCache.value } delete retainedResults[jobId] resultCache.value = retainedResults } function isLoadableResultJob(job: GeneratedAnalysisJob): boolean { return job.status === 'completed' && job.result_status === 'available' } function isCurrentResultJob(jobId: string): boolean { const job = jobs.value.find(candidate => candidate.job_id === jobId) return job !== undefined && isLoadableResultJob(job) } function scheduleResultRetry( job: GeneratedAnalysisJob, cause: unknown, forceRefresh = false, ): void { if ( paused || !isRetryableResultError(cause) || resultRetryTimers.has(job.job_id) ) return const attempt = resultRetryAttempts.get(job.job_id) ?? 0 if (attempt >= MAX_RESULT_RETRIES) return resultRetryAttempts.set(job.job_id, attempt + 1) resultRetryTimers.set(job.job_id, setTimeout(() => { resultRetryTimers.delete(job.job_id) const current = jobs.value.find(candidate => candidate.job_id === job.job_id) if (!paused && current && isLoadableResultJob(current)) { void loadResultInternal(current, forceRefresh) } }, pollInterval * (2 ** attempt))) } function clearResultRetry(jobId: string): void { const timer = resultRetryTimers.get(jobId) if (timer !== undefined) clearTimeout(timer) resultRetryTimers.delete(jobId) resultRetryAttempts.delete(jobId) } function schedulePoll(): void { if (disposed || paused || jobStream.isConnected.value || pollTimer !== undefined) return pollTimer = setTimeout(async () => { pollTimer = undefined if (disposed) return try { await refreshJobs() } catch (cause) { error.value = errorMessage(cause, 'Failed to refresh analysis jobs') if (jobs.value.some(job => isActiveJobStatus(job.status))) schedulePoll() } }, pollInterval) } function clearPoll(): void { if (pollTimer === undefined) return clearTimeout(pollTimer) pollTimer = undefined } function schedulePollForActiveJobs(): void { if (jobs.value.some(job => isActiveJobStatus(job.status))) schedulePoll() } function applyJobStreamMessage(message: EventStreamMessage): void { if (message.event === 'snapshot' && 'jobs' in message.data) { const payload = message.data replaceJobs(payload.jobs) } else if (message.event === 'job' || message.event === 'terminal') { upsertJob(message.data as GeneratedAnalysisJobInput) } error.value = null } async function cancelJob(job: GeneratedAnalysisJob | string): Promise { const jobId = typeof job === 'string' ? job : job.job_id const revision = beginJobOperation(jobId) try { const updated = await request( `/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST' }, ) return mergeJobResponse(updated, revision) } finally { finishJobOperation(jobId) } } async function deleteJob(job: GeneratedAnalysisJob | string): Promise { const jobId = typeof job === 'string' ? job : job.job_id await request<{ status: 'deleted' }>( `/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }, ) jobs.value = jobs.value.filter(candidate => candidate.job_id !== jobId) recordJobsMutation([jobId]) rejectJobWaiters(jobId, new Error(`Job ${jobId} was deleted`)) discardCachedResult(jobId) pruneJobRevisions() return true } async function clearFinishedJobs(definitionId?: string): Promise { const query = definitionId === undefined ? '' : `?definition_id=${encodeURIComponent(definitionId)}` const payload = await request<{ deleted: number }>(`/jobs${query}`, { method: 'DELETE' }) if (payload.deleted > 0) await refreshJobs() return payload.deleted } const jobsAdapter: JobsStatusTrayAdapter = { listJobs: async () => ({ jobs: await refreshJobs() }), cancelJob: async job => cancelJob(job as GeneratedAnalysisJob), deleteJob: async job => deleteJob(job as GeneratedAnalysisJob), clearFinishedJobs: async () => clearFinishedJobs(), } function pause(): void { if (disposed || paused) return paused = true status.value = 'paused' clearPoll() for (const jobId of resultRetryTimers.keys()) clearResultRetry(jobId) for (const jobId of downloadRefreshTimers.keys()) { clearManagedDownloadRefresh(jobId) } jobStream.stop() } function resume(): void { if (disposed || !paused) return paused = false status.value = initialized ? 'ready' : 'idle' if (!initialized) return for (const [jobId, result] of Object.entries(resultCache.value)) { scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result)) } if (streamEnabled) jobStream.start() else if (jobs.value.some(job => isActiveJobStatus(job.status))) schedulePoll() } function dispose(): void { if (disposed) return disposed = true paused = true status.value = 'disposed' clearPoll() for (const timer of resultRetryTimers.values()) clearTimeout(timer) resultRetryTimers.clear() resultRetryAttempts.clear() for (const timer of downloadRefreshTimers.values()) clearTimeout(timer) downloadRefreshTimers.clear() const cause = new Error('Plugin jobs runtime is disposed') for (const jobId of jobWaiters.keys()) rejectJobWaiters(jobId, cause) jobRevisions.clear() pendingJobOperations.clear() jobStream.stop() } async function request(path: string, init: RequestInit): Promise { const response = await transport(`${baseUrl}${path}`, { ...init, headers: { Accept: 'application/json', ...(init.body && !(init.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), ...init.headers, }, }) if (!response.ok) { const payload: unknown = await response.json().catch(() => null) throw mintApiErrorFromResponse(response.status, payload, { requestId: response.headers.get('X-Request-ID'), }) } return await response.json() as T } onScopeDispose(dispose) if (options.immediate) void initialize().catch(() => undefined) return { manifest, session, jobs, latestResult, isLoading, isSubmitting, error, status, initialize, ready: initialize, uploadPath, uploadStagedPart, getJobStaging, sealJob, submit, loadResult, resultForJob, refreshJobs, refresh, getJob, waitForJob, jobsFor, cancelJob, deleteJob, clearFinishedJobs, pause, resume, dispose, jobsAdapter, } } function jobIdFromReference(job: GeneratedAnalysisJob | string): string { return typeof job === 'string' ? job : job.job_id } function encodeStagedPartKey(key: string): string { return key.split('/').map(segment => encodeURIComponent(segment)).join('/') } function jobLifecycleRank(status: GeneratedAnalysisJob['status']): number { if (status === 'awaiting_input') return 0 if (status === 'queued') return 1 if (status === 'running') return 2 return 3 } function shouldPreserveJobLifecycle( current: GeneratedAnalysisJob, incoming: GeneratedAnalysisJob, ): boolean { const currentRank = jobLifecycleRank(current.status) const incomingRank = jobLifecycleRank(incoming.status) return incomingRank < currentRank || (currentRank === 3 && incomingRank === 3 && current.status !== incoming.status) } function terminalWaitResult( job: GeneratedAnalysisJob, rejectOnFailure: boolean, ): Promise { if (rejectOnFailure && (job.status === 'failed' || job.status === 'cancelled')) { return Promise.reject(new Error(job.error ?? `Job ${job.job_id} ${job.status}`)) } return Promise.resolve(job) } function pluginJobWaitAbort(signal: AbortSignal): unknown { if (signal.reason !== undefined) return signal.reason const cause = new Error('Waiting for job was aborted') cause.name = 'AbortError' return cause } function isPluginJobArtifactPathInput(value: unknown): value is PluginJobArtifactPathInput { return Boolean( value && typeof value === 'object' && 'kind' in value && value.kind === 'artifact-path' && 'experimentId' in value && Number.isInteger(value.experimentId) && 'artifactId' in value && Number.isInteger(value.artifactId), ) } function isPluginJobPathInput(value: unknown): value is PluginJobPathInput { return Boolean( value && typeof value === 'object' && 'kind' in value && value.kind === 'path' && 'files' in value && Array.isArray(value.files) && 'relativePaths' in value && Array.isArray(value.relativePaths), ) } function readStoredAccessToken(): string | null { try { return globalThis.localStorage?.getItem(AUTH_TOKEN_KEY) ?? null } catch { return null } } function createGeneratedSessionStorage(candidate?: Storage): GeneratedSessionStorage { const fallback = new Map() let delegate: Storage | undefined let available = true try { delegate = candidate ?? globalThis.sessionStorage } catch { available = false } return { getItem(key) { if (available && delegate) { try { return delegate.getItem(key) ?? fallback.get(key) ?? null } catch { available = false } } return fallback.get(key) ?? null }, setItem(key, value) { fallback.set(key, value) if (available && delegate) { try { delegate.setItem(key, value) } catch { available = false } } }, } } function isSessionNotFoundError(error: unknown): boolean { return error instanceof MintApiError && error.status === 404 && (error.detail === SESSION_NOT_FOUND_DETAIL || error.message === SESSION_NOT_FOUND_DETAIL) } function isRetryableResultError(error: unknown): boolean { return !(error instanceof MintApiError) || error.status === 409 || error.status === 429 || error.status >= 500 } function isRetryableJobLookupError(error: unknown): boolean { return !(error instanceof MintApiError) || error.status === 408 || error.status === 429 || error.status >= 500 } function isManagedDownloadResult( result: GeneratedAnalysisResult, ): result is Extract { return result.kind === 'artifact' && result.managed === true } function managedDownloadNeedsRefresh(result: GeneratedAnalysisResult): boolean { if (!isManagedDownloadResult(result)) return false if (!result.download_url || !result.download_expires_at) return true const expiresAt = Date.parse(result.download_expires_at) return !Number.isFinite(expiresAt) || expiresAt <= Date.now() + DOWNLOAD_URL_REFRESH_SKEW_MS } function managedDownloadRefreshDelay(result: GeneratedAnalysisResult): number | null { if (!isManagedDownloadResult(result)) return null if (!result.download_url || !result.download_expires_at) return null const expiresAt = Date.parse(result.download_expires_at) if (!Number.isFinite(expiresAt)) return null const remaining = expiresAt - Date.now() return remaining <= DOWNLOAD_URL_REFRESH_SKEW_MS ? remaining : remaining - DOWNLOAD_URL_REFRESH_SKEW_MS } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }