import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { resolveCli } from "@/lib/clis"; import { careerOpsRoot, readMemory } from "@/lib/career-ops"; import { acquireTrackerWrite, releaseTrackerWrite } from "@/lib/core/run-registry"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 800; // a real oferta evaluation / pdf-mode CV tailoring + render is heavy and multi-step // The web ORCHESTRATES the real career-ops engine — it does NOT reimplement it. // kind "evaluate" runs the REAL modes/oferta.md and persists the canonical // artifacts (A–F report + tracker row) via the SAME scripts the CLI uses // (reserve-report-num.mjs → reports/ → batch/tracker-additions/ → merge-tracker.mjs), // so a web evaluation is byte-identical to a CLI one (single source of truth, no // drift). kind "research" stays read-only. Streams progress as NDJSON events. function buildPrompt(kind: string, input: string, memory: string, today: string): string { const mem = memory.trim() ? `\n\nDurable notes about the user (from their profile):\n${memory.trim()}\n` : ""; if (kind === "research") { return `You are investigating the user's OWN work / portfolio to surface job-search-relevant strengths, headless. Investigate the target (use WebFetch for URLs; read local files if referenced) and report: what it is, why it is impressive, and how to leverage it in their job search — which roles/claims it supports and how to frame it on a CV. Be specific, honest, and encouraging.${mem} End with EXACTLY one final line: VERDICT: {0-5 signal strength}/5 — {why it helps their search, ≤12 words} Target: ${input}`; } if (kind === "pdf") { return `You are generating the user's ATS-optimized, TAILORED CV PDF for application #${input}, headless, on their machine. Run the REAL career-ops "pdf" mode — follow modes/pdf.md EXACTLY (do not improvise a format). 1. Read modes/pdf.md, cv.md, config/profile.yml, and the evaluation report at reports/${input}-*.md (for the JD keywords + analysis). 2. Tailor the CV per modes/pdf.md: inject the JD's keywords into the summary + first bullets, reorder experience by relevance, build the competency grid, pick the top 3–4 projects. NEVER invent skills — only reword REAL experience using the JD's vocabulary. 3. Fill templates/cv-template.html's {{...}} placeholders with the tailored content; write the HTML to /tmp/cv-{candidate}-{company}.html (candidate = the profile name in kebab-case). 4. Render the PDF: \`node generate-pdf.mjs /tmp/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-${today}.pdf --format={letter for US/Canada companies, else a4}\`. 5. Update the tracker: in data/applications.md, change the PDF column for row #${input} from ❌ to ✅. Do not submit anything anywhere. End with EXACTLY one final line: VERDICT: {5 if the PDF was written, else 1}/5 — {the output/ path, ≤12 words}`; } if (kind === "fix-portal") { return `A company's job-portal ATS slug is BROKEN — career-ops can no longer scan it, so it silently disappears from every future scan. Repair it (headless, on the user's machine): 1. Run \`node verify-portals.mjs --add "${input}"\` — it probes Greenhouse/Ashby/Lever for the company's correct ATS slug and prints the suggested ats + slug. 2. Open portals.yml, find the "${input}" entry under tracked_companies, and update its careers_url (and any api/slug field) to the suggested WORKING ATS URL. Change ONLY this one company; preserve all other YAML structure, comments and formatting exactly. 3. Re-run \`node verify-portals.mjs\` and confirm "${input}" now shows ✅ live (not ❌). If NO slug variant resolves, say so clearly and leave portals.yml unchanged. Never touch any other company. End with EXACTLY one final line: VERDICT: {5 if now live, else 1}/5 — {what you changed, ≤12 words}`; } // evaluate (default) — run the REAL oferta mode + persist canonically return `You are running the OFFICIAL career-ops job evaluation, HEADLESS, on the user's own machine. Today is ${today}. Run the REAL career-ops evaluation — do NOT improvise your own scoring. 1. Read modes/oferta.md and follow it EXACTLY (blocks A–F, G posting-legitimacy, and the Machine Summary). Ground the fit in THIS person: read cv.md, config/profile.yml and modes/_profile.md. Use WebFetch to read the posting (you are headless — Playwright is unavailable, so use WebFetch and mark the report header "Verification: unconfirmed (batch mode)"). 2. Persist the result CANONICALLY so the web and the CLI share ONE source of truth: a. Reserve a report number: run \`node reserve-report-num.mjs\` — its stdout is a 3-digit number (e.g. 035). b. Write the full report to reports/{num}-{company-slug}-${today}.md (company-slug = company lowercased, non-alphanumerics → hyphens). c. Append ONE row of 9 TAB-separated columns to batch/tracker-additions/{num}-{company-slug}.tsv, in THIS exact order (real \\t tabs, status BEFORE score): {num}\t${today}\t{Company}\t{Role}\t{CanonicalStatus e.g. Evaluated}\t{score}/5\t❌\t[{num}](reports/{num}-{company-slug}-${today}.md)\t{one-line note} d. Merge into the tracker: run \`node merge-tracker.mjs\` (it dedupes by company+role+report-num, validates the status, and writes data/applications.md — NEVER edit applications.md by hand). 3. NEVER submit an application, fill no forms, contact no one. This is evaluation + persistence ONLY.${mem} After everything above is written and merged, output EXACTLY one final line, nothing after it: VERDICT: {score}/5 — {reason in 12 words or fewer} Posting URL: ${input}`; } export async function POST(req: Request) { let body: { kind?: string; input?: string; cliId?: string }; try { body = await req.json(); } catch { return new Response(JSON.stringify({ error: "bad json" }), { status: 400 }); } const { kind = "evaluate", input, cliId } = body; if (!input || !cliId) { return new Response(JSON.stringify({ error: "input and cliId required" }), { status: 400 }); } const resolved = resolveCli(cliId); if (!resolved) { return new Response(JSON.stringify({ error: `CLI '${cliId}' not found` }), { status: 404, headers: { "Content-Type": "application/json" }, }); } const { spec, binPath } = resolved; // These run the REAL core (modes/scripts), not just data — fail clearly if the // root is incomplete instead of faking it. const needsScript: Record = { evaluate: "modes/oferta.md", "fix-portal": "verify-portals.mjs", pdf: "generate-pdf.mjs" }; const required = needsScript[kind]; if (required && !fs.existsSync(path.join(careerOpsRoot(), required))) { return new Response( JSON.stringify({ error: `This needs a complete career-ops checkout (${required}). CAREER_OPS_ROOT has data only — point it at a full checkout.`, }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } // An A–F score is meaningless without a CV to score against — the CLI would // hallucinate a fit narrative and still emit a VERDICT. Require cv.md first. if ((kind === "evaluate" || kind === "pdf") && !fs.existsSync(path.join(careerOpsRoot(), "cv.md"))) { return new Response( JSON.stringify({ error: "Add your CV first so I can score this against you — drop it on the home page." }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const today = new Date().toISOString().slice(0, 10); const prompt = buildPrompt(kind, input, readMemory(), today); const isClaude = cliId === "claude"; // Tool scope by kind (comma-separated lists; disallowedTools is the hard // guardrail). 'evaluate' runs the REAL mode + persists canonical artifacts → // it needs Write + Bash (reserve-report-num / merge-tracker / write the // report). 'research' stays read-only. Task (sub-agents) is always blocked // (runaway cost). NEVER auto-submits — that is a prompt-level guarantee. const tools = kind === "evaluate" || kind === "fix-portal" || kind === "pdf" ? { allowed: "Read,WebFetch,WebSearch,Write,Edit,Bash,Glob,Grep", disallowed: "Task,NotebookEdit" } : { allowed: "Read,WebFetch,WebSearch,Glob,Grep", disallowed: "Bash,Write,Edit,NotebookEdit,Task" }; const args = isClaude ? ["-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-mode", "acceptEdits", "--allowedTools", tools.allowed, "--disallowedTools", tools.disallowed] : spec.args(prompt); // For write-needing kinds, snapshot reports/ so we can verify the worker // actually persisted (non-Claude CLIs lack Write auth and silently no-op). const reportsDir = path.join(careerOpsRoot(), "reports"); const countReports = () => { try { return fs.readdirSync(reportsDir).filter((f) => f.endsWith(".md")).length; } catch { return 0; } }; const persists = kind === "evaluate"; const reportsBefore = persists ? countReports() : 0; // Tracker-mutating runs hold a write token so a row delete can't race their merge // (tracker.mjs delete doesn't yet share a lock with merge-tracker — see run-registry). const writeToken = kind === "evaluate" || kind === "pdf" ? acquireTrackerWrite() : null; const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env }); const enc = new TextEncoder(); // `closed` + kill timer in the OUTER scope so cancel() (client disconnect) can // flip `closed` before the child's late handlers run, and send() is try/catch'd — // otherwise a late enqueue onto a closed controller throws uncaught (see #1155). let closed = false; let killer: ReturnType | undefined; const stream = new ReadableStream({ start(controller) { let buf = ""; let emittedText = false; // any assistant text delta → the CLI actually ran let sawError = false; let lastTokens = 0; // per-run token cost from the Claude result event (#6) — local only let lastCostUsd: number | null = null; // pdf-mode tailors a full CV + renders it — give it more headroom. const killMs = kind === "pdf" ? 720_000 : 285_000; killer = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* ignore */ } }, killMs); const send = (obj: unknown) => { if (closed) return; try { controller.enqueue(enc.encode(JSON.stringify(obj) + "\n")); } catch { closed = true; } }; const close = () => { if (!closed) { closed = true; if (killer) clearTimeout(killer); if (writeToken !== null) releaseTrackerWrite(writeToken); try { controller.close(); } catch { /* */ } } }; child.stdout.on("data", (d: Buffer) => { if (closed) return; if (!isClaude) { emittedText = true; send({ type: "text", text: d.toString() }); return; } buf += d.toString(); 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 === "stream_event") { const e = ev.event; if (e?.type === "content_block_start" && e.content_block?.type === "tool_use") { send({ type: "tool", name: e.content_block.name }); } else if (e?.type === "content_block_delta" && e.delta?.text) { emittedText = true; send({ type: "text", text: e.delta.text }); } } else if (ev.type === "system" && ev.subtype === "init") { send({ type: "status", label: "Agent ready" }); } else if (ev.type === "result") { // Capture the per-run cost; the authoritative "done" is sent on close // (so the honesty gate decides done-vs-error first). Tokens = the same // formula /api/usage uses: input + output + cache-creation. const u = ev.usage || {}; lastTokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0); if (typeof ev.total_cost_usd === "number") lastCostUsd = ev.total_cost_usd; } } catch { /* partial line */ } } }); child.stderr.on("data", (d: Buffer) => { const s = d.toString(); // Widened: auth/login/quota failures are the most common real error and // the old narrow regex missed them (silent false "success"). if (/error|denied|fatal|not found|unauthorized|forbidden|auth|login|credential|api[ -]?key|quota|rate limit|not authenticated/i.test(s)) { sawError = true; send({ type: "error", msg: s.trim().slice(0, 200) }); } }); child.on("error", (e) => { send({ type: "error", msg: e.message }); close(); }); child.on("close", (code) => { const wroteReport = countReports() > reportsBefore; const cleanExit = code === 0; // non-zero OR null (killed/signal) = NOT clean // Honesty gate (#9): a green "done" with a parsed score requires a CLEAN exit, // real output, AND (for evaluations) a report actually written. Anything else // is surfaced — an errored run must never be banked as a confident score. if (!emittedText && !sawError && !cleanExit) { send({ type: "error", msg: "The CLI exited with an error — is it installed and authenticated?" }); } else if (!emittedText && !sawError) { send({ type: "error", msg: "The CLI produced no output — is it installed and authenticated? (career-ops is best on Claude Code.)" }); } else if (persists && !wroteReport) { // The worker ran but never wrote the report/tracker row (e.g. a CLI // without file-write authorization) — surface it instead of a fake score. send({ type: "error", msg: "This evaluation didn't save a report, so it's not in your tracker. Full evaluation is verified on Claude Code." }); } else if (!cleanExit || sawError) { // Produced output (maybe even a report) but did NOT finish cleanly — flag it // instead of recording a confident score off a half-finished run. send({ type: "error", msg: "This run hit an error before finishing, so it isn't recorded as a confident result — re-run it to verify." }); } else { send({ type: "done", tokens: lastTokens, costUsd: lastCostUsd }); } close(); }); }, cancel() { closed = true; if (killer) clearTimeout(killer); if (writeToken !== null) releaseTrackerWrite(writeToken); try { child.kill("SIGTERM"); } catch { /* ignore */ } }, }); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", }, }); }