// Job activity adapter: prefer SSE (resumable, pushed), fall back to polling the // same durable events when EventSource is missing or the stream errors. The cursor // is the event `seq`, shared by both transports, so switching mid-stream never // drops or repeats an event. subscribeJob returns a stop() handle. import { api } from "./api.js"; // Mirrors ACTIVE_STATES in services/jobs.py; a job outside this set is terminal // and the poller stops. const ACTIVE = new Set(["queued", "running", "cancelling", "retry_queued"]); export function subscribeJob(jobId, { onEvent, onDone, onError, after = 0, pollMs = 1000 } = {}) { let cursor = after; let stopped = false; let source = null; let timer = null; function stop() { if (stopped) return; stopped = true; if (source) source.close(); if (timer) clearTimeout(timer); if (onDone) onDone(cursor); } async function poll() { if (stopped) return; try { const data = await api.jobEvents(jobId, cursor); for (const event of data.events) { cursor = event.seq; if (onEvent) onEvent(event); } if (!ACTIVE.has(data.state)) return stop(); } catch (error) { if (!stopped && onError) onError(error); } if (!stopped) timer = setTimeout(poll, pollMs); } if (typeof EventSource === "function") { source = new EventSource(api.jobEventsStreamUrl(jobId, cursor)); source.onmessage = (message) => { const event = JSON.parse(message.data); cursor = Number(message.lastEventId) || cursor; if (onEvent) onEvent({ seq: cursor, ...event }); }; // Server sends `event: done` when the job reaches a terminal state. source.addEventListener("done", stop); // Network/stream error: drop SSE and continue from the same cursor via polling // so no consumer notices the transport switch. source.onerror = () => { if (stopped || !source) return; source.close(); source = null; poll(); }; } else { poll(); } return { stop }; }