feat(ai): queue strategy snapshots
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, StrategySnapshotOperationResponse, TailoredCvDraft, UserOperation } from "../types";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
@@ -188,6 +188,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||
const [focusPlanOperation, setFocusPlanOperation] = useState<UserOperation | null>(null);
|
||||
const announcedFocusPlanOperation = useRef<string | null>(null);
|
||||
const focusPlanLookupVersion = useRef(0);
|
||||
const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false);
|
||||
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
|
||||
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
||||
@@ -231,6 +234,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setDraftReloadToken(0);
|
||||
setDraftSubject("");
|
||||
setDraftBody("");
|
||||
setFocusPlanOperation(null);
|
||||
announcedFocusPlanOperation.current = null;
|
||||
focusPlanLookupVersion.current += 1;
|
||||
followUpCache.clearCached();
|
||||
candidateFitCache.clearCached();
|
||||
focusPlanCache.clearCached();
|
||||
@@ -372,31 +378,97 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const loadCachedFocusPlan = useCallback(async () => {
|
||||
if (!jobId) return null;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = focusPlanCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setFocusPlan(cached);
|
||||
return;
|
||||
return cached;
|
||||
}
|
||||
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
try {
|
||||
const r = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } });
|
||||
focusPlanCache.setCached(cacheKey, r.data);
|
||||
setFocusPlan(r.data);
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [canUseAi, open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
return r.data;
|
||||
} catch {
|
||||
setFocusPlan(null);
|
||||
return null;
|
||||
}
|
||||
}, [jobId, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
const regenerateFocusPlan = useCallback(() => {
|
||||
useEffect(() => {
|
||||
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
|
||||
setLoadingFocusPlan(true);
|
||||
void loadCachedFocusPlan().finally(() => setLoadingFocusPlan(false));
|
||||
}, [canUseAi, open, jobId, tab, focusPlan, loadCachedFocusPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseAi || !open || !jobId) return;
|
||||
const version = ++focusPlanLookupVersion.current;
|
||||
api.get<UserOperation>(`/jobapplications/${jobId}/focus-plan/operation`, { params: { attachmentIds: selectedAttachmentCsv || undefined } })
|
||||
.then(response => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(response.data); })
|
||||
.catch(() => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(null); });
|
||||
}, [canUseAi, open, jobId, selectedAttachmentCsv]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !focusPlanOperation || ["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
api.get<UserOperation>(`/operations/${focusPlanOperation.id}`)
|
||||
.then(response => setFocusPlanOperation(response.data))
|
||||
.catch(() => undefined);
|
||||
}, 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, focusPlanOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusPlanOperation || !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status) || announcedFocusPlanOperation.current === `${focusPlanOperation.id}:${focusPlanOperation.status}`) return;
|
||||
announcedFocusPlanOperation.current = `${focusPlanOperation.id}:${focusPlanOperation.status}`;
|
||||
if (focusPlanOperation.status === "succeeded") {
|
||||
void loadCachedFocusPlan().then(() => toast("Strategy snapshot completed.", "success"));
|
||||
} else if (focusPlanOperation.status === "failed") {
|
||||
toast("Strategy snapshot failed. You can retry safely.", "error");
|
||||
} else {
|
||||
toast("Strategy snapshot cancelled.", "info");
|
||||
}
|
||||
}, [focusPlanOperation, loadCachedFocusPlan, toast]);
|
||||
|
||||
const regenerateFocusPlan = useCallback(async () => {
|
||||
if (!canUseAi || !jobId) return;
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setFocusPlan(r.data);
|
||||
toast("Focus plan regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
|
||||
}, [canUseAi, jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
||||
try {
|
||||
const response = await api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null });
|
||||
focusPlanLookupVersion.current += 1;
|
||||
announcedFocusPlanOperation.current = null;
|
||||
setFocusPlanOperation(response.data.operation);
|
||||
toast(response.data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to queue strategy snapshot."), "error");
|
||||
} finally {
|
||||
setLoadingFocusPlan(false);
|
||||
}
|
||||
}, [canUseAi, jobId, selectedAttachmentCsv, toast]);
|
||||
|
||||
const cancelFocusPlan = useCallback(async () => {
|
||||
if (!focusPlanOperation?.canCancel) return;
|
||||
try {
|
||||
const response = await api.post<UserOperation>(`/operations/${focusPlanOperation.id}/cancel`);
|
||||
setFocusPlanOperation(response.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to cancel strategy snapshot."), "error");
|
||||
}
|
||||
}, [focusPlanOperation, toast]);
|
||||
|
||||
const retryFocusPlan = useCallback(async () => {
|
||||
if (!focusPlanOperation?.canRetry) return;
|
||||
try {
|
||||
announcedFocusPlanOperation.current = null;
|
||||
const response = await api.post<UserOperation>(`/operations/${focusPlanOperation.id}/retry`);
|
||||
setFocusPlanOperation(response.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to retry strategy snapshot."), "error");
|
||||
}
|
||||
}, [focusPlanOperation, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
@@ -758,18 +830,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="overline" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
|
||||
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi} onClick={async () => {
|
||||
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi || !!focusPlanOperation && !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setLoadingStrategySnapshot(true);
|
||||
try {
|
||||
const [fitRes, focusRes] = await Promise.all([
|
||||
const [fitRes, operationRes] = await Promise.all([
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null }),
|
||||
]);
|
||||
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data);
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data);
|
||||
setCandidateFit(fitRes.data);
|
||||
setFocusPlan(focusRes.data);
|
||||
focusPlanLookupVersion.current += 1;
|
||||
announcedFocusPlanOperation.current = null;
|
||||
setFocusPlanOperation(operationRes.data.operation);
|
||||
} catch {
|
||||
toast(t("jobDetailsStrategySnapshotFailed"), "error");
|
||||
} finally {
|
||||
@@ -777,6 +850,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"}</GradientButton>
|
||||
</Box>
|
||||
{focusPlanOperation && focusPlanOperation.status !== "succeeded" ? (
|
||||
<Alert severity={focusPlanOperation.status === "failed" ? "error" : focusPlanOperation.status === "cancelled" ? "warning" : "info"} sx={{ gridColumn: "1 / -1" }}
|
||||
action={<>
|
||||
{focusPlanOperation.canCancel ? <Button size="small" color="inherit" onClick={() => void cancelFocusPlan()}>Cancel</Button> : null}
|
||||
{focusPlanOperation.canRetry ? <Button size="small" color="inherit" onClick={() => void retryFocusPlan()}>Retry</Button> : null}
|
||||
</>}>
|
||||
Strategy snapshot: {strategyOperationLabel(focusPlanOperation)}
|
||||
</Alert>
|
||||
) : null}
|
||||
{candidateFit || focusPlan ? (
|
||||
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
@@ -1223,8 +1305,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
regenerateCandidateFit={regenerateCandidateFit}
|
||||
fitLevel={fitLevel}
|
||||
focusPlan={focusPlan}
|
||||
loadingFocusPlan={loadingFocusPlan}
|
||||
loadingFocusPlan={loadingFocusPlan || !!focusPlanOperation && !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)}
|
||||
regenerateFocusPlan={regenerateFocusPlan}
|
||||
focusPlanOperation={focusPlanOperation}
|
||||
cancelFocusPlan={() => void cancelFocusPlan()}
|
||||
retryFocusPlan={() => void retryFocusPlan()}
|
||||
interviewPrep={interviewPrep}
|
||||
loadingInterviewPrep={loadingInterviewPrep}
|
||||
regenerateInterviewPrep={regenerateInterviewPrep}
|
||||
@@ -1243,3 +1328,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function strategyOperationLabel(operation: UserOperation) {
|
||||
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
|
||||
switch (operation.status) {
|
||||
case "queued": return "queued";
|
||||
case "running": return "processing locally";
|
||||
case "waiting_for_retry": return "waiting to retry";
|
||||
case "waiting_for_external_fallback": return "waiting for approved fallback";
|
||||
case "failed": return `failed${operation.failureCategory ? ` (${operation.failureCategory.replaceAll("_", " ")})` : ""}`;
|
||||
case "cancelled": return "cancelled";
|
||||
default: return "completed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse } from "../types";
|
||||
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse, UserOperation } from "../types";
|
||||
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
|
||||
|
||||
type Props = {
|
||||
@@ -17,6 +17,9 @@ type Props = {
|
||||
focusPlan: FocusPlanResponse | null;
|
||||
loadingFocusPlan: boolean;
|
||||
regenerateFocusPlan: () => void;
|
||||
focusPlanOperation: UserOperation | null;
|
||||
cancelFocusPlan: () => void;
|
||||
retryFocusPlan: () => void;
|
||||
interviewPrep: InterviewPrepResponse | null;
|
||||
loadingInterviewPrep: boolean;
|
||||
regenerateInterviewPrep: () => void;
|
||||
@@ -26,7 +29,7 @@ type Props = {
|
||||
|
||||
export default function JobInsightTabs(props: Props) {
|
||||
const { t } = useI18n();
|
||||
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, focusPlanOperation, cancelFocusPlan, retryFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||
return <>
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
@@ -66,10 +69,19 @@ export default function JobInsightTabs(props: Props) {
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||
{loadingFocusPlan ? "Starting..." : focusPlan ? "Regenerate" : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||
{focusPlanOperation && focusPlanOperation.status !== "succeeded" ? (
|
||||
<Alert severity={focusPlanOperation.status === "failed" ? "error" : focusPlanOperation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }}
|
||||
action={<>
|
||||
{focusPlanOperation.canCancel ? <Button size="small" color="inherit" onClick={cancelFocusPlan}>Cancel</Button> : null}
|
||||
{focusPlanOperation.canRetry ? <Button size="small" color="inherit" onClick={retryFocusPlan}>Retry</Button> : null}
|
||||
</>}>
|
||||
Strategy snapshot: {operationLabel(focusPlanOperation)}
|
||||
</Alert>
|
||||
) : null}
|
||||
{loadingFocusPlan && !focusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
||||
@@ -116,3 +128,16 @@ export default function JobInsightTabs(props: Props) {
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function operationLabel(operation: UserOperation) {
|
||||
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
|
||||
switch (operation.status) {
|
||||
case "queued": return "queued";
|
||||
case "running": return "processing locally";
|
||||
case "waiting_for_retry": return "waiting to retry";
|
||||
case "waiting_for_external_fallback": return "waiting for approved fallback";
|
||||
case "failed": return `failed${operation.failureCategory ? ` (${operation.failureCategory.replaceAll("_", " ")})` : ""}`;
|
||||
case "cancelled": return "cancelled";
|
||||
default: return "completed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ beforeEach(() => {
|
||||
if (url === '/jobapplications/42/focus-plan') {
|
||||
return Promise.resolve({ data: { strategicSummary: 'Lead with backend delivery and measurable outcomes.', immediatePriorities: ['Highlight .NET ownership'], cvBulletIdeas: [], proofPointsToLeadWith: [], coverLetterAngles: [], followUpApproach: [] } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/focus-plan/operation') {
|
||||
return Promise.reject({ response: { status: 404 } });
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
|
||||
@@ -136,6 +139,11 @@ beforeEach(() => {
|
||||
if (url === '/jobapplications/42/generate-application-package') {
|
||||
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/focus-plan/operations') {
|
||||
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-1', operation: {
|
||||
id: 'strategy-1', taskType: 'strategy.snapshot', status: 'succeeded', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: false,
|
||||
} } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
@@ -252,3 +260,47 @@ test('strategy snapshot can be generated from overview', async () => {
|
||||
expect(await screen.findByText(/lead with backend delivery and measurable outcomes/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/highlight \.net ownership/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('strategy snapshot exposes queued and cancelled durable states', async () => {
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42/focus-plan/operations') {
|
||||
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-queued', operation: {
|
||||
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
|
||||
} } } as any);
|
||||
}
|
||||
if (url === '/operations/strategy-queued/cancel') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'cancelled', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: true,
|
||||
} } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
|
||||
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(await screen.findByText(/strategy snapshot: cancelled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed strategy snapshot offers a safe retry', async () => {
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42/focus-plan/operations') {
|
||||
return Promise.resolve({ data: { created: false, statusUrl: '/api/operations/strategy-failed', operation: {
|
||||
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'failed', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), failureCategory: 'provider_unavailable', canCancel: false, canRetry: true,
|
||||
} } } as any);
|
||||
}
|
||||
if (url === '/operations/strategy-failed/retry') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
|
||||
} } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
|
||||
expect(await screen.findByText(/strategy snapshot: failed.*provider unavailable/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -198,6 +198,29 @@ export interface FocusPlanResponse {
|
||||
strategicSummary: string;
|
||||
}
|
||||
|
||||
export interface UserOperation {
|
||||
id: string;
|
||||
taskType: string;
|
||||
status: "queued" | "running" | "waiting_for_retry" | "waiting_for_external_fallback" | "succeeded" | "failed" | "cancelled";
|
||||
subjectType?: string | null;
|
||||
createdAtUtc: string;
|
||||
startedAtUtc?: string | null;
|
||||
completedAtUtc?: string | null;
|
||||
deadlineAtUtc?: string | null;
|
||||
cancellationRequestedAtUtc?: string | null;
|
||||
progressStage?: string | null;
|
||||
progressPercent?: number | null;
|
||||
failureCategory?: string | null;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
export interface StrategySnapshotOperationResponse {
|
||||
operation: UserOperation;
|
||||
statusUrl: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface InterviewPrepResponse {
|
||||
summary: string;
|
||||
talkingPoints: string[];
|
||||
|
||||
Reference in New Issue
Block a user