import { computed, ref, toValue, watch, type ComputedRef, type MaybeRefOrGetter, type Ref, } from 'vue' import { useApi } from './useApi' import { useRequestSyncState } from './useRequestSyncState' import { getInjectedPlatformContext, resolveCurrentExperimentId, } from './platformContextHelpers' import type { AnalysisArtifactDetail, AnalysisArtifactListResponse, AnalysisArtifactMetadataUpdate, AnalysisArtifactStatusFilter, AnalysisArtifactSummary, AnalysisArtifactTarget, } from '../types/analysisArtifactTypes' export { ANALYSIS_FILE_ARTIFACT_SCHEMA } from '../types/analysisArtifactTypes' export interface UseAnalysisArtifactsOptions { /** Experiment id; defaults to platform injection or URL. Refetches when it changes. */ experimentId?: MaybeRefOrGetter /** Override API base URL. */ apiBaseUrl?: string /** Scope to a single owning plugin; omit or null to keep all plugins. */ pluginId?: MaybeRefOrGetter /** Always request archived artifacts alongside active ones. */ includeArchived?: MaybeRefOrGetter /** Fetch as soon as an experiment id is available. */ immediate?: boolean } export interface UseAnalysisArtifactsReturn { /** Fetched artifacts, scoped to pluginId when set. */ artifacts: ComputedRef /** Artifacts after applying statusFilter, pluginFilter, and search. */ visibleArtifacts: ComputedRef /** Detail loaded by the last successful getDetail call, or null. */ selectedDetail: Ref /** Resolved experiment id from options, platform injection, or URL. */ currentExperimentId: ComputedRef /** Whether an experiment id is available. */ hasCurrentExperiment: ComputedRef /** Whether any list or detail fetch is in progress. */ isLoading: ComputedRef /** Whether any archive/restore/metadata mutation is in progress. */ isMutating: ComputedRef /** Error message from the last failed operation, or null. */ error: Ref /** Timestamp of the last successful load, or null. */ lastLoadedAt: Ref /** Case-insensitive filter over display_name and artifact_key. */ search: Ref /** Lifecycle filter; widening past "active" refetches with include_archived. */ statusFilter: Ref /** Plugin filter applied to visibleArtifacts, or null for all plugins. */ pluginFilter: Ref /** Fetch the artifact list for the current experiment. */ list: () => Promise /** Alias of list. */ refresh: () => Promise /** Fetch one artifact with its result payload; null when it fails or is superseded. */ getDetail: (artifactId: number) => Promise /** Resolve and fetch the exact stable artifact identity. */ resolveTarget: (target: AnalysisArtifactTarget) => Promise /** Update display_name and/or note; resolves to the updated summary or null. */ updateMetadata: ( artifactId: number, updates: AnalysisArtifactMetadataUpdate, ) => Promise /** Archive an artifact; resolves to the updated summary or null. */ archive: (artifactId: number) => Promise /** Restore an archived artifact; resolves to the updated summary or null. */ restore: (artifactId: number) => Promise /** Whether a key exists in the loaded artifacts (no network call). */ hasArtifactKey: (artifactKey: string) => boolean /** Reset artifacts, selection, and error. */ clear: () => void } /** * Lists and manages analysis artifacts through the user-facing platform API. * * @example Recommended * ```ts * const artifacts = useAnalysisArtifacts({ pluginId: 'LEAF' }) * await artifacts.list() * ``` */ export function useAnalysisArtifacts( options: UseAnalysisArtifactsOptions = {}, ): UseAnalysisArtifactsReturn { const injectedContext = getInjectedPlatformContext() const api = useApi({ baseUrl: options.apiBaseUrl ?? injectedContext?.platformApiUrl }) const currentExperimentId = computed( () => toValue(options.experimentId) ?? resolveCurrentExperimentId(injectedContext), ) const hasCurrentExperiment = computed(() => currentExperimentId.value !== undefined) const scopePluginId = computed(() => toValue(options.pluginId) ?? null) const request = useRequestSyncState('Analysis artifact request failed.') const fetchedArtifacts = ref([]) const selectedDetail = ref(null) // Counters, not booleans: overlapping requests must not clear each other's flag. const loadingCount = ref(0) const mutatingCount = ref(0) const isLoading = computed(() => loadingCount.value > 0) const isMutating = computed(() => mutatingCount.value > 0) const search = ref('') const statusFilter = ref('active') const pluginFilter = ref(null) const needsArchived = computed( () => Boolean(toValue(options.includeArchived)) || statusFilter.value !== 'active', ) const artifacts = computed(() => ( scopePluginId.value ? fetchedArtifacts.value.filter((artifact) => artifact.plugin_id === scopePluginId.value) : fetchedArtifacts.value )) const visibleArtifacts = computed(() => { const query = search.value.trim().toLowerCase() return artifacts.value.filter((artifact) => { if (statusFilter.value !== 'all' && artifact.status !== statusFilter.value) return false if (pluginFilter.value && artifact.plugin_id !== pluginFilter.value) return false if (!query) return true return ( artifact.display_name.toLowerCase().includes(query) || artifact.artifact_key.toLowerCase().includes(query) ) }) }) // Generation tokens: a superseded response must commit nothing — not rows, not the // shared error, not lastLoadedAt — or a late failure would bury fresh results. let listToken = 0 let detailToken = 0 const hasRequestedList = ref(false) function currentExperimentIdOrError(action: string): number | undefined { const id = currentExperimentId.value if (id === undefined) { request.setError(`No current experiment is selected for ${action}`) return undefined } return id } function replaceArtifact(summary: AnalysisArtifactSummary): void { const index = fetchedArtifacts.value.findIndex((artifact) => artifact.id === summary.id) if (index >= 0) { fetchedArtifacts.value.splice(index, 1, summary) } } async function list(): Promise { const experimentId = currentExperimentIdOrError('listing analysis artifacts') if (experimentId === undefined) { fetchedArtifacts.value = [] return [] } const token = ++listToken hasRequestedList.value = true loadingCount.value += 1 request.clearError() try { const data = await api.get( `/experiments/${experimentId}/analysis-artifacts?include_archived=${needsArchived.value}`, ) if (token !== listToken) return artifacts.value // Copy: replaceArtifact splices in place, and the response array is not ours. fetchedArtifacts.value = [...(data?.artifacts ?? [])] request.markLoaded() return artifacts.value } catch (err) { if (token !== listToken) return [] request.setError(err) fetchedArtifacts.value = [] return [] } finally { loadingCount.value -= 1 } } async function getDetail(artifactId: number): Promise { const experimentId = currentExperimentIdOrError('loading an analysis artifact') if (experimentId === undefined) return null const token = ++detailToken loadingCount.value += 1 request.clearError() try { const detail = await api.get( `/experiments/${experimentId}/analysis-artifacts/${artifactId}`, ) if (token !== detailToken) return null selectedDetail.value = detail request.markLoaded() return detail } catch (err) { if (token !== detailToken) return null request.setError(err) return null } finally { loadingCount.value -= 1 } } async function resolveTarget( target: AnalysisArtifactTarget, ): Promise { const experimentId = currentExperimentId.value if (experimentId !== target.experimentId) { request.setError( `Analysis artifact target belongs to experiment ${target.experimentId}, not ` + `${experimentId ?? 'the current selection'}.`, ) return null } const available = await list() const summary = available.find((artifact) => ( artifact.plugin_id === target.artifactPluginId && artifact.artifact_key === target.artifactKey && artifact.status === 'active' )) if (!summary) { request.setError( `Analysis artifact ${target.artifactPluginId}/${target.artifactKey} is not available.`, ) return null } const detail = await getDetail(summary.id) if ( detail && ( detail.experiment_id !== target.experimentId || detail.plugin_id !== target.artifactPluginId || detail.artifact_key !== target.artifactKey ) ) { selectedDetail.value = null request.setError( `Analysis artifact ${target.artifactPluginId}/${target.artifactKey} did not ` + 'match the requested identity.', ) return null } return detail } async function mutate( action: string, operation: (experimentId: number) => Promise, ): Promise { const experimentId = currentExperimentIdOrError(action) if (experimentId === undefined) return null mutatingCount.value += 1 request.clearError() try { const summary = await operation(experimentId) replaceArtifact(summary) request.markSaved() return summary } catch (err) { request.setError(err) return null } finally { mutatingCount.value -= 1 } } async function updateMetadata( artifactId: number, updates: AnalysisArtifactMetadataUpdate, ): Promise { return mutate('updating artifact metadata', (experimentId) => api.patch( `/experiments/${experimentId}/analysis-artifacts/${artifactId}`, updates, )) } async function archive(artifactId: number): Promise { return mutate('archiving an analysis artifact', (experimentId) => api.post( `/experiments/${experimentId}/analysis-artifacts/${artifactId}/archive`, )) } async function restore(artifactId: number): Promise { return mutate('restoring an analysis artifact', (experimentId) => api.post( `/experiments/${experimentId}/analysis-artifacts/${artifactId}/restore`, )) } function hasArtifactKey(artifactKey: string): boolean { return artifacts.value.some((artifact) => artifact.artifact_key === artifactKey) } /** Drop experiment-scoped state and orphan every in-flight response. */ function clear(): void { listToken += 1 detailToken += 1 fetchedArtifacts.value = [] selectedDetail.value = null request.clearError() } // Refetch when the scope changes or archived rows are newly needed. `immediate` // covers a late-resolving experiment id; otherwise stay quiet until something // has asked for a list at least once. watch([currentExperimentId, needsArchived], ([experimentId], [previousExperimentId]) => { if (experimentId !== previousExperimentId) { // Scope changed: the previous experiment's rows and any in-flight reply for // them must not survive into the new one. clear() } if (experimentId === undefined) return if (options.immediate || hasRequestedList.value) { void list() } }, { immediate: options.immediate }) return { artifacts, visibleArtifacts, selectedDetail, currentExperimentId, hasCurrentExperiment, isLoading, isMutating, error: request.error, lastLoadedAt: request.lastLoadedAt, search, statusFilter, pluginFilter, list, refresh: list, getDetail, resolveTarget, updateMetadata, archive, restore, hasArtifactKey, clear, } }