feat(cv)!: queue durable processing
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:
@@ -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] ? (
|
||||
|
||||
Reference in New Issue
Block a user