266 lines
15 KiB
TypeScript
266 lines
15 KiB
TypeScript
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",
|
||
},
|
||
});
|
||
}
|