Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions prisma/migrations/20260813180000_job_evaluations/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Durable, source-independent asynchronous evaluation lifecycle.
CREATE TABLE "JobEvaluation" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"resumeId" TEXT NOT NULL,
"evaluatorKey" TEXT NOT NULL,
"evaluatorVersion" TEXT NOT NULL,
"definitionHash" TEXT NOT NULL,
"inputHash" TEXT NOT NULL,
"jobHash" TEXT NOT NULL,
"resumeHash" TEXT NOT NULL,
"inputSnapshot" TEXT NOT NULL,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"status" TEXT NOT NULL DEFAULT 'pending',
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" DATETIME,
"leaseToken" TEXT,
"leaseExpiresAt" DATETIME,
"resultJson" TEXT,
"resultHash" TEXT,
"lastError" TEXT,
"evaluatedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "JobEvaluation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "JobEvaluation_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "JobEvaluation_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE UNIQUE INDEX "JobEvaluation_jobId_resumeId_evaluatorKey_evaluatorVersion_inputHash_key"
ON "JobEvaluation"("jobId", "resumeId", "evaluatorKey", "evaluatorVersion", "inputHash");
CREATE INDEX "JobEvaluation_userId_isCurrent_evaluatorKey_evaluatorVersion_idx"
ON "JobEvaluation"("userId", "isCurrent", "evaluatorKey", "evaluatorVersion");
CREATE INDEX "JobEvaluation_userId_status_nextAttemptAt_idx"
ON "JobEvaluation"("userId", "status", "nextAttemptAt");
CREATE INDEX "JobEvaluation_status_leaseExpiresAt_idx" ON "JobEvaluation"("status", "leaseExpiresAt");
CREATE INDEX "JobEvaluation_jobId_isCurrent_idx" ON "JobEvaluation"("jobId", "isCurrent");
39 changes: 39 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ model User {
Tag Tag[]
Question Question[]
McpAccessToken McpAccessToken[]
JobEvaluation JobEvaluation[]
ChatConversation ChatConversation?
defaultResumeId String?
defaultResume Resume? @relation("UserDefaultResume", fields: [defaultResumeId], references: [id], onDelete: SetNull)
Expand Down Expand Up @@ -86,6 +87,7 @@ model Resume {
File File? @relation(fields: [FileId], references: [id])
FileId String? @unique
Job Job[]
JobEvaluation JobEvaluation[]
Automation Automation[]
defaultForUser User[] @relation("UserDefaultResume")
reviewData String?
Expand Down Expand Up @@ -312,6 +314,7 @@ model Job {
CoverLetter CoverLetter? @relation(fields: [coverLetterId], references: [id])
coverLetterId String?
Notes Note[]
evaluations JobEvaluation[]
tags Tag[]

// Automation discovery fields
Expand Down Expand Up @@ -530,6 +533,42 @@ model McpAccessToken {
@@index([userId])
}

model JobEvaluation {
id String @id @default(uuid())
userId String
jobId String
resumeId String
evaluatorKey String
evaluatorVersion String
definitionHash String
inputHash String
jobHash String
resumeHash String
inputSnapshot String
isCurrent Boolean @default(true)
status String @default("pending")
attemptCount Int @default(0)
nextAttemptAt DateTime?
leaseToken String?
leaseExpiresAt DateTime?
resultJson String?
resultHash String?
lastError String?
evaluatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
resume Resume @relation(fields: [resumeId], references: [id], onDelete: Cascade)

@@unique([jobId, resumeId, evaluatorKey, evaluatorVersion, inputHash])
@@index([userId, isCurrent, evaluatorKey, evaluatorVersion])
@@index([userId, status, nextAttemptAt])
@@index([status, leaseExpiresAt])
@@index([jobId, isCurrent])
}

model ChatConversation {
id String @id @default(uuid())
userId String @unique
Expand Down
16 changes: 14 additions & 2 deletions src/actions/mcpToken.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface PublicTokenMeta {
export async function createMcpToken(input: {
name: string;
expiryDays: 30 | 90 | 365;
type?: "agent" | "evaluation-worker";
}): Promise<
| { success: true; token: string; record: PublicTokenMeta }
| { success: false; message: string }
Expand All @@ -44,7 +45,9 @@ export async function createMcpToken(input: {
name: input.name.trim(),
tokenHash: hash,
tokenPrefix: prefix,
scopes: JSON.stringify(["jobs:write", "questions:write", "resume:write"]),
scopes: JSON.stringify(input.type === "evaluation-worker"
? ["evaluations:worker"]
: ["jobs:write", "questions:write", "resume:write"]),
expiresAt,
},
});
Expand Down Expand Up @@ -105,9 +108,18 @@ function toPublicMeta(record: {
id: record.id,
name: record.name,
tokenPrefix: record.tokenPrefix,
scopes: JSON.parse(record.scopes) as string[],
scopes: parseScopes(record.scopes),
expiresAt: record.expiresAt,
lastUsedAt: record.lastUsedAt,
createdAt: record.createdAt,
};
}

function parseScopes(scopes: string): string[] {
try {
const parsed: unknown = JSON.parse(scopes);
return Array.isArray(parsed) && parsed.every((scope) => typeof scope === "string") ? parsed : [];
} catch {
return [];
}
}
16 changes: 16 additions & 0 deletions src/app/api/job-evaluations/[id]/complete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { completeEvaluation } from "@/lib/jobEvaluations/service";
import { boundedJson, stringField, workerAuth } from "@/lib/jobEvaluations/http";

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await workerAuth(request);
if ("error" in auth) return auth.error;
const parsed = await boundedJson(request);
if ("error" in parsed) return parsed.error;
const body = parsed.value as Record<string, unknown>;
const leaseToken = stringField(body.leaseToken, 200);
const inputHash = stringField(body.inputHash, 128);
if (!leaseToken || !inputHash || !body.result || typeof body.result !== "object" || Array.isArray(body.result) || JSON.stringify(body.result).length > 96_000) return NextResponse.json({ error: "Invalid completion" }, { status: 400 });
const outcome = await completeEvaluation(auth.userId, (await params).id, leaseToken, inputHash, body.result as Record<string, unknown>);
return outcome === "completed" || outcome === "idempotent" ? NextResponse.json({ status: outcome }) : NextResponse.json({ error: "Stale or conflicting completion" }, { status: 409 });
}
17 changes: 17 additions & 0 deletions src/app/api/job-evaluations/[id]/fail/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { failEvaluation } from "@/lib/jobEvaluations/service";
import { boundedJson, stringField, workerAuth } from "@/lib/jobEvaluations/http";

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await workerAuth(request);
if ("error" in auth) return auth.error;
const parsed = await boundedJson(request, 8_000);
if ("error" in parsed) return parsed.error;
const body = parsed.value as Record<string, unknown>;
const leaseToken = stringField(body.leaseToken, 200);
const inputHash = stringField(body.inputHash, 128);
const error = stringField(body.error, 2_000);
if (!leaseToken || !inputHash || !error) return NextResponse.json({ error: "Invalid failure" }, { status: 400 });
const outcome = await failEvaluation(auth.userId, (await params).id, leaseToken, inputHash, error);
return outcome === "retry" || outcome === "failed" || outcome === "idempotent" ? NextResponse.json({ status: outcome }) : NextResponse.json({ error: "Stale or conflicting failure" }, { status: 409 });
}
45 changes: 45 additions & 0 deletions src/app/api/job-evaluations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/db";
import { materializeCurrentEvaluations, claimEvaluations, type Evaluator } from "@/lib/jobEvaluations/service";
import { boundedJson, stringField, workerAuth } from "@/lib/jobEvaluations/http";

function evaluatorFrom(value: unknown): Evaluator | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const body = value as Record<string, unknown>;
const evaluatorKey = stringField(body.evaluatorKey, 100);
const evaluatorVersion = stringField(body.evaluatorVersion, 100);
if (!evaluatorKey || !evaluatorVersion || body.evaluatorDefinition === undefined || JSON.stringify(body.evaluatorDefinition).length > 32_000) return null;
return { evaluatorKey, evaluatorVersion, evaluatorDefinition: body.evaluatorDefinition };
}

export async function POST(request: Request) {
const auth = await workerAuth(request);
if ("error" in auth) return auth.error;
const parsed = await boundedJson(request);
if ("error" in parsed) return parsed.error;
const body = parsed.value as Record<string, unknown>;
const evaluator = evaluatorFrom(body);
const batch = typeof body.batch === "number" ? body.batch : NaN;
const leaseSeconds = typeof body.leaseSeconds === "number" ? body.leaseSeconds : NaN;
if (!evaluator || !Number.isInteger(batch) || batch < 1 || batch > 10 || !Number.isInteger(leaseSeconds) || leaseSeconds < 15 || leaseSeconds > 3600) {
return NextResponse.json({ error: "Invalid claim request" }, { status: 400 });
}
const rows = await claimEvaluations(auth.userId, evaluator, batch, leaseSeconds * 1000);
return NextResponse.json({ evaluations: rows.map((row) => ({ id: row.id, inputHash: row.inputHash, leaseToken: row.leaseToken, leaseExpiresAt: row.leaseExpiresAt, input: JSON.parse(row.inputSnapshot) })) });
}

export async function GET(request: Request) {
const auth = await workerAuth(request);
if ("error" in auth) return auth.error;
const searchParams = new URL(request.url).searchParams;
const evaluatorKey = searchParams.get("evaluatorKey");
const evaluatorVersion = searchParams.get("evaluatorVersion");
const definition = searchParams.get("definition");
if (!evaluatorKey || !evaluatorVersion || !definition) return NextResponse.json({ error: "evaluatorKey, evaluatorVersion, and definition are required" }, { status: 400 });
let evaluatorDefinition: unknown;
try { evaluatorDefinition = JSON.parse(definition); } catch { return NextResponse.json({ error: "Invalid definition" }, { status: 400 }); }
await materializeCurrentEvaluations(auth.userId, { evaluatorKey, evaluatorVersion, evaluatorDefinition });
const jobId = new URL(request.url).searchParams.get("jobId");
const rows = await prisma.jobEvaluation.findMany({ where: { userId: auth.userId, evaluatorKey, evaluatorVersion, ...(jobId ? { jobId } : {}) }, orderBy: { createdAt: "desc" } });
return NextResponse.json({ evaluations: rows.filter((row) => row.isCurrent), history: rows.filter((row) => !row.isCurrent).map((row) => ({ id: row.id, jobId: row.jobId, status: row.status, evaluatedAt: row.evaluatedAt })) });
}
14 changes: 13 additions & 1 deletion src/components/settings/McpAccessSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export default function McpAccessSettings() {
const [expiryDays, setExpiryDays] = useState<30 | 90 | 365>(
APP_CONSTANTS.MCP_TOKEN_EXPIRY_DEFAULT_DAYS as 30 | 90 | 365,
);
const [tokenType, setTokenType] = useState<"agent" | "evaluation-worker">("agent");

const [revealedToken, setRevealedToken] = useState<{ token: string; name: string } | null>(null);

Expand All @@ -173,7 +174,7 @@ export default function McpAccessSettings() {
const handleGenerate = async () => {
if (!tokenName.trim()) return;
setGenerating(true);
const result = await createMcpToken({ name: tokenName.trim(), expiryDays });
const result = await createMcpToken({ name: tokenName.trim(), expiryDays, type: tokenType });
setGenerating(false);
if (!result.success) {
toastError(result.message);
Expand All @@ -183,6 +184,7 @@ export default function McpAccessSettings() {
setShowGenerateDialog(false);
setTokenName("");
setExpiryDays(APP_CONSTANTS.MCP_TOKEN_EXPIRY_DEFAULT_DAYS as 30 | 90 | 365);
setTokenType("agent");
await fetchTokens();
};

Expand Down Expand Up @@ -305,6 +307,16 @@ export default function McpAccessSettings() {
onKeyDown={(e) => e.key === "Enter" && handleGenerate()}
/>
</div>
<div className="space-y-2">
<Label>Token type</Label>
<Select value={tokenType} onValueChange={(value) => setTokenType(value as "agent" | "evaluation-worker")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="agent">Agent (jobs, questions, resume)</SelectItem>
<SelectItem value="evaluation-worker">Evaluation worker (evaluations only)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Expires in</Label>
<Select
Expand Down
8 changes: 7 additions & 1 deletion src/lib/ai/tools/preprocessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,13 @@ export const convertResumeToText = (resume: Resume): Promise<string> => {
return `## ${section.sectionTitle.toUpperCase()}\n${lines.join("\n")}`;
}
default:
return "";
{
const others = section.others
?.map((other) => removeHtmlTags(other.content))
.filter(Boolean)
.join("\n\n");
return others ? `## ${section.sectionTitle.toUpperCase()}\n${others}` : "";
}
}
})
.filter(Boolean)
Expand Down
38 changes: 38 additions & 0 deletions src/lib/jobEvaluations/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { resolveMcpToken } from "@/lib/mcp/auth";

export const MAX_BODY_BYTES = 128 * 1024;

export async function workerAuth(request: Request) {
const auth = await resolveMcpToken(request);
if (!auth.ok) return { error: NextResponse.json({ error: auth.error }, { status: auth.status }) };
if (!auth.scopes.includes("evaluations:worker")) return { error: NextResponse.json({ error: "Missing evaluations:worker scope" }, { status: 403 }) };
return { userId: auth.userId };
}

export async function boundedJson(request: Request, maximum = MAX_BODY_BYTES): Promise<{ value: unknown } | { error: NextResponse }> {
const length = request.headers.get("content-length");
if (length && (!/^\d+$/.test(length) || Number(length) > maximum)) {
return { error: NextResponse.json({ error: "Payload too large" }, { status: 413 }) };
}
const reader = request.body?.getReader();
if (!reader) return { error: NextResponse.json({ error: "Expected JSON body" }, { status: 400 }) };
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maximum) return { error: NextResponse.json({ error: "Payload too large" }, { status: 413 }) };
chunks.push(value);
}
try {
return { value: JSON.parse(new TextDecoder().decode(Buffer.concat(chunks))) };
} catch {
return { error: NextResponse.json({ error: "Malformed JSON" }, { status: 400 }) };
}
}

export function stringField(value: unknown, max: number): string | null {
return typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
}
29 changes: 29 additions & 0 deletions src/lib/jobEvaluations/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { isSubstantialDescription, sha256, stableJson, withSqliteBusyRetry } from "./service";

describe("job evaluation identity helpers", () => {
it("uses recursively stable JSON hashes without changing array order", () => {
expect(stableJson({ b: { d: 1, c: 2 }, a: ["second", "first"] }))
.toBe('{"a":["second","first"],"b":{"c":2,"d":1}}');
expect(sha256({ b: 2, a: 1 })).toBe(sha256({ a: 1, b: 2 }));
});

it("uses the persisted normalized description rather than write-time metadata", () => {
expect(isSubstantialDescription(`<p>${Array.from({ length: 150 }, (_, i) => `word${i}`).join(" ")}</p>`)).toBe(true);
expect(isSubstantialDescription("short description")).toBe(false);
});

it("retries transient SQLite busy errors", async () => {
const operation = vi.fn()
.mockRejectedValueOnce(new Error("SQLITE_BUSY: database is locked"))
.mockResolvedValueOnce("claimed");
await expect(withSqliteBusyRetry(operation)).resolves.toBe("claimed");
expect(operation).toHaveBeenCalledTimes(2);
});

it("does not retry non-transient errors", async () => {
const operation = vi.fn().mockRejectedValue(new Error("validation failed"));
await expect(withSqliteBusyRetry(operation)).rejects.toThrow("validation failed");
expect(operation).toHaveBeenCalledTimes(1);
});
});
Loading