import { defineStore } from "pinia"; import { listRuns, triggerManualRun, cancelRun, type RunListItem } from "@/features/runs"; import { createDefaultSafetyState, normalizeSafetyState, type ScrapeSafetyState, } from "@/features/safety"; import { type PublicationItem } from "@/features/publications"; import { ApiRequestError } from "@/lib/api/errors"; export const RUN_STATUS_POLL_INTERVAL_MS = 15000; export const RUN_STATUS_STARTING_PHASE_MS = 1500; export type StartManualCheckResult = | { kind: "started"; runId: number; reusedExistingRun: boolean; } | { kind: "already_running"; runId: number | null; requestId: string | null; } | { kind: "error"; message: string; requestId: string | null; }; export type CancelCheckResult = | { kind: "success" } | { kind: "error"; message: string }; let pollTimer: ReturnType | null = null; let syncPromise: Promise | null = null; let submittingPhaseTimer: ReturnType | null = null; let eventSource: EventSource | null = null; let activeStreamRunId: number | null = null; const ACTIVE_STATUSES = new Set(["running", "resolving"]); type StreamDisplayIdentifier = PublicationItem["display_identifier"]; function parseRunId(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim().length > 0) { const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) { return parsed; } } return null; } function extractRunIdFromDetails(details: unknown): number | null { if (!details || typeof details !== "object") { return null; } const runIdCandidate = (details as Record).run_id; return parseRunId(runIdCandidate); } function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | null { if (!details || typeof details !== "object") { return null; } const candidate = (details as Record).safety_state; if (!candidate || typeof candidate !== "object") { return null; } return normalizeSafetyState(candidate); } function isActiveStatus(value: string | null | undefined): boolean { return value !== undefined && value !== null && ACTIVE_STATUSES.has(value); } function parsePublicationCount(value: unknown, fallback: number): number { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(Math.trunc(value), 0); } return fallback; } function parseDisplayIdentifier(value: unknown): StreamDisplayIdentifier { if (!value || typeof value !== "object") { return null; } const payload = value as Record; if (typeof payload.kind !== "string" || typeof payload.value !== "string" || typeof payload.label !== "string") { return null; } if (typeof payload.confidence_score !== "number" || !Number.isFinite(payload.confidence_score)) { return null; } const url = typeof payload.url === "string" ? payload.url : null; return { kind: payload.kind, value: payload.value, label: payload.label, url, confidence_score: payload.confidence_score, }; } function withUpdatedDisplayIdentifier( items: PublicationItem[], update: { publicationId: number; displayIdentifier: StreamDisplayIdentifier; }, ): PublicationItem[] { const { publicationId, displayIdentifier } = update; let changed = false; const next = items.map((item) => { if (item.publication_id !== publicationId) { return item; } changed = true; return { ...item, display_identifier: displayIdentifier }; }); return changed ? next : items; } function reconcileRunCounters(previous: RunListItem | null, next: RunListItem | null): RunListItem | null { if (previous === null || next === null) { return next; } if (previous.id !== next.id) { return next; } const previousCount = parsePublicationCount(previous.new_publication_count, 0); const nextCount = parsePublicationCount(next.new_publication_count, previousCount); return { ...next, new_publication_count: Math.max(previousCount, nextCount), }; } function buildPlaceholderRunningRun(runId: number): RunListItem { return { id: runId, trigger_type: "manual", status: "running", start_dt: new Date().toISOString(), end_dt: null, scholar_count: 0, new_publication_count: 0, failed_count: 0, partial_count: 0, }; } export const useRunStatusStore = defineStore("runStatus", { state: () => ({ latestRun: null as RunListItem | null, safetyState: createDefaultSafetyState() as ScrapeSafetyState, isSubmitting: false, assumeRunningFromSubmission: false, isPolling: false, lastErrorMessage: null as string | null, lastErrorRequestId: null as string | null, lastSyncAt: null as number | null, livePublications: [] as Array, }), getters: { isRunActive(state): boolean { const s = state.latestRun?.status; return state.isSubmitting || s === "running" || s === "resolving"; }, isLikelyRunning(state): boolean { const s = state.latestRun?.status; return s === "running" || s === "resolving" || state.assumeRunningFromSubmission; }, canStart(): boolean { return !this.isRunActive && !this.safetyState.cooldown_active; }, }, actions: { clearSubmittingPhaseTimer(): void { if (submittingPhaseTimer !== null) { clearTimeout(submittingPhaseTimer); submittingPhaseTimer = null; } }, beginSubmittingPhaseTracking(): void { this.assumeRunningFromSubmission = false; this.clearSubmittingPhaseTimer(); submittingPhaseTimer = setTimeout(() => { if (!this.isSubmitting) { return; } this.assumeRunningFromSubmission = true; }, RUN_STATUS_STARTING_PHASE_MS); }, setLatestRun(run: RunListItem | null): void { this.latestRun = run; this.lastSyncAt = Date.now(); this.lastErrorMessage = null; this.lastErrorRequestId = null; if (isActiveStatus(run?.status)) { this.assumeRunningFromSubmission = true; } else if (!this.isSubmitting) { this.assumeRunningFromSubmission = false; } this.updatePolling(); }, setSafetyState(value: unknown): void { this.safetyState = normalizeSafetyState(value); }, startPolling(): void { if (pollTimer !== null) { this.isPolling = true; return; } this.isPolling = true; pollTimer = setInterval(() => { void this.syncLatest(); }, RUN_STATUS_POLL_INTERVAL_MS); }, stopPolling(): void { if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } this.isPolling = false; }, updatePolling(): void { if (this.isRunActive) { this.startPolling(); this.updateEventSource(); return; } this.stopPolling(); this.updateEventSource(); }, updateEventSource(): void { const targetRunId = isActiveStatus(this.latestRun?.status) ? this.latestRun?.id ?? null : null; if (activeStreamRunId === targetRunId) { return; } if (eventSource !== null) { eventSource.close(); eventSource = null; activeStreamRunId = null; } if (targetRunId !== null) { if (typeof EventSource === "undefined") { return; } activeStreamRunId = targetRunId; this.livePublications = []; eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`); eventSource.addEventListener("publication_discovered", (e) => { try { const data = JSON.parse(e.data); if (this.latestRun && this.latestRun.id === targetRunId) { const baseline = parsePublicationCount(this.latestRun.new_publication_count, 0); const payloadCount = parsePublicationCount(data?.new_publication_count, baseline + 1); this.latestRun.new_publication_count = Math.max(baseline, payloadCount); } this.livePublications.unshift({ publication_id: data.publication_id, scholar_profile_id: data.scholar_profile_id, scholar_label: data.scholar_label, title: data.title, pub_url: data.pub_url, first_seen_at: data.first_seen_at, year: null, citation_count: 0, venue_text: null, display_identifier: null, pdf_url: null, pdf_status: "untracked", pdf_attempt_count: 0, pdf_failure_reason: null, pdf_failure_detail: null, is_read: false, is_favorite: false, is_new_in_latest_run: true, }); if (this.livePublications.length > 50) { this.livePublications.pop(); } } catch (err) { console.error("Failed to parse SSE event", err); } }); eventSource.addEventListener("identifier_updated", (e) => { try { const data = JSON.parse(e.data); const publicationId = parseRunId(data?.publication_id); const displayIdentifier = parseDisplayIdentifier(data?.display_identifier); if (publicationId === null || displayIdentifier === null) { return; } this.livePublications = withUpdatedDisplayIdentifier( this.livePublications, { publicationId, displayIdentifier, }, ); } catch (err) { console.error("Failed to parse SSE event", err); } }); eventSource.onerror = () => { // Reconnecting is handled automatically by EventSource, // but if it's permanently closed, we could do something here. }; } }, async syncLatest(): Promise { if (syncPromise) { await syncPromise; return; } syncPromise = (async () => { try { const payload = await listRuns({ limit: 1 }); const latest = reconcileRunCounters(this.latestRun, payload.runs[0] ?? null); this.latestRun = latest; this.safetyState = normalizeSafetyState(payload.safety_state); this.lastSyncAt = Date.now(); this.lastErrorMessage = null; this.lastErrorRequestId = null; if (isActiveStatus(latest?.status)) { this.assumeRunningFromSubmission = true; } else if (!this.isSubmitting) { this.assumeRunningFromSubmission = false; } } catch (error) { if (error instanceof ApiRequestError) { this.lastErrorMessage = error.message; this.lastErrorRequestId = error.requestId; } else { this.lastErrorMessage = "Unable to refresh check status."; this.lastErrorRequestId = null; } } finally { syncPromise = null; this.updatePolling(); } })(); await syncPromise; }, async bootstrap(): Promise { await this.syncLatest(); }, async startManualCheck(): Promise { if (this.isRunActive) { return { kind: "already_running", runId: this.latestRun?.id ?? null, requestId: null, }; } if (this.safetyState.cooldown_active) { const message = this.safetyState.recommended_action || "Scrape safety cooldown is active; run start is temporarily blocked."; this.lastErrorMessage = message; this.lastErrorRequestId = null; return { kind: "error", message, requestId: null, }; } this.isSubmitting = true; this.lastErrorMessage = null; this.lastErrorRequestId = null; this.beginSubmittingPhaseTracking(); this.updatePolling(); try { const result = await triggerManualRun(); this.safetyState = normalizeSafetyState(result.safety_state); await this.syncLatest(); if (!this.latestRun || this.latestRun.id !== result.run_id) { this.latestRun = buildPlaceholderRunningRun(result.run_id); this.lastSyncAt = Date.now(); this.assumeRunningFromSubmission = true; } return { kind: "started", runId: result.run_id, reusedExistingRun: result.reused_existing_run, }; } catch (error) { if (error instanceof ApiRequestError && error.code === "run_in_progress") { const runId = extractRunIdFromDetails(error.details); if (runId !== null) { this.latestRun = buildPlaceholderRunningRun(runId); this.lastSyncAt = Date.now(); this.assumeRunningFromSubmission = true; } await this.syncLatest(); return { kind: "already_running", runId: this.latestRun?.id ?? runId, requestId: error.requestId, }; } if (error instanceof ApiRequestError && error.code === "scrape_cooldown_active") { const safetyState = extractSafetyStateFromDetails(error.details); if (safetyState) { this.safetyState = safetyState; } this.lastErrorMessage = error.message; this.lastErrorRequestId = error.requestId; return { kind: "error", message: error.message, requestId: error.requestId, }; } if (error instanceof ApiRequestError) { this.lastErrorMessage = error.message; this.lastErrorRequestId = error.requestId; return { kind: "error", message: error.message, requestId: error.requestId, }; } const fallbackMessage = "Unable to start an update check."; this.lastErrorMessage = fallbackMessage; this.lastErrorRequestId = null; return { kind: "error", message: fallbackMessage, requestId: null, }; } finally { this.isSubmitting = false; this.clearSubmittingPhaseTimer(); if (!isActiveStatus(this.latestRun?.status)) { this.assumeRunningFromSubmission = false; } this.updatePolling(); } }, async cancelActiveCheck(): Promise<{ kind: "success" } | { kind: "error"; message: string }> { if (!this.latestRun || !isActiveStatus(this.latestRun.status)) { return { kind: "error", message: "No active run to cancel." }; } try { const response = await cancelRun(this.latestRun.id); this.setLatestRun(reconcileRunCounters(this.latestRun, response.run)); this.setSafetyState(response.safety_state); return { kind: "success" }; } catch (error) { let errMessage = "Failed to cancel check."; if (error instanceof ApiRequestError) { errMessage = error.message; } return { kind: "error", message: errMessage }; } }, reset(): void { this.stopPolling(); if (eventSource !== null) { eventSource.close(); eventSource = null; activeStreamRunId = null; } this.clearSubmittingPhaseTimer(); this.latestRun = null; this.isSubmitting = false; this.assumeRunningFromSubmission = false; this.lastErrorMessage = null; this.lastErrorRequestId = null; this.lastSyncAt = null; this.safetyState = createDefaultSafetyState(); this.livePublications = []; }, }, });