feat(cv)!: queue durable processing
CI and Deploy / test (pull_request) Failing after 1m39s
CI and Deploy / deploy (pull_request) Has been skipped

CV upload now returns 202 with an owner-scoped operation instead of holding the request through parsing. Existing review approval remains required.

BREAKING CHANGE: profile-cv upload responses use the durable operation contract.
This commit is contained in:
cesnimda
2026-08-09 15:11:03 +02:00
parent 39e98049ea
commit c3c5af8329
9 changed files with 614 additions and 141 deletions
+52 -7
View File
@@ -28,6 +28,7 @@ import {
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import type { UserOperation } from "../types";
import {
emptyStructuredCv,
getStructuredCvFieldMetadata,
@@ -52,6 +53,7 @@ type ExtractionRun = {
normalizerVersion: string;
llmPromptVersion: string;
errorMessage?: string;
operation?: UserOperation | null;
};
type CvImportDiff = {
@@ -74,8 +76,26 @@ type QueuedCvRunResponse = {
queued: boolean;
extractionRunId: number;
status: string;
operation?: UserOperation | null;
statusUrl?: string | null;
created: boolean;
};
const activeOperationStatuses = new Set(["queued", "running", "waiting_for_retry", "waiting_for_external_fallback"]);
const activeRunLabels = new Set(["queued", "running", "processing locally", "waiting to retry", "waiting for approved fallback"]);
function cvRunStatus(run: ExtractionRun) {
const operation = run.operation;
if (!operation || operation.status === "succeeded") return run.status;
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
switch (operation.status) {
case "running": return "processing locally";
case "waiting_for_retry": return "waiting to retry";
case "waiting_for_external_fallback": return "waiting for approved fallback";
default: return operation.status;
}
}
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -208,7 +228,9 @@ export default function CareerProfilePage() {
}, [loadProfile, loadVersions]);
useEffect(() => {
const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running");
const activeRuns = extractionRuns.filter((run) => run.operation
? activeOperationStatuses.has(run.operation.status)
: run.status === "queued" || run.status === "running");
if (activeRuns.length === 0) return;
const timer = window.setInterval(() => {
@@ -232,14 +254,15 @@ export default function CareerProfilePage() {
useEffect(() => {
const previous = runStatusRef.current;
for (const run of extractionRuns) {
const status = cvRunStatus(run);
const prior = previous[run.id];
if ((prior === "queued" || prior === "running") && run.status === "pending_review") {
if (activeRunLabels.has(prior) && status === "pending_review") {
toast(`CV ${run.trigger} is ready to review.`, "info");
}
if ((prior === "queued" || prior === "running") && run.status === "failed") {
if (activeRunLabels.has(prior) && status === "failed") {
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
}
previous[run.id] = run.status;
previous[run.id] = status;
}
}, [extractionRuns, toast]);
@@ -392,9 +415,9 @@ export default function CareerProfilePage() {
formData.append("file", file);
setUploadingCv(true);
try {
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast("CV extracted. Review the changes before applying them.", "info");
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
@@ -508,7 +531,7 @@ export default function CareerProfilePage() {
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 0.75 }}>
<Typography variant="overline">{run.trigger}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
<Chip size="small" label={run.status} color={run.status === "applied" ? "success" : run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
<Chip size="small" label={cvRunStatus(run)} color={run.status === "applied" ? "success" : run.operation?.status === "failed" || run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
{run.id === structuredCv.metadata.appliedExtractionRunId ? <Chip size="small" color="primary" label={t("profileCvCurrentRun")} /> : null}
</Box>
</Box>
@@ -521,6 +544,28 @@ export default function CareerProfilePage() {
{run.errorMessage}
</Typography>
) : null}
{run.operation?.canCancel ? (
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/cancel`);
await loadProfile();
toast("CV processing cancellation requested.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
}
}}>Cancel processing</Button>
) : null}
{run.operation?.canRetry ? (
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/retry`);
await loadProfile();
toast("CV processing queued again.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
}
}}>Retry processing</Button>
) : null}
{run.status === "pending_review" ? (
<Box sx={{ mt: 1.25 }}>
{runDiffs[run.id] ? (