Files
career-ops-vbleroy/web/src/app/api/run/route.ts
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

266 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = { 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<typeof setTimeout> | undefined;
const stream = new ReadableStream<Uint8Array>({
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",
},
});
}