Files
career-ops-vbleroy/web/src/components/jobs/job-store.tsx
T
redlineo d295c7f139
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s
init
2026-07-13 10:14:00 +03:00

221 lines
8.3 KiB
TypeScript

"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<Ctx | null>(null);
export function useJobs() {
const c = useContext(JobsContext);
if (!c) throw new Error("useJobs must be used within <JobsProvider>");
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<Job[]>([]);
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 <JobsContext.Provider value={{ jobs, startJob, removeJob, clearFinished }}>{children}</JobsContext.Provider>;
}