import { computed, onMounted, onUnmounted, ref, shallowRef, toValue, watch, type ComputedRef, type MaybeRefOrGetter, type Ref, type ShallowRef, } from 'vue' import { isActiveJobStatus, normalizeJobState } from '../jobs' import type { JobListPayload, JobState, JobStateInput, JobStreamData, } from '../types/jobs' import type { PluginEventStreamMessage, UsePluginEventStreamReturn, } from './usePluginClient' export type JobsStatusTrayTab = 'active' | 'history' /** * The canonical owner of a collection of plugin jobs. * * Sources, rather than job-center UI, own polling, event streams, and all job * mutations. This keeps components and consumers on one reactive job graph. */ export interface PluginJobCenterSource { jobs: MaybeRefOrGetter refreshJobs?: () => Promise cancelJob?: (jobId: string) => Promise deleteJob?: (jobId: string) => Promise clearFinishedJobs?: () => Promise dispose?: () => void } export interface CombinedPluginJobCenterSource extends PluginJobCenterSource { jobs: ComputedRef } /** * Combines independent job runtimes without copying their reactive state. * * Job ids must be globally unique across the sources. A duplicate id throws * when jobs are read or an id-routed action is requested, rather than silently * dispatching a mutation to the wrong owner. */ export function combinePluginJobSources( sources: readonly PluginJobCenterSource[], ): CombinedPluginJobCenterSource { const jobs = computed(() => { const combined: JobStateInput[] = [] const owners = new Set() for (const source of sources) { for (const job of toValue(source.jobs)) { if (owners.has(job.job_id)) throw duplicatePluginJobId(job.job_id) owners.add(job.job_id) combined.push(job) } } return combined }) function ownerFor(jobId: string): PluginJobCenterSource { let owner: PluginJobCenterSource | undefined for (const source of sources) { if (!toValue(source.jobs).some(job => job.job_id === jobId)) continue if (owner) throw duplicatePluginJobId(jobId) owner = source } if (!owner) throw new Error(`Unknown plugin job id: ${jobId}`) return owner } const combined: CombinedPluginJobCenterSource = { jobs } if (sources.some(source => source.refreshJobs)) { combined.refreshJobs = async () => { await Promise.all(sources.map(source => source.refreshJobs?.())) return jobs.value } } if (sources.some(source => source.cancelJob)) { combined.cancelJob = async (jobId) => { const owner = ownerFor(jobId) if (!owner.cancelJob) throw new Error(`Job ${jobId} cannot be cancelled by its source`) return owner.cancelJob(jobId) } } if (sources.length > 0 && sources.every(source => source.deleteJob)) { combined.deleteJob = async (jobId) => { const owner = ownerFor(jobId) if (!owner.deleteJob) throw new Error(`Job ${jobId} cannot be deleted by its source`) return owner.deleteJob(jobId) } } // Never report a successful combined clear after mutating only a subset. // Without bulk support from every source, the job center can still fall // back to this source's id-routed deleteJob method when all owners expose it. if (sources.length > 0 && sources.every(source => source.clearFinishedJobs)) { combined.clearFinishedJobs = async () => { const results = await Promise.all( sources.map(source => source.clearFinishedJobs!()), ) return results.every((result): result is number => typeof result === 'number') ? results.reduce((total, result) => total + result, 0) : undefined } } if (sources.some(source => source.dispose)) { combined.dispose = () => { const errors: unknown[] = [] for (const source of sources) { try { source.dispose?.() } catch (cause) { errors.push(cause) } } if (errors.length === 1) throw errors[0] if (errors.length > 1) throw new PluginJobSourceDisposeError(errors) } } return combined } class PluginJobSourceDisposeError extends Error { readonly cause: unknown readonly errors: readonly unknown[] constructor(errors: readonly unknown[]) { super('Failed to dispose plugin job sources') this.name = 'PluginJobSourceDisposeError' this.cause = errors[0] this.errors = Object.freeze([...errors]) } } function duplicatePluginJobId(jobId: string): Error { return new Error(`Duplicate plugin job id across combined sources: ${jobId}`) } /** @deprecated Removed in MINT 1.4; prefer a {@link PluginJobCenterSource}. */ export interface JobsStatusTrayAdapter { listJobs?: () => Promise cancelJob?: (job: JobState) => Promise deleteJob?: (job: JobState) => Promise clearFinishedJobs?: (jobs: readonly JobState[]) => Promise } export interface UsePluginJobCenterOptions { source?: MaybeRefOrGetter /** @deprecated Removed in MINT 1.4; pass a source with its canonical jobs ref. */ jobs?: MaybeRefOrGetter /** @deprecated Removed in MINT 1.4; pass source actions instead. */ adapter?: MaybeRefOrGetter /** @deprecated Removed in MINT 1.4; event handling belongs to the source runtime. */ eventStream?: MaybeRefOrGetter | undefined> loadOnMount?: boolean } export interface UseJobsStatusTrayOptions extends UsePluginJobCenterOptions {} export interface JobStreamMessage { event: string data: JobStreamData } export interface UsePluginJobCenterReturn { jobs: ComputedRef activeJobs: ComputedRef historyJobs: ComputedRef deletableJobs: ComputedRef featuredJob: ComputedRef canRefresh: ComputedRef canCancel: ComputedRef canDelete: ComputedRef canClearFinished: ComputedRef isOpen: Ref activeTab: Ref isLoading: Ref error: ShallowRef pendingJobIds: Ref> open: () => void close: () => void toggle: () => void refresh: () => Promise applyStreamMessage: (message: JobStreamMessage) => void cancelJob: (job: JobState) => Promise deleteJob: (job: JobState) => Promise clearFinished: () => Promise isPending: (job: JobState) => boolean } export type UseJobsStatusTrayReturn = UsePluginJobCenterReturn interface LegacyJobsOverride { readonly input: readonly JobStateInput[] | undefined readonly jobs: JobState[] } function responseJobs( response: JobListPayload | readonly JobStateInput[], ): readonly JobStateInput[] { return 'jobs' in response ? response.jobs : response } function isSnapshotPayload(data: JobStreamData): data is { jobs: JobStateInput[] } { return 'jobs' in data && Array.isArray(data.jobs) } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback } /** * Headless controller for plugin job presentation and actions. * * When a source is supplied, every job list is derived directly from that * source. The controller never mirrors source jobs into another ref. */ export function usePluginJobCenter( options: UsePluginJobCenterOptions = {}, ): UsePluginJobCenterReturn { const legacyOverride = shallowRef(null) const isOpen = ref(false) const activeTab = ref('active') const isLoading = ref(false) const error = shallowRef(null) const pendingJobIds = ref(new Set()) const jobRevisions = new Map() let stateRevision = 0 let requestGeneration = 0 let active = true const source = computed(() => toValue(options.source)) const adapter = computed(() => toValue(options.adapter)) const jobs = computed(() => { const currentSource = source.value if (currentSource) { return [...toValue(currentSource.jobs)].map(normalizeJobState) } const input = toValue(options.jobs) const override = legacyOverride.value if (override && override.input === input) return override.jobs return input ? [...input].map(normalizeJobState) : [] }) const activeJobs = computed(() => jobs.value.filter(job => isActiveJobStatus(job.status))) const historyJobs = computed(() => jobs.value.filter(job => !isActiveJobStatus(job.status))) const deletableJobs = computed(() => historyJobs.value.filter(job => job.can_delete)) const featuredJob = computed(() => activeJobs.value[0] ?? historyJobs.value[0] ?? null) const canRefresh = computed(() => Boolean(source.value?.refreshJobs || adapter.value?.listJobs)) const canCancel = computed(() => Boolean(source.value?.cancelJob || adapter.value?.cancelJob)) const canDelete = computed(() => Boolean(source.value?.deleteJob || adapter.value?.deleteJob)) const canClearFinished = computed(() => Boolean( source.value?.clearFinishedJobs || source.value?.deleteJob || adapter.value?.clearFinishedJobs || adapter.value?.deleteJob, )) function bumpJobRevision(jobId: string): void { jobRevisions.set(jobId, (jobRevisions.get(jobId) ?? 0) + 1) } function replaceLegacyJobs(nextJobs: readonly JobStateInput[]): void { const normalized = nextJobs.map(normalizeJobState) const touchedJobIds = new Set([ ...jobs.value.map(job => job.job_id), ...normalized.map(job => job.job_id), ]) touchedJobIds.forEach(bumpJobRevision) legacyOverride.value = { input: toValue(options.jobs), jobs: normalized, } stateRevision += 1 } function upsertLegacyJob(nextJob: JobStateInput): void { const normalized = normalizeJobState(nextJob) const existingIndex = jobs.value.findIndex(job => job.job_id === normalized.job_id) const nextJobs = [...jobs.value] if (existingIndex === -1) nextJobs.unshift(normalized) else nextJobs[existingIndex] = normalized legacyOverride.value = { input: toValue(options.jobs), jobs: nextJobs, } bumpJobRevision(normalized.job_id) stateRevision += 1 } function removeLegacyJobs(jobIds: Set): void { legacyOverride.value = { input: toValue(options.jobs), jobs: jobs.value.filter(job => !jobIds.has(job.job_id)), } jobIds.forEach(bumpJobRevision) stateRevision += 1 } function setPending(jobId: string, pending: boolean): void { const next = new Set(pendingJobIds.value) if (pending) next.add(jobId) else next.delete(jobId) pendingJobIds.value = next } function applyStreamMessage(message: JobStreamMessage): void { if (!active || source.value) return if (message.event === 'snapshot' && isSnapshotPayload(message.data)) { replaceLegacyJobs(message.data.jobs) return } if ( (message.event === 'job' || message.event === 'terminal') && !isSnapshotPayload(message.data) ) { upsertLegacyJob(message.data) } } async function refresh(): Promise { const currentSource = source.value const listJobs = currentSource?.refreshJobs ?? adapter.value?.listJobs if (!listJobs) return false const generation = ++requestGeneration const revision = stateRevision isLoading.value = true error.value = null try { const response = await listJobs() if (!active || generation !== requestGeneration || revision !== stateRevision) { return false } if (!currentSource && response) replaceLegacyJobs(responseJobs(response)) return true } catch (cause) { if (active && generation === requestGeneration) { error.value = errorMessage(cause, 'Failed to load jobs') } return false } finally { if (active && generation === requestGeneration) isLoading.value = false } } async function cancelJob(job: JobState): Promise { const sourceCancel = source.value?.cancelJob const adapterCancel = adapter.value?.cancelJob if (!job.can_cancel || (!sourceCancel && !adapterCancel) || pendingJobIds.value.has(job.job_id)) { return false } const revision = jobRevisions.get(job.job_id) ?? 0 setPending(job.job_id, true) error.value = null try { const updated = sourceCancel ? await sourceCancel(job.job_id) : await adapterCancel!(job) if (!active) return false if (!sourceCancel && revision === (jobRevisions.get(job.job_id) ?? 0)) { if (updated) upsertLegacyJob(updated) else await refresh() } return true } catch (cause) { if (active) error.value = errorMessage(cause, 'Failed to cancel job') return false } finally { if (active) setPending(job.job_id, false) } } async function deleteJob(job: JobState): Promise { const sourceDelete = source.value?.deleteJob const adapterDelete = adapter.value?.deleteJob if (!job.can_delete || (!sourceDelete && !adapterDelete) || pendingJobIds.value.has(job.job_id)) { return false } setPending(job.job_id, true) error.value = null try { const deleted = sourceDelete ? await sourceDelete(job.job_id) : await adapterDelete!(job) if (!active) return false if (!sourceDelete) { if (deleted === false) await refresh() else removeLegacyJobs(new Set([job.job_id])) } return deleted !== false } catch (cause) { if (active) error.value = errorMessage(cause, 'Failed to delete job') return false } finally { if (active) setPending(job.job_id, false) } } async function clearFinished(): Promise { const targets = deletableJobs.value if (targets.length === 0) return false error.value = null try { const currentSource = source.value if (currentSource?.clearFinishedJobs) { const deleted = await currentSource.clearFinishedJobs() if (!active) return false return typeof deleted !== 'number' || deleted === targets.length } if (currentSource?.deleteJob) { const results = await Promise.all(targets.map(job => deleteJob(job))) return results.every(Boolean) } const currentAdapter = adapter.value if (currentAdapter?.clearFinishedJobs) { const deleted = await currentAdapter.clearFinishedJobs(targets) if (!active) return false if (typeof deleted === 'number' && deleted !== targets.length) { await refresh() return false } removeLegacyJobs(new Set(targets.map(job => job.job_id))) return true } if (!currentAdapter?.deleteJob) return false const results = await Promise.all(targets.map(job => deleteJob(job))) return results.every(Boolean) } catch (cause) { if (active) error.value = errorMessage(cause, 'Failed to clear jobs') return false } } if (options.eventStream !== undefined) { watch( () => toValue(options.eventStream)?.lastMessage.value ?? null, (message: PluginEventStreamMessage | null) => { if (message) applyStreamMessage(message) }, { immediate: true }, ) } onMounted(() => { if (options.loadOnMount !== false) void refresh() }) onUnmounted(() => { active = false requestGeneration += 1 }) return { jobs, activeJobs, historyJobs, deletableJobs, featuredJob, canRefresh, canCancel, canDelete, canClearFinished, isOpen, activeTab, isLoading, error, pendingJobIds, open: () => { isOpen.value = true }, close: () => { isOpen.value = false }, toggle: () => { isOpen.value = !isOpen.value }, refresh, applyStreamMessage, cancelJob, deleteJob, clearFinished, isPending: job => pendingJobIds.value.has(job.job_id), } } /** @deprecated Removed in MINT 1.4; use `usePluginJobCenter()` for the plugin job-center controller. */ export function useJobsStatusTray( options: UseJobsStatusTrayOptions = {}, ): UseJobsStatusTrayReturn { return usePluginJobCenter(options) }