init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,220 @@
|
||||
"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>;
|
||||
}
|
||||
Reference in new issue
Block a user