"use client"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { scoreTone } from "@/lib/format"; export type JobStep = { kind: "tool" | "status"; label: string; ts: number }; export type JobResult = { score: number | null; summary: string; tone: "good" | "warn" | "bad" | "muted" }; export type Job = { id: string; title: string; subtitle?: string; page?: string; // route the job was launched from / refers to input?: string; // the URL/posting it processed (links inbox rows to their worker) kind?: string; batchId?: string; // groups jobs fired together (e.g. "evaluate all Anthropic") status: "running" | "done" | "error"; steps: JobStep[]; text: string; result?: JobResult; cost?: { tokens: number; usd?: number }; // per-run token cost (Claude result event) — local only startedAt: number; endedAt?: number; }; type StartOpts = { title: string; subtitle?: string; kind: string; input: string; page?: string; batchId?: string }; type Ctx = { jobs: Job[]; startJob: (opts: StartOpts) => string | null; removeJob: (id: string) => void; clearFinished: () => void; }; const JobsContext = createContext(null); export function useJobs() { const c = useContext(JobsContext); if (!c) throw new Error("useJobs must be used within "); return c; } const CONFIG_KEY = "career-ops:config"; const JOBS_KEY = "career-ops:jobs"; function parseVerdict(text: string): JobResult { const m = text.match(/VERDICT:\s*([\d.]+)\s*\/\s*5\s*[—:|-]+\s*(.+)/i); if (m) { const score = parseFloat(m[1]); return { score, summary: m[2].trim().replace(/\s+/g, " ").slice(0, 90), tone: scoreTone(`${score}`) }; } const s = text.match(/\b([0-5](?:\.\d)?)\s*\/\s*5\b/); if (s) { const score = parseFloat(s[1]); return { score, summary: "", tone: scoreTone(`${score}`) }; } return { score: null, summary: "", tone: "muted" }; } export function JobsProvider({ children }: { children: React.ReactNode }) { const [jobs, setJobs] = useState([]); const seq = useRef(0); const loaded = useRef(false); // restore history useEffect(() => { try { const raw = localStorage.getItem(JOBS_KEY); const arr = raw ? JSON.parse(raw) : null; if (Array.isArray(arr)) { // anything left "running" from a previous session is stale → mark interrupted setJobs(arr.map((j: Job) => (j.status === "running" ? { ...j, status: "error", steps: [...(j.steps || []), { kind: "status", label: "Interrupted (page reloaded)", ts: Date.now() }] } : j))); } } catch { /* ignore */ } loaded.current = true; }, []); // persist useEffect(() => { if (!loaded.current) return; try { localStorage.setItem(JOBS_KEY, JSON.stringify(jobs.slice(0, 40))); } catch { /* quota */ } }, [jobs]); const patch = useCallback((id: string, fn: (j: Job) => Job) => { setJobs((js) => js.map((j) => (j.id === id ? fn(j) : j))); }, []); const startJob = useCallback( (opts: StartOpts): string | null => { let cliId: string | null = null; try { const raw = localStorage.getItem(CONFIG_KEY); cliId = raw ? JSON.parse(raw).cliId || null : null; } catch { cliId = null; } const id = `job-${Date.now()}-${seq.current++}`; const job: Job = { id, title: opts.title, subtitle: opts.subtitle, page: opts.page, input: opts.input, kind: opts.kind, batchId: opts.batchId, status: "running", steps: [{ kind: "status", label: "Starting…", ts: Date.now() }], text: "", startedAt: Date.now(), }; setJobs((js) => [job, ...js]); if (!cliId) { patch(id, (j) => ({ ...j, status: "error", endedAt: Date.now(), steps: [...j.steps, { kind: "status", label: "No CLI configured — open Config", ts: Date.now() }] })); return id; } (async () => { let text = ""; let verdictLine = ""; // latched separately so the 8000-char tail can't drop it let doneTokens = 0; // per-run token cost, forwarded on the done event (#6) let doneCostUsd: number | null = null; const steps: JobStep[] = []; const finish = (status: "done" | "error", lastLabel?: string) => { const result = status === "done" ? parseVerdict(verdictLine || text) : undefined; const cost = status === "done" && doneTokens > 0 ? { tokens: doneTokens, usd: doneCostUsd ?? undefined } : undefined; patch(id, (j) => ({ ...j, status, result, cost, endedAt: Date.now(), steps: lastLabel ? [...j.steps, { kind: "status", label: lastLabel, ts: Date.now() }] : j.steps, })); // persist a readable log file so the CLI/assistant can read past runs if (status === "done") { fetch("/api/runs/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, title: opts.title, subtitle: opts.subtitle, page: opts.page, input: opts.input, result, cost, steps, output: text }), }).catch(() => {}); // Tell server-snapshot surfaces (Today, pipeline) to refetch — the // worker just wrote a real tracker row / report they don't yet see. if (typeof window !== "undefined" && (opts.kind === "evaluate" || opts.kind === "pdf")) { window.dispatchEvent(new CustomEvent("co-job-done", { detail: { kind: opts.kind, input: opts.input } })); } } }; try { const res = await fetch("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind: opts.kind, input: opts.input, cliId }), }); if (!res.ok || !res.body) { const e = await res.json().catch(() => ({})); finish("error", e.error || "Failed to start"); return; } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf("\n")) !== -1) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; try { const ev = JSON.parse(line); if (ev.type === "tool") { steps.push({ kind: "tool", label: ev.name, ts: Date.now() }); patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "tool", label: ev.name, ts: Date.now() }] })); } else if (ev.type === "status") { steps.push({ kind: "status", label: ev.label, ts: Date.now() }); patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "status", label: ev.label, ts: Date.now() }] })); } else if (ev.type === "text") { const full = text + ev.text; const vm = full.match(/VERDICT:[^\n]*/i); if (vm) verdictLine = vm[0]; text = full.slice(-8000); patch(id, (j) => ({ ...j, text })); } else if (ev.type === "done") { // finish happens on stream-close; capture the per-run cost it carries if (typeof ev.tokens === "number") doneTokens = ev.tokens; if (typeof ev.costUsd === "number") doneCostUsd = ev.costUsd; } else if (ev.type === "error") { finish("error", ev.msg || "Error"); return; } } catch { /* skip */ } } } finish("done", "Done"); } catch { finish("error", "Connection error"); } })(); return id; }, [patch], ); const removeJob = useCallback((id: string) => setJobs((js) => js.filter((j) => j.id !== id)), []); const clearFinished = useCallback(() => setJobs((js) => js.filter((j) => j.status === "running")), []); return {children}; }