import { useEffect, useState } from "react"; import { z } from "zod"; const logRow = z.object({ seq: z.number(), step: z.number(), text: z.string() }); const snapshot = z.object({ rows: z.array(logRow), done: z.boolean() }); const event = z.discriminatedUnion("type", [ z.object({ type: z.literal("log"), seq: z.number(), step: z.number(), text: z.string() }), z.object({ type: z.literal("step"), index: z.number(), status: z.string(), conclusion: z.string().nullable() }), z.object({ type: z.literal("done"), conclusion: z.string().nullable() }), ]); export type LogRow = z.infer; type State = { rows: LogRow[]; live: boolean; error: string | null }; /** Loads a job's log and, while it runs, follows the Runner Durable Object's server-sent event stream. */ export function useJobLog(jobId: string, onStepChange: () => void) { const [state, setState] = useState({ rows: [], live: false, error: null }); useEffect(() => { let source: EventSource | null = null; let cancelled = false; async function start() { const response = await fetch(`/api/jobs/${jobId}/logs`); if (!response.ok) { setState((current) => ({ ...current, error: `Failed to load logs (${response.status})` })); return; } const initial = snapshot.parse(await response.json()); if (cancelled) return; setState({ rows: initial.rows, live: !initial.done, error: null }); if (initial.done) return; const after = initial.rows.at(-1)?.seq ?? 0; source = new EventSource(`/api/jobs/${jobId}/logs/stream?after=${after}`); source.onmessage = (message) => { const parsed = event.safeParse(JSON.parse(message.data)); if (!parsed.success) return; const payload = parsed.data; if (payload.type === "log") { setState((current) => current.rows.some((row) => row.seq === payload.seq) ? current : { ...current, rows: [...current.rows, { seq: payload.seq, step: payload.step, text: payload.text }] }, ); } else if (payload.type === "step") { onStepChange(); } else { setState((current) => ({ ...current, live: false })); source?.close(); onStepChange(); } }; } start().catch((error: unknown) => setState((current) => ({ ...current, error: error instanceof Error ? error.message : "Log stream failed" })), ); return () => { cancelled = true; source?.close(); }; }, [jobId, onStepChange]); return state; }