From ac0a1281f49c0437bc30c638a876cbb61001bb25 Mon Sep 17 00:00:00 2001 From: Ammad Ali <73386704+Mr-Nobody1@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:38:04 +0000 Subject: [PATCH] Enhance RxResume validation, settings handling, and caching (#287) * feat: enhance validation handling in ReactiveResumeConfigPanel and rxresume-config * feat: enhance RxResume validation handling in SettingsPage and ReactiveResumeSection * feat: enhance RxResume settings handling and cache management * feat: add save-time validation and caching for Reactive Resume settings * refactor: improve code formatting and readability across multiple files * fix: improve condition check in hasOverrideKey function * feat: enhance RxResume validation and settings handling with precheck options * refactor: streamline RxResume client initialization and improve backup sorting logic --- biome.json | 3 +- docs-site/docs/features/reactive-resume.md | 16 + docs-site/docs/features/settings.md | 5 + .../components/ReactiveResumeConfigPanel.tsx | 40 +++ .../src/client/lib/rxresume-config.ts | 62 +++- .../src/client/pages/SettingsPage.test.tsx | 130 +++++++ .../src/client/pages/SettingsPage.tsx | 153 +++++++-- .../components/ReactiveResumeSection.tsx | 50 ++- .../src/server/api/routes/backup.test.ts | 1 + orchestrator/src/server/api/routes/backup.ts | 175 +++++----- .../src/server/api/routes/onboarding.test.ts | 65 ++++ .../src/server/api/routes/onboarding.ts | 89 ++++- .../src/server/api/routes/profile.test.ts | 1 + orchestrator/src/server/api/routes/profile.ts | 7 +- .../src/server/api/routes/settings.test.ts | 115 +++++++ .../src/server/api/routes/settings.ts | 127 ++++++- .../src/server/api/routes/test-utils.ts | 24 +- orchestrator/src/server/db/index.ts | 3 + .../src/server/services/backup/index.ts | 143 ++------ .../src/server/services/profile.test.ts | 5 + orchestrator/src/server/services/profile.ts | 4 +- .../src/server/services/rxresume/client.ts | 17 +- .../server/services/rxresume/index.test.ts | 106 ++++++ .../src/server/services/rxresume/index.ts | 317 ++++++++++++------ .../settings-update/apply-updates.test.ts | 23 ++ .../services/settings-update/apply-updates.ts | 1 + .../services/settings-update/registry.ts | 26 +- shared/src/types/settings.ts | 1 + 28 files changed, 1364 insertions(+), 345 deletions(-) diff --git a/biome.json b/biome.json index 1fd4830..731b43d 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,8 @@ "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", "formatter": { "indentStyle": "space", - "indentWidth": 2 + "indentWidth": 2, + "lineEnding": "lf" }, "files": { "includes": [ diff --git a/docs-site/docs/features/reactive-resume.md b/docs-site/docs/features/reactive-resume.md index a7dd8b8..485d888 100644 --- a/docs-site/docs/features/reactive-resume.md +++ b/docs-site/docs/features/reactive-resume.md @@ -108,6 +108,14 @@ Or via environment variables: If you leave the URL blank in the dashboard, JobOps uses `RXRESUME_URL` when it is set; if not set, it falls back to the public cloud default for the selected mode. +### Save-time validation + +When you save Reactive Resume credentials or the shared URL in Settings: + +1. JobOps validates only the credential-bearing Reactive Resume fields for the selected mode. +2. Invalid credentials or other `4xx` configuration failures block the save and show a persistent inline error. +3. Temporary network failures, timeouts, or upstream `5xx` errors show a persistent inline warning, but the save still succeeds. + ### 2) Select base resume In **Settings → Reactive Resume**: @@ -139,6 +147,12 @@ High-level flow: 6. Export PDF. 7. Delete temporary resume. +### Resume-data caching + +JobOps caches successful Reactive Resume resume fetches in memory for 5 minutes. + +This reduces repeated API calls from settings loads, profile checks, project lookups, and PDF generation while still refreshing often enough for normal editing workflows. + ### Per-job tracer links Before generating a PDF, each job can enable/disable tracer links. @@ -208,6 +222,8 @@ curl -X POST "http://localhost:3001/api/jobs//generate-pdf" - Ensure the selected mode has credentials configured. - `v5`: set a valid API key. - `v4`: set email + password. +- Invalid credentials block save and remain visible as an inline error until you edit the selected mode's credentials or URL. +- Temporary Reactive Resume downtime shows an inline warning, but other settings can still be saved. - Save settings, then refresh resumes in the Reactive Resume section. ### No resumes appear in dropdown diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index f6688b5..201032b 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -111,11 +111,14 @@ Defaults and constraints: - Configure a shared RxResume URL for cloud or self-hosted deployments - Configure v4 email/password or v5 API key in the same section +- Invalid Reactive Resume credentials or other `4xx` config failures block the save and stay visible as an inline error +- Temporary Reactive Resume downtime shows an inline warning, but the save still succeeds - Select a template/base resume - Configure project selection behavior: - Max projects - Must-include projects - AI-selectable projects +- JobOps briefly caches successful Reactive Resume resume data to reduce repeated API calls across settings, profile, and PDF flows ### Tracer Links @@ -215,6 +218,8 @@ curl -X POST "http://localhost:3001/api/backups" - JobOps resolves the RxResume URL in this order: the value saved in **Settings → Reactive Resume**, then the `RXRESUME_URL` environment variable (if set), and finally the public cloud default. - Open **Settings → Reactive Resume** and configure the shared RxResume URL if you use a self-hosted instance. - If you leave the URL blank, JobOps will fall back to `RXRESUME_URL` when it is configured; otherwise it uses the public cloud default. +- Invalid credentials block the save and remain visible inline until you edit the selected mode's credentials or URL. +- Temporary instance downtime shows a warning inline, but does not block unrelated settings updates. - Then refresh available resumes from the Reactive Resume section. ### RxResume projects look empty in the RxResume UI diff --git a/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx index 1a8c2ba..2e6098a 100644 --- a/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx +++ b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx @@ -6,7 +6,9 @@ import { } from "@client/pages/settings/resume-projects-state"; import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js"; import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js"; +import { AlertCircle, AlertTriangle } from "lucide-react"; import type React from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; @@ -26,6 +28,7 @@ type VersionValidationState = { checked: boolean; valid: boolean; message?: string | null; + status?: number | null; }; type ProjectSelectionConfig = { @@ -107,6 +110,11 @@ function renderStatusPill(label: string, state: VersionValidationState) { ); } +function isAvailabilityWarning(state?: VersionValidationState): boolean { + const status = state?.status ?? null; + return status === 0 || (typeof status === "number" && status >= 500); +} + export const ReactiveResumeConfigPanel: React.FC< ReactiveResumeConfigPanelProps > = ({ @@ -126,6 +134,14 @@ export const ReactiveResumeConfigPanel: React.FC< projectSelection && hasRxResumeAccess, ); const selectedValidationStatus = validationStatuses?.[mode]; + const showInlineValidationAlert = Boolean( + selectedValidationStatus?.checked && + !selectedValidationStatus.valid && + selectedValidationStatus.message, + ); + const selectedValidationIsWarning = + showInlineValidationAlert && + isAvailabilityWarning(selectedValidationStatus); const handleModeChange = (value: string) => onModeChange(value === "v4" ? "v4" : "v5"); @@ -157,6 +173,30 @@ export const ReactiveResumeConfigPanel: React.FC< ) : null} + {showInlineValidationAlert && selectedValidationStatus?.message ? ( + svg]:text-amber-700" + : undefined + } + > + {selectedValidationIsWarning ? ( + + ) : ( + + )} + + Reactive Resume {mode.toUpperCase()}{" "} + {selectedValidationIsWarning ? "warning" : "error"} + + + {selectedValidationStatus.message} + + + ) : null} + {mode === "v5" ? (
({ - email: draft.email || undefined, - baseUrl: draft.baseUrl || undefined, - password: draft.password || undefined, - apiKey: draft.apiKey || undefined, -}); + options?: { + preserveBlankFields?: Array; + }, +) => { + const preserveBlankFields = new Set(options?.preserveBlankFields ?? []); + return { + email: preserveBlankFields.has("email") + ? draft.email + : draft.email || undefined, + baseUrl: preserveBlankFields.has("baseUrl") + ? draft.baseUrl + : draft.baseUrl || undefined, + password: preserveBlankFields.has("password") + ? draft.password + : draft.password || undefined, + apiKey: preserveBlankFields.has("apiKey") + ? draft.apiKey + : draft.apiKey || undefined, + }; +}; + +export const isRxResumeBlockingValidationFailure = ( + validation: ValidationResult, +): boolean => + !validation.valid && + typeof validation.status === "number" && + validation.status >= 400 && + validation.status < 500; + +export const isRxResumeAvailabilityValidationFailure = ( + validation: ValidationResult, +): boolean => + !validation.valid && + (validation.status === 0 || + (typeof validation.status === "number" && validation.status >= 500)); export const buildRxResumeSettingsUpdate = ( mode: RxResumeMode, @@ -149,6 +178,7 @@ type ValidateAndMaybePersistRxResumeModeInput = { ) => Promise; persist?: (update: Partial) => Promise; persistOnSuccess?: boolean; + skipPrecheck?: boolean; getPrecheckMessage?: ( failure: Exclude, ) => string; @@ -172,6 +202,7 @@ export const validateAndMaybePersistRxResumeMode = async ( validate, persist, persistOnSuccess = false, + skipPrecheck = false, getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure], getValidationErrorMessage = (error) => error instanceof Error ? error.message : "RxResume validation failed", @@ -181,16 +212,19 @@ export const validateAndMaybePersistRxResumeMode = async ( : "Failed to save RxResume settings", } = input; - const precheckFailure = getRxResumeCredentialPrecheckFailure({ - mode, - stored, - draft, - }); - if (precheckFailure) { + const precheckFailure = skipPrecheck + ? null + : getRxResumeCredentialPrecheckFailure({ + mode, + stored, + draft, + }); + if (precheckFailure !== null) { return { validation: { valid: false, message: getPrecheckMessage(precheckFailure), + status: 400, }, precheckFailure, updatedSettings: null, @@ -208,6 +242,7 @@ export const validateAndMaybePersistRxResumeMode = async ( validation: { valid: false, message: getValidationErrorMessage(error, mode), + status: 0, }, precheckFailure: null, updatedSettings: null, @@ -219,6 +254,7 @@ export const validateAndMaybePersistRxResumeMode = async ( validation: { valid: validation.valid, message: validation.valid ? null : (validation.message ?? null), + status: validation.valid ? null : (validation.status ?? null), }, precheckFailure: null, updatedSettings: null, @@ -233,6 +269,7 @@ export const validateAndMaybePersistRxResumeMode = async ( validation: { valid: true, message: null, + status: null, }, precheckFailure: null, updatedSettings, @@ -242,6 +279,7 @@ export const validateAndMaybePersistRxResumeMode = async ( validation: { valid: false, message: getPersistErrorMessage(error, mode), + status: 0, }, precheckFailure: null, updatedSettings: null, diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 8d64155..18b57a4 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -17,6 +17,7 @@ vi.mock("../api", () => ({ getSettings: vi.fn(), updateSettings: vi.fn(), validateRxresume: vi.fn(), + getRxResumeProjects: vi.fn(), clearDatabase: vi.fn(), deleteJobsByStatus: vi.fn(), getTracerReadiness: vi.fn(), @@ -72,6 +73,13 @@ const openWritingStyleSection = async () => { fireEvent.click(chatTrigger); }; +const openReactiveResumeSection = async () => { + const trigger = await screen.findByRole("button", { + name: /reactive resume/i, + }); + fireEvent.click(trigger); +}; + describe("SettingsPage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -92,6 +100,7 @@ describe("SettingsPage", () => { vi.mocked(api.validateRxresume).mockResolvedValue({ valid: false, message: "Missing credentials", + status: 400, }); }); @@ -308,6 +317,127 @@ describe("SettingsPage", () => { ); }); + it("blocks save and renders an inline alert when the v5 API key is invalid", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + + renderPage(); + await openReactiveResumeSection(); + + await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); + vi.mocked(api.validateRxresume).mockClear(); + vi.mocked(api.validateRxresume).mockResolvedValue({ + valid: false, + message: + "Reactive Resume v5 API key is invalid. Update the API key and try again.", + status: 401, + }); + + fireEvent.change(screen.getByLabelText(/v5 api key/i), { + target: { value: "invalid-v5-key" }, + }); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + expect( + await screen.findByText(/Reactive Resume v5 API key is invalid/i), + ).toBeInTheDocument(); + expect(api.updateSettings).not.toHaveBeenCalled(); + }); + + it("allows saving on RxResume availability warnings and keeps the inline warning visible", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + vi.mocked(api.updateSettings).mockResolvedValue({ + ...baseSettings, + rxresumeApiKeyHint: "rr-v", + }); + + renderPage(); + await openReactiveResumeSection(); + + await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); + vi.mocked(api.validateRxresume).mockClear(); + vi.mocked(api.validateRxresume).mockResolvedValue({ + valid: false, + message: + "JobOps could not verify Reactive Resume because the instance is unavailable right now.", + status: 0, + }); + + fireEvent.change(screen.getByLabelText(/v5 api key/i), { + target: { value: "rr-v5-warning-key" }, + }); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()); + expect( + await screen.findByText(/instance is unavailable right now/i), + ).toBeInTheDocument(); + expect(toast.success).toHaveBeenCalledWith("Settings saved"); + expect(toast.info).toHaveBeenCalledWith( + "Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable.", + ); + }); + + it("does not run RxResume validation for unrelated settings saves", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + vi.mocked(api.updateSettings).mockResolvedValue({ + ...baseSettings, + model: { + value: "new-model", + default: baseSettings.model.default, + override: "new-model", + }, + }); + + renderPage(); + await openModelSection(); + await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); + vi.mocked(api.validateRxresume).mockClear(); + + fireEvent.change(screen.getByLabelText(/default model/i), { + target: { value: "new-model" }, + }); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()); + expect(api.validateRxresume).not.toHaveBeenCalled(); + }); + + it("clears the previous RxResume warning when the key or URL changes", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + vi.mocked(api.validateRxresume).mockResolvedValue({ + valid: false, + message: + "JobOps could not verify Reactive Resume because the instance is unavailable right now.", + status: 0, + }); + + renderPage(); + await openReactiveResumeSection(); + + expect( + await screen.findByText(/instance is unavailable right now/i), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/rxresume url/i), { + target: { value: "https://resume.example.com" }, + }); + + await waitFor(() => + expect( + screen.queryByText(/instance is unavailable right now/i), + ).not.toBeInTheDocument(), + ); + }); + it("saves the writing language mode through the settings page", async () => { vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.updateSettings).mockResolvedValue( diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 8aa222e..b39ef0b 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -6,8 +6,12 @@ import { useTracerReadiness } from "@client/hooks/useTracerReadiness"; import { coerceRxResumeMode, getRxResumeCredentialDrafts, + getRxResumeCredentialPrecheckFailure, + isRxResumeAvailabilityValidationFailure, + isRxResumeBlockingValidationFailure, RXRESUME_MODES, RXRESUME_PRECHECK_MESSAGES, + toRxResumeValidationPayload, validateAndMaybePersistRxResumeMode, } from "@client/lib/rxresume-config"; import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection"; @@ -37,6 +41,7 @@ import type { ResumeProjectCatalogItem, ResumeProjectsSettings, RxResumeMode, + ValidationResult, } from "@shared/types.js"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings } from "lucide-react"; @@ -101,13 +106,31 @@ type RxResumeValidationBadgeState = { checked: boolean; valid: boolean; message: string | null; + status: number | null; }; const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = { checked: false, valid: false, message: null, + status: null, }; +const getRxResumeValidationFieldsForMode = ( + mode: RxResumeMode, +): Array => + mode === "v5" + ? ["rxresumeApiKey", "rxresumeUrl"] + : ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"]; + +const toRxResumeValidationBadgeState = ( + validation: ValidationResult, +): RxResumeValidationBadgeState => ({ + checked: true, + valid: validation.valid, + message: validation.valid ? null : (validation.message ?? null), + status: validation.valid ? null : (validation.status ?? null), +}); + const normalizeLlmProviderValue = ( value: string | null | undefined, ): LlmProviderValue => (value ? normalizeLlmProvider(value) : null); @@ -404,6 +427,7 @@ export const SettingsPage: React.FC = () => { }); const { + clearErrors, handleSubmit, reset, setError, @@ -598,6 +622,27 @@ export const SettingsPage: React.FC = () => { } }, [refreshReadiness]); + const setRxResumeValidationStatus = useCallback( + (mode: RxResumeMode, validation: ValidationResult) => { + setRxresumeValidationStatuses((current) => ({ + ...current, + [mode]: toRxResumeValidationBadgeState(validation), + })); + }, + [], + ); + + const clearRxResumeValidationFeedback = useCallback( + (mode: RxResumeMode) => { + setRxresumeValidationStatuses((current) => ({ + ...current, + [mode]: EMPTY_RXRESUME_VALIDATION_BADGE_STATE, + })); + clearErrors(getRxResumeValidationFieldsForMode(mode)); + }, + [clearErrors], + ); + const validateRxresumeMode = useCallback( async ( mode: RxResumeMode, @@ -614,6 +659,7 @@ export const SettingsPage: React.FC = () => { validate: api.validateRxresume, persist: api.updateSettings, persistOnSuccess, + skipPrecheck: silent, getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure], getValidationErrorMessage: (error) => error instanceof Error ? error.message : "RxResume validation failed", @@ -621,16 +667,7 @@ export const SettingsPage: React.FC = () => { error instanceof Error ? error.message : "RxResume validation failed", }); - setRxresumeValidationStatuses((current) => ({ - ...current, - [mode]: { - checked: true, - valid: result.validation.valid, - message: result.validation.valid - ? null - : (result.validation.message ?? null), - }, - })); + setRxResumeValidationStatus(mode, result.validation); if (result.updatedSettings) { setSettings(result.updatedSettings); @@ -661,7 +698,7 @@ export const SettingsPage: React.FC = () => { `Reactive Resume ${mode} validation failed`, ); }, - [getValues, queryClient, storedRxResume], + [getValues, queryClient, setRxResumeValidationStatus, storedRxResume], ); useEffect(() => { @@ -677,7 +714,7 @@ export const SettingsPage: React.FC = () => { validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }), ), ); - }, [settings, rxresumeValidationStatuses, validateRxresumeMode]); + }, [rxresumeValidationStatuses, settings, validateRxresumeMode]); const effectiveProfileProjects = rxResumeProjectsOverride ?? @@ -786,7 +823,7 @@ export const SettingsPage: React.FC = () => { if (value !== undefined) envPayload.webhookSecret = value; } - const payload: UpdateSettingsInput = { + const payload: Partial = { model: normalizeString(data.model), modelScorer: normalizeString(data.modelScorer), modelTailoring: normalizeString(data.modelTailoring), @@ -794,8 +831,12 @@ export const SettingsPage: React.FC = () => { pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), resumeProjects: resumeProjectsOverride, - rxresumeMode: data.rxresumeMode ?? "v5", - rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), + ...(dirtyFields.rxresumeMode + ? { rxresumeMode: data.rxresumeMode ?? "v5" } + : {}), + ...(dirtyFields.rxresumeBaseResumeId + ? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) } + : {}), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), chatStyleTone: normalizeString(data.chatStyleTone), chatStyleFormality: normalizeString(data.chatStyleFormality), @@ -836,15 +877,88 @@ export const SettingsPage: React.FC = () => { ...envPayload, }; - // Remove virtual field because the backend doesn't expect it - // this exists only to toggle the UI - // need to track it so that the save button is enabled when it changes - delete payload.enableBasicAuth; + const shouldValidateRxResumeBeforeSave = Boolean( + dirtyFields.rxresumeMode || + dirtyFields.rxresumeUrl || + dirtyFields.rxresumeApiKey || + dirtyFields.rxresumeEmail || + dirtyFields.rxresumePassword, + ); + const rxResumeValidationMode = (data.rxresumeMode ?? + rxresumeMode) as RxResumeMode; + let rxResumeSaveWarningMessage: string | null = null; + + if (shouldValidateRxResumeBeforeSave) { + const validationDraft = getRxResumeCredentialDrafts(data); + const precheckFailure = getRxResumeCredentialPrecheckFailure({ + mode: rxResumeValidationMode, + stored: storedRxResume, + draft: validationDraft, + }); + + if (!precheckFailure) { + const preserveBlankFields = [ + ...(dirtyFields.rxresumeEmail ? (["email"] as const) : []), + ...(dirtyFields.rxresumePassword ? (["password"] as const) : []), + ...(dirtyFields.rxresumeApiKey ? (["apiKey"] as const) : []), + ...(dirtyFields.rxresumeUrl ? (["baseUrl"] as const) : []), + ]; + const validation = await api.validateRxresume({ + mode: rxResumeValidationMode, + ...toRxResumeValidationPayload(validationDraft, { + preserveBlankFields: preserveBlankFields as Array< + keyof ReturnType + >, + }), + }); + + setRxResumeValidationStatus(rxResumeValidationMode, validation); + + if (isRxResumeBlockingValidationFailure(validation)) { + clearErrors( + getRxResumeValidationFieldsForMode(rxResumeValidationMode), + ); + if (rxResumeValidationMode === "v5") { + setError("rxresumeApiKey", { + type: "manual", + message: + validation.message ?? + "Reactive Resume v5 API key is invalid.", + }); + } else { + setError("rxresumeEmail", { + type: "manual", + message: + validation.message ?? + "Reactive Resume v4 email/password is invalid.", + }); + setError("rxresumePassword", { + type: "manual", + message: + validation.message ?? + "Reactive Resume v4 email/password is invalid.", + }); + } + return; + } + + clearErrors( + getRxResumeValidationFieldsForMode(rxResumeValidationMode), + ); + if (isRxResumeAvailabilityValidationFailure(validation)) { + rxResumeSaveWarningMessage = + "Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable."; + } + } + } const updated = await updateSettingsMutation.mutateAsync(payload); setSettings(updated); reset(mapSettingsToForm(updated)); toast.success("Settings saved"); + if (rxResumeSaveWarningMessage) { + toast.info(rxResumeSaveWarningMessage); + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings"; @@ -995,6 +1109,7 @@ export const SettingsPage: React.FC = () => { }} hasRxResumeAccess={hasRxResumeAccess} rxresumeMode={rxresumeMode} + onCredentialFieldEdit={clearRxResumeValidationFeedback} validationStatuses={rxresumeValidationStatuses} profileProjects={effectiveProfileProjects} lockedCount={lockedCount} diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 8e8f1be..5cc2f4e 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -21,9 +21,20 @@ type ReactiveResumeSectionProps = { hasRxResumeAccess: boolean; rxresumeMode: RxResumeMode; onRxresumeModeChange?: (mode: RxResumeMode) => void; + onCredentialFieldEdit?: (mode: RxResumeMode) => void; validationStatuses?: { - v4: { checked: boolean; valid: boolean; message?: string | null }; - v5: { checked: boolean; valid: boolean; message?: string | null }; + v4: { + checked: boolean; + valid: boolean; + message?: string | null; + status?: number | null; + }; + v5: { + checked: boolean; + valid: boolean; + message?: string | null; + status?: number | null; + }; }; profileProjects: ResumeProjectCatalogItem[]; lockedCount: number; @@ -39,6 +50,7 @@ export const ReactiveResumeSection: React.FC = ({ hasRxResumeAccess, rxresumeMode, onRxresumeModeChange, + onCredentialFieldEdit, validationStatuses, profileProjects, lockedCount, @@ -49,6 +61,7 @@ export const ReactiveResumeSection: React.FC = ({ }) => { const { control, + clearErrors, setValue, formState: { errors }, } = useFormContext(); @@ -70,6 +83,15 @@ export const ReactiveResumeSection: React.FC = ({ shouldTouch: true, }); + const clearRxResumeFeedback = (mode: RxResumeMode) => { + onCredentialFieldEdit?.(mode); + clearErrors( + mode === "v5" + ? ["rxresumeApiKey", "rxresumeUrl"] + : ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"], + ); + }; + return ( @@ -88,24 +110,32 @@ export const ReactiveResumeSection: React.FC = ({ validationStatuses={validationStatuses} shared={{ baseUrl: rxresumeUrlValue, - onBaseUrlChange: (value) => - setDirtyTouchedValue("rxresumeUrl", value), + onBaseUrlChange: (value) => { + clearRxResumeFeedback(selectedMode); + setDirtyTouchedValue("rxresumeUrl", value); + }, baseUrlError: errors.rxresumeUrl?.message as string | undefined, }} v5={{ apiKey: rxresumeApiKeyValue, - onApiKeyChange: (value) => - setDirtyTouchedValue("rxresumeApiKey", value), + onApiKeyChange: (value) => { + clearRxResumeFeedback("v5"); + setDirtyTouchedValue("rxresumeApiKey", value); + }, error: errors.rxresumeApiKey?.message as string | undefined, }} v4={{ email: rxresumeEmailValue, - onEmailChange: (value) => - setDirtyTouchedValue("rxresumeEmail", value), + onEmailChange: (value) => { + clearRxResumeFeedback("v4"); + setDirtyTouchedValue("rxresumeEmail", value); + }, emailError: errors.rxresumeEmail?.message as string | undefined, password: rxresumePasswordValue, - onPasswordChange: (value) => - setDirtyTouchedValue("rxresumePassword", value), + onPasswordChange: (value) => { + clearRxResumeFeedback("v4"); + setDirtyTouchedValue("rxresumePassword", value); + }, passwordError: errors.rxresumePassword?.message as | string | undefined, diff --git a/orchestrator/src/server/api/routes/backup.test.ts b/orchestrator/src/server/api/routes/backup.test.ts index cd963fb..029cb35 100644 --- a/orchestrator/src/server/api/routes/backup.test.ts +++ b/orchestrator/src/server/api/routes/backup.test.ts @@ -61,6 +61,7 @@ describe.sequential("Backup API routes", () => { it("should return error if database does not exist", async () => { // Delete the database + closeDb(); await fs.promises.unlink(`${tempDir}/jobs.db`); const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" }); diff --git a/orchestrator/src/server/api/routes/backup.ts b/orchestrator/src/server/api/routes/backup.ts index dc7b114..3fca3cb 100644 --- a/orchestrator/src/server/api/routes/backup.ts +++ b/orchestrator/src/server/api/routes/backup.ts @@ -1,5 +1,5 @@ -import { notFound } from "@infra/errors"; -import { fail } from "@infra/http"; +import { badRequest, notFound } from "@infra/errors"; +import { asyncRoute, fail, ok } from "@infra/http"; import { logger } from "@infra/logger"; import { isDemoMode, sendDemoBlocked } from "@server/config/demo"; import { @@ -15,102 +15,101 @@ export const backupRouter = Router(); /** * GET /api/backups - List all backups with metadata */ -backupRouter.get("/", async (_req: Request, res: Response) => { - try { - const backups = await listBackups(); - const nextScheduled = getNextBackupTime(); - - res.json({ - success: true, - data: { - backups, - nextScheduled, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to list backups", error); - res.status(500).json({ success: false, error: message }); - } -}); +backupRouter.get( + "/", + asyncRoute(async (_req: Request, res: Response) => { + try { + const backups = await listBackups(); + const nextScheduled = getNextBackupTime(); + ok(res, { backups, nextScheduled }); + } catch (error) { + logger.error("Failed to list backups", { + route: "GET /api/backups", + error, + }); + throw error; + } + }), +); /** * POST /api/backups - Create a manual backup */ -backupRouter.post("/", async (_req: Request, res: Response) => { - try { - if (isDemoMode()) { - return sendDemoBlocked( - res, - "Manual backup creation is disabled in the public demo.", - { route: "POST /api/backups" }, - ); +backupRouter.post( + "/", + asyncRoute(async (_req: Request, res: Response) => { + try { + if (isDemoMode()) { + return sendDemoBlocked( + res, + "Manual backup creation is disabled in the public demo.", + { route: "POST /api/backups" }, + ); + } + + const filename = await createBackup("manual"); + const backups = await listBackups(); + const backup = backups.find((b) => b.filename === filename); + + if (!backup) { + throw new Error("Backup was created but not found in list"); + } + + ok(res, backup); + } catch (error) { + logger.error("Failed to create backup", { + route: "POST /api/backups", + error, + }); + throw error; } - - const filename = await createBackup("manual"); - const backups = await listBackups(); - const backup = backups.find((b) => b.filename === filename); - - if (!backup) { - throw new Error("Backup was created but not found in list"); - } - - res.json({ - success: true, - data: backup, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to create backup", error); - res.status(500).json({ success: false, error: message }); - } -}); + }), +); /** * DELETE /api/backups/:filename - Delete a specific backup */ -backupRouter.delete("/:filename", async (req: Request, res: Response) => { - try { - if (isDemoMode()) { - return sendDemoBlocked( - res, - "Deleting backups is disabled in the public demo.", - { - route: "DELETE /api/backups/:filename", - filename: req.params.filename, - }, - ); - } +backupRouter.delete( + "/:filename", + asyncRoute(async (req: Request, res: Response) => { + try { + if (isDemoMode()) { + return sendDemoBlocked( + res, + "Deleting backups is disabled in the public demo.", + { + route: "DELETE /api/backups/:filename", + filename: req.params.filename, + }, + ); + } - const { filename } = req.params; + const { filename } = req.params; - if (!filename) { - res.status(400).json({ - success: false, - error: "Filename is required", + if (!filename) { + fail(res, badRequest("Filename is required")); + return; + } + + await deleteBackup(filename); + ok(res, { message: `Backup ${filename} deleted successfully` }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + logger.error("Failed to delete backup", { + route: "DELETE /api/backups/:filename", + filename: req.params.filename, + error, }); - return; + + if (message.includes("not found")) { + fail(res, notFound(message)); + return; + } + if (message.includes("Invalid")) { + fail(res, badRequest(message)); + return; + } + throw error; } - - await deleteBackup(filename); - - res.json({ - success: true, - message: `Backup ${filename} deleted successfully`, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to delete backup", { - filename: req.params.filename, - error, - }); - - if (message.includes("not found")) { - return fail(res, notFound(message)); - } else if (message.includes("Invalid")) { - res.status(400).json({ success: false, error: message }); - } else { - res.status(500).json({ success: false, error: message }); - } - } -}); + }), +); diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts index 6dd09b0..eae811e 100644 --- a/orchestrator/src/server/api/routes/onboarding.test.ts +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -341,6 +341,7 @@ describe.sequential("Onboarding API routes", () => { expect(body.ok).toBe(true); expect(body.data.valid).toBe(false); expect(body.data.message).toContain("not configured"); + expect(body.data.status).toBe(400); }); it("returns invalid when only email is provided", async () => { @@ -389,6 +390,68 @@ describe.sequential("Onboarding API routes", () => { expect(body.ok).toBe(true); // Should be invalid because credentials are fake expect(body.data.valid).toBe(false); + expect(body.data.status).toBe(401); + expect(body.data.message).toContain("email/password"); + }); + + it("returns a v5 API-key specific warning for invalid v5 credentials", async () => { + global.fetch = vi.fn((input, init) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/openapi/resumes")) { + return Promise.resolve({ + ok: false, + status: 401, + headers: { get: () => "application/json" }, + json: async () => ({ message: "Unauthorized" }), + } as unknown as Response); + } + return originalFetch(input, init); + }); + + const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "v5", + apiKey: "rr-v5-invalid-key", + baseUrl: "http://localhost:3000", + }), + }); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.ok).toBe(true); + expect(body.data.valid).toBe(false); + expect(body.data.status).toBe(401); + expect(body.data.message).toContain("API key"); + }); + + it("returns an availability warning when the Reactive Resume instance is unreachable", async () => { + global.fetch = vi.fn((input, init) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/openapi/resumes")) { + return Promise.reject(new TypeError("fetch failed")); + } + return originalFetch(input, init); + }); + + const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "v5", + apiKey: "rr-v5-test-key", + baseUrl: "http://localhost:3000", + }), + }); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.ok).toBe(true); + expect(body.data.valid).toBe(false); + expect(body.data.status).toBe(0); + expect(body.data.message).toContain("http://localhost:3000"); + expect(body.data.message).toContain("unavailable"); }); it("validates v5 API key mode against Reactive Resume OpenAPI", async () => { @@ -420,6 +483,7 @@ describe.sequential("Onboarding API routes", () => { expect(body.ok).toBe(true); expect(body.data.valid).toBe(true); expect(body.data.message).toBeNull(); + expect(body.data.status).toBeNull(); }); it("handles whitespace-only credentials", async () => { @@ -433,6 +497,7 @@ describe.sequential("Onboarding API routes", () => { expect(res.ok).toBe(true); expect(body.data.valid).toBe(false); expect(body.data.message).toContain("not configured"); + expect(body.data.status).toBe(400); }); }); diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index 0df183a..2eda62b 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -17,6 +17,7 @@ export const onboardingRouter = Router(); type ValidationResponse = { valid: boolean; message: string | null; + status?: number | null; }; function getDefaultValidationBaseUrl( @@ -140,10 +141,57 @@ async function validateRxresume(options?: { baseUrl?: string | null; }): Promise { const rawMode = options?.mode?.trim(); - const mode = rawMode === "v4" || rawMode === "v5" ? rawMode : undefined; + const explicitMode = rawMode === "v4" || rawMode === "v5" ? rawMode : null; + const requestEmail = options?.email?.trim() ?? ""; + const requestPassword = options?.password?.trim() ?? ""; + const requestApiKey = options?.apiKey?.trim() ?? ""; + const hasExplicitV4Input = + options?.email !== undefined || options?.password !== undefined; + const hasExplicitV5Input = options?.apiKey !== undefined; + const storedModeRaw = (await getSetting("rxresumeMode"))?.trim(); + const storedMode = + storedModeRaw === "v4" || storedModeRaw === "v5" + ? storedModeRaw + : undefined; + const inferredMode = + explicitMode ?? + (hasExplicitV5Input + ? "v5" + : hasExplicitV4Input + ? "v4" + : storedMode === "v4" + ? "v4" + : "v5"); + const storedBaseUrl = await getSetting("rxresumeUrl"); + const resolvedBaseUrl = + options?.baseUrl !== undefined && options?.baseUrl !== null + ? options.baseUrl.trim() || + process.env.RXRESUME_URL?.trim() || + (inferredMode === "v4" ? "https://v4.rxresu.me" : "https://rxresu.me") + : storedBaseUrl?.trim() || + process.env.RXRESUME_URL?.trim() || + (inferredMode === "v4" ? "https://v4.rxresu.me" : "https://rxresu.me"); + + if (inferredMode === "v4" && hasExplicitV4Input) { + if (!requestEmail || !requestPassword) { + return { + valid: false, + status: 400, + message: "Reactive Resume v4 credentials are not configured.", + }; + } + } + + if (inferredMode === "v5" && hasExplicitV5Input && !requestApiKey) { + return { + valid: false, + status: 400, + message: "Reactive Resume v5 API key is not configured.", + }; + } const result = await validateRxResumeCredentials({ - mode, + mode: inferredMode, v4: { email: options?.email ?? undefined, password: options?.password ?? undefined, @@ -155,21 +203,52 @@ async function validateRxresume(options?: { }, }); - if (result.ok) return { valid: true, message: null }; + if (result.ok) return { valid: true, message: null, status: null }; const normalizedMessage = result.message.toLowerCase(); + if (result.status === 400 && normalizedMessage.includes("not configured")) { + return { + valid: false, + status: 400, + message: result.message, + }; + } + if ( result.status === 401 || normalizedMessage.includes("invalidcredentials") ) { return { valid: false, + status: result.status, message: - "Invalid RxResume credentials. Check your configured Reactive Resume mode credentials and try again.", + inferredMode === "v4" + ? "Reactive Resume v4 email/password is invalid. Update the email/password and try again." + : "Reactive Resume v5 API key is invalid. Update the API key and try again.", }; } - return { valid: false, message: result.message }; + if (result.status === 0 || result.status >= 500) { + return { + valid: false, + status: result.status, + message: `JobOps could not verify Reactive Resume because the instance at ${resolvedBaseUrl} is unavailable right now.`, + }; + } + + if (result.status >= 400 && result.status < 500) { + return { + valid: false, + status: result.status, + message: `Reactive Resume returned HTTP ${result.status} from ${resolvedBaseUrl}. Check the configured URL and selected mode.`, + }; + } + + return { + valid: false, + message: result.message, + status: result.status, + }; } onboardingRouter.post( diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 93e697c..1c84ccf 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -4,6 +4,7 @@ import { startServer, stopServer } from "./test-utils"; // Mock the RxResume adapter service vi.mock("@server/services/rxresume", () => ({ + clearRxResumeResumeCache: vi.fn(), getResume: vi.fn(), RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error { constructor() { diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index 10f478d..e399981 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -4,7 +4,11 @@ import { isDemoMode } from "@server/config/demo"; import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults"; import { clearProfileCache, getProfile } from "@server/services/profile"; import { extractProjectsFromProfile } from "@server/services/resumeProjects"; -import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume"; +import { + clearRxResumeResumeCache, + getResume, + RxResumeAuthConfigError, +} from "@server/services/rxresume"; import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId"; import { type Request, type Response, Router } from "express"; @@ -87,6 +91,7 @@ profileRouter.get("/status", async (_req: Request, res: Response) => { profileRouter.post("/refresh", async (_req: Request, res: Response) => { try { clearProfileCache(); + clearRxResumeResumeCache(); const profile = await getProfile(true); ok(res, profile); } catch (error) { diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 8faa03a..ca61949 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -2,8 +2,13 @@ import type { Server } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@server/services/rxresume", () => ({ + clearRxResumeResumeCache: vi.fn(), listResumes: vi.fn(), getResume: vi.fn(), + validateCredentials: vi.fn(async () => ({ + ok: true, + mode: "v5", + })), validateResumeSchema: vi.fn(async (data: unknown) => ({ ok: true, mode: @@ -55,6 +60,7 @@ vi.mock("@server/services/rxresume", () => ({ import { extractProjectsFromResume, getResume, + validateCredentials, } from "@server/services/rxresume"; import { startServer, stopServer } from "./test-utils"; @@ -65,6 +71,11 @@ describe.sequential("Settings API routes", () => { let tempDir: string; beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(validateCredentials).mockResolvedValue({ + ok: true, + mode: "v5", + }); ({ server, baseUrl, closeDb, tempDir } = await startServer({ env: { LLM_API_KEY: "secret-key", @@ -139,6 +150,110 @@ describe.sequential("Settings API routes", () => { expect(patchBody.data.llmApiKeyHint).toBe("upda"); }); + it("blocks saving when the configured Reactive Resume v5 API key is invalid", async () => { + vi.mocked(validateCredentials).mockResolvedValue({ + ok: false, + mode: "v5", + status: 401, + message: + "Reactive Resume v5 API key is invalid. Update the API key and try again.", + }); + + const res = await fetch(`${baseUrl}/api/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rxresumeMode: "v5", + rxresumeApiKey: "invalid-key", + }), + }); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("UNAUTHORIZED"); + expect(body.error.message).toContain("API key"); + + const settingsRes = await fetch(`${baseUrl}/api/settings`); + const settingsBody = await settingsRes.json(); + expect(settingsBody.data.rxresumeApiKeyHint).toBeNull(); + }); + + it("blocks saving when Reactive Resume returns another 4xx validation failure", async () => { + vi.mocked(validateCredentials).mockResolvedValue({ + ok: false, + mode: "v5", + status: 404, + message: + "Reactive Resume returned HTTP 404 from https://resume.example.com. Check the configured URL and selected mode.", + }); + + const res = await fetch(`${baseUrl}/api/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rxresumeUrl: "https://resume.example.com", + }), + }); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("NOT_FOUND"); + + const settingsRes = await fetch(`${baseUrl}/api/settings`); + const settingsBody = await settingsRes.json(); + expect(settingsBody.data.rxresumeUrl).toBe( + "https://env.rxresume.example.com", + ); + }); + + it("allows saving when Reactive Resume is temporarily unavailable", async () => { + vi.mocked(validateCredentials).mockResolvedValue({ + ok: false, + mode: "v5", + status: 0, + message: + "JobOps could not verify Reactive Resume because the instance is unavailable.", + }); + + const res = await fetch(`${baseUrl}/api/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rxresumeMode: "v5", + rxresumeApiKey: "rr-v5-warning-key", + }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.rxresumeApiKeyHint).toBe("rr-v"); + }); + + it("does not run Reactive Resume validation for unrelated settings saves", async () => { + vi.mocked(validateCredentials).mockResolvedValue({ + ok: false, + mode: "v5", + status: 401, + message: "should not run", + }); + + const res = await fetch(`${baseUrl}/api/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o-mini", + }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(validateCredentials).not.toHaveBeenCalled(); + }); + it("validates basic auth requirements", async () => { const res = await fetch(`${baseUrl}/api/settings`, { method: "PATCH", diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 2b14964..245bc6a 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -3,27 +3,110 @@ import { badRequest, serviceUnavailable, statusToCode, + unauthorized, upstreamError, } from "@infra/errors"; import { asyncRoute, fail, ok } from "@infra/http"; import { logger } from "@infra/logger"; +import { getRequestId } from "@infra/request-context"; import { isDemoMode, sendDemoBlocked } from "@server/config/demo"; import { setBackupSettings } from "@server/services/backup/index"; +import { clearProfileCache } from "@server/services/profile"; import { + clearRxResumeResumeCache, extractProjectsFromResume, getResume, listResumes, RxResumeAuthConfigError, RxResumeRequestError, validateResumeSchema, + validateCredentials as validateRxResumeCredentials, } from "@server/services/rxresume"; import { getEffectiveSettings } from "@server/services/settings"; import { applySettingsUpdates } from "@server/services/settings-update"; -import { updateSettingsSchema } from "@shared/settings-schema"; +import { + type UpdateSettingsInput, + updateSettingsSchema, +} from "@shared/settings-schema"; import { type Request, type Response, Router } from "express"; export const settingsRouter = Router(); +const RXRESUME_SAVE_VALIDATION_KEYS: Array = [ + "rxresumeMode", + "rxresumeUrl", + "rxresumeApiKey", + "rxresumeEmail", + "rxresumePassword", +]; + +function hasInputKey( + input: UpdateSettingsInput, + key: K, +): boolean { + return Object.hasOwn(input, key); +} + +function shouldValidateRxResumeOnSave(input: UpdateSettingsInput): boolean { + return RXRESUME_SAVE_VALIDATION_KEYS.some((key) => hasInputKey(input, key)); +} + +function isMissingRxResumeConfigValidationResult(input: { + status: number; + message: string; +}): boolean { + return input.status === 400 && /not configured/i.test(input.message); +} + +function buildRxResumeValidationOptions( + input: UpdateSettingsInput, +): Parameters[0] { + return { + mode: + input.rxresumeMode === "v4" || input.rxresumeMode === "v5" + ? input.rxresumeMode + : undefined, + v4: { + ...(hasInputKey(input, "rxresumeEmail") + ? { email: input.rxresumeEmail } + : {}), + ...(hasInputKey(input, "rxresumePassword") + ? { password: input.rxresumePassword } + : {}), + ...(hasInputKey(input, "rxresumeUrl") + ? { baseUrl: input.rxresumeUrl } + : {}), + }, + v5: { + ...(hasInputKey(input, "rxresumeApiKey") + ? { apiKey: input.rxresumeApiKey } + : {}), + ...(hasInputKey(input, "rxresumeUrl") + ? { baseUrl: input.rxresumeUrl } + : {}), + }, + }; +} + +function toRxResumeValidationAppError( + status: number, + message: string, +): AppError { + if (status === 401) { + return unauthorized(message); + } + + if (status === 400) { + return badRequest(message); + } + + return new AppError({ + status, + code: statusToCode(status), + message, + }); +} + /** * GET /api/settings - Get app settings (effective + defaults) */ @@ -50,7 +133,49 @@ settingsRouter.patch( } const input = updateSettingsSchema.parse(req.body); + if (shouldValidateRxResumeOnSave(input)) { + const validation = await validateRxResumeCredentials( + buildRxResumeValidationOptions(input), + ); + if (!validation.ok) { + const status = validation.status ?? 0; + if ( + isMissingRxResumeConfigValidationResult({ + status, + message: validation.message, + }) + ) { + logger.info( + "Skipping save-time Reactive Resume validation because credentials are incomplete", + { + requestId: getRequestId() ?? null, + route: "PATCH /api/settings", + rxresumeMode: validation.mode ?? input.rxresumeMode ?? null, + status, + }, + ); + } else if (status >= 400 && status < 500) { + fail(res, toRxResumeValidationAppError(status, validation.message)); + return; + } else if (status === 0 || status >= 500) { + logger.warn( + "Reactive Resume save-time validation could not verify upstream availability", + { + requestId: getRequestId() ?? null, + route: "PATCH /api/settings", + rxresumeMode: validation.mode ?? input.rxresumeMode ?? null, + status, + }, + ); + } + } + } + const plan = await applySettingsUpdates(input); + if (plan.shouldClearRxResumeCaches) { + clearRxResumeResumeCache(); + clearProfileCache(); + } const data = await getEffectiveSettings(); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 245c46e..95fd601 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -61,6 +61,7 @@ vi.mock("@server/services/scorer", () => ({ vi.mock("@server/services/profile", () => ({ getProfile: vi.fn().mockResolvedValue({}), + clearProfileCache: vi.fn(), })); vi.mock("@server/services/visa-sponsors/index", () => ({ @@ -81,6 +82,23 @@ vi.mock("@server/services/visa-sponsors/index", () => ({ })); const originalEnv = { ...process.env }; +const isolatedEnvKeys = [ + "RXRESUME_API_KEY", + "RXRESUME_EMAIL", + "RXRESUME_PASSWORD", + "RXRESUME_URL", + "RXRESUME_MODE", + "LLM_API_KEY", + "LLM_PROVIDER", + "LLM_BASE_URL", + "BASIC_AUTH_USER", + "BASIC_AUTH_PASSWORD", + "WEBHOOK_SECRET", + "UKVISAJOBS_EMAIL", + "UKVISAJOBS_PASSWORD", + "ADZUNA_APP_ID", + "ADZUNA_APP_KEY", +] as const; export async function startServer(options?: { env?: Record; @@ -93,8 +111,12 @@ export async function startServer(options?: { vi.resetModules(); const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-")); const envOverrides = options?.env ?? {}; + const nextEnv = { ...originalEnv }; + for (const key of isolatedEnvKeys) { + delete nextEnv[key]; + } process.env = { - ...originalEnv, + ...nextEnv, DATA_DIR: tempDir, NODE_ENV: "test", MODEL: "test-model", diff --git a/orchestrator/src/server/db/index.ts b/orchestrator/src/server/db/index.ts index b58c333..12aa45d 100644 --- a/orchestrator/src/server/db/index.ts +++ b/orchestrator/src/server/db/index.ts @@ -20,11 +20,14 @@ if (!existsSync(dataDir)) { const sqlite = new Database(DB_PATH); sqlite.pragma("journal_mode = WAL"); +let isClosed = false; export const db = drizzle(sqlite, { schema }); export { schema }; export function closeDb() { + if (isClosed) return; sqlite.close(); + isClosed = true; } diff --git a/orchestrator/src/server/services/backup/index.ts b/orchestrator/src/server/services/backup/index.ts index fd59bce..ec82012 100644 --- a/orchestrator/src/server/services/backup/index.ts +++ b/orchestrator/src/server/services/backup/index.ts @@ -8,6 +8,7 @@ import fs from "node:fs"; import type { FileHandle } from "node:fs/promises"; import path from "node:path"; +import { logger } from "@infra/logger"; import { getDataDir } from "@server/config/dataDir"; import { createScheduler } from "@server/utils/scheduler"; import type { BackupInfo } from "@shared/types"; @@ -32,59 +33,43 @@ interface BackupSettings { maxCount: number; } -// Current settings (updated by setBackupSettings) let currentSettings: BackupSettings = { enabled: false, hour: 2, maxCount: 5, }; -// Create scheduler for automatic backups const scheduler = createScheduler("backup", async () => { await createBackup("auto"); await cleanupOldBackups(); }); -/** - * Get the path to the database file - */ function getDbPath(): string { return path.join(getDataDir(), DB_FILENAME); } -/** - * Get the data directory path - */ function getBackupDir(): string { return getDataDir(); } -/** - * Generate filename for a backup - */ function generateBackupFilename(type: "auto" | "manual"): string { const now = new Date(); if (type === "auto") { - // Format: jobs_YYYY_MM_DD.db (UTC date to match UTC scheduler) const year = now.getUTCFullYear(); const month = String(now.getUTCMonth() + 1).padStart(2, "0"); const day = String(now.getUTCDate()).padStart(2, "0"); return `${AUTO_BACKUP_PREFIX}${year}_${month}_${day}.db`; - } else { - // Format: jobs_manual_YYYY_MM_DD_HH_MM_SS.db (local time for manual backups) - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`; } + + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`; } -/** - * Parse backup filename to extract creation date - */ function parseBackupDate(filename: string): Date | null { const autoMatch = filename.match(AUTO_BACKUP_REGEX); if (autoMatch) { @@ -135,20 +120,12 @@ function buildUtcDate( return date; } -/** - * Determine backup type from filename - */ function getBackupType(filename: string): "auto" | "manual" | null { if (AUTO_BACKUP_PATTERN.test(filename)) return "auto"; if (MANUAL_BACKUP_PATTERN.test(filename)) return "manual"; return null; } -/** - * Create a backup of the database - * @param type - 'auto' for scheduled backups, 'manual' for user-triggered - * @returns The filename of the created backup - */ export async function createBackup(type: "auto" | "manual"): Promise { const dbPath = getDbPath(); const backupDir = getBackupDir(); @@ -157,7 +134,6 @@ export async function createBackup(type: "auto" | "manual"): Promise { let backupPath = path.join(backupDir, filename); let reservedHandle: FileHandle | null = null; - // Check if database exists if (!fs.existsSync(dbPath)) { throw new Error(`Database file not found: ${dbPath}`); } @@ -176,9 +152,10 @@ export async function createBackup(type: "auto" | "manual"): Promise { if (type === "auto") { reservedHandle = await tryReserve(backupPath); if (!reservedHandle) { - console.log( - `ℹ️ [backup] Auto backup already exists for today: ${filename}`, - ); + logger.info("Automatic backup already exists for current day", { + filename, + type, + }); return filename; } } else { @@ -204,7 +181,6 @@ export async function createBackup(type: "auto" | "manual"): Promise { } } - // Close the reserved file handle before running SQLite backup await reservedHandle.close(); let sqlite: SqliteDatabase | null = null; @@ -218,32 +194,28 @@ export async function createBackup(type: "auto" | "manual"): Promise { sqlite?.close(); } - console.log( - `✅ [backup] Created ${type} backup: ${filename} (${(await fs.promises.stat(backupPath)).size} bytes)`, - ); + logger.info("Created database backup", { + filename, + type, + size: (await fs.promises.stat(backupPath)).size, + }); return filename; } -/** - * List all backups with metadata - * @returns Array of backup information - */ export async function listBackups(): Promise { const backupDir = getBackupDir(); - // Check if directory exists if (!fs.existsSync(backupDir)) { return []; } - // Read directory and filter backup files const files = await fs.promises.readdir(backupDir); - const backupFiles = files.filter((file) => { - return AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file); - }); + const backupFiles = files.filter( + (file) => + AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file), + ); - // Get metadata for each backup const backups: BackupInfo[] = []; for (const filename of backupFiles) { const filePath = path.join(backupDir, filename); @@ -261,20 +233,14 @@ export async function listBackups(): Promise { } } - // Sort by creation date (newest first) - backups.sort((a, b) => { - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }); + backups.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); return backups; } -/** - * Delete a specific backup - * @param filename - Name of the backup file to delete - */ export async function deleteBackup(filename: string): Promise { - // Validate filename to prevent path traversal if ( !AUTO_BACKUP_PATTERN.test(filename) && !MANUAL_BACKUP_PATTERN.test(filename) @@ -285,33 +251,22 @@ export async function deleteBackup(filename: string): Promise { const backupDir = getBackupDir(); const filePath = path.join(backupDir, filename); - // Check if file exists if (!fs.existsSync(filePath)) { throw new Error(`Backup not found: ${filename}`); } - // Delete file await fs.promises.unlink(filePath); - console.log(`🗑️ [backup] Deleted backup: ${filename}`); + logger.info("Deleted database backup", { filename }); } -/** - * Clean up old automatic backups - * Keeps only the most recent N automatic backups (where N = maxCount) - * Manual backups are never deleted automatically - */ export async function cleanupOldBackups(): Promise { const backups = await listBackups(); - - // Filter to only automatic backups const autoBackups = backups.filter((b) => b.type === "auto"); - // Sort by creation date (oldest first for deletion) - autoBackups.sort((a, b) => { - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }); + autoBackups.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); - // Delete oldest backups if we exceed max count const maxCount = currentSettings.maxCount; if (autoBackups.length > maxCount) { const toDelete = autoBackups.slice(0, autoBackups.length - maxCount); @@ -320,79 +275,55 @@ export async function cleanupOldBackups(): Promise { try { await deleteBackup(backup.filename); } catch (error) { - console.error( - `❌ [backup] Failed to delete old backup ${backup.filename}:`, + logger.error("Failed to delete old automatic backup", { + filename: backup.filename, error, - ); + }); } } - console.log( - `🧹 [backup] Cleaned up ${toDelete.length} old automatic backups (max: ${maxCount})`, - ); + logger.info("Cleaned up old automatic backups", { + deletedCount: toDelete.length, + maxCount, + }); } } -/** - * Update backup settings and restart scheduler if needed - * @param settings - New backup settings - */ export function setBackupSettings(settings: Partial): void { const oldEnabled = currentSettings.enabled; const oldHour = currentSettings.hour; - // Update settings currentSettings = { ...currentSettings, ...settings }; - console.log(`⚙️ [backup] Settings updated:`, currentSettings); + logger.info("Backup settings updated", currentSettings); - // Restart scheduler if settings changed if (currentSettings.enabled) { if (!oldEnabled || oldHour !== currentSettings.hour) { - // Start or restart with new hour scheduler.start(currentSettings.hour); } } else if (oldEnabled && !currentSettings.enabled) { - // Stop scheduler scheduler.stop(); } } -/** - * Get current backup settings - */ export function getBackupSettings(): BackupSettings { return { ...currentSettings }; } -/** - * Get the next scheduled backup time - * @returns ISO string of next backup time, or null if disabled - */ export function getNextBackupTime(): string | null { return scheduler.getNextRun(); } -/** - * Check if automatic backup scheduler is running - */ export function isBackupSchedulerRunning(): boolean { return scheduler.isRunning(); } -/** - * Start the backup scheduler manually (used on server startup) - * Only starts if backup is enabled - */ export function startBackupScheduler(): void { if (currentSettings.enabled) { scheduler.start(currentSettings.hour); } } -/** - * Stop the backup scheduler - */ export function stopBackupScheduler(): void { scheduler.stop(); } diff --git a/orchestrator/src/server/services/profile.test.ts b/orchestrator/src/server/services/profile.test.ts index d7b5b30..7fd6921 100644 --- a/orchestrator/src/server/services/profile.test.ts +++ b/orchestrator/src/server/services/profile.test.ts @@ -78,6 +78,11 @@ describe("getProfile", () => { await getProfile(true); expect(getResume).toHaveBeenCalledTimes(2); + expect(vi.mocked(getResume).mock.calls[0]).toEqual(["test-resume-id"]); + expect(vi.mocked(getResume).mock.calls[1]).toEqual([ + "test-resume-id", + { forceRefresh: true }, + ]); }); it("should throw user-friendly error on credential issues", async () => { diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts index 24e7048..3bafb60 100644 --- a/orchestrator/src/server/services/profile.ts +++ b/orchestrator/src/server/services/profile.ts @@ -38,7 +38,9 @@ export async function getProfile(forceRefresh = false): Promise { logger.info("Fetching profile from Reactive Resume", { resumeId: rxresumeBaseResumeId, }); - const resume = await getResume(rxresumeBaseResumeId); + const resume = forceRefresh + ? await getResume(rxresumeBaseResumeId, { forceRefresh: true }) + : await getResume(rxresumeBaseResumeId); if (!resume.data || typeof resume.data !== "object") { throw new Error("Resume data is empty or invalid"); diff --git a/orchestrator/src/server/services/rxresume/client.ts b/orchestrator/src/server/services/rxresume/client.ts index e40c8f7..6600a75 100644 --- a/orchestrator/src/server/services/rxresume/client.ts +++ b/orchestrator/src/server/services/rxresume/client.ts @@ -72,7 +72,7 @@ const tokenCache = new Map(); // Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour) const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000; -export class RxResumeClient { +class RxResumeClientImpl { private readonly tokenTtlMs: number; constructor( @@ -456,6 +456,21 @@ export class RxResumeClient { } } +type GlobalRxResumeClientClass = typeof globalThis & { + __jobOpsRxResumeClientClass?: typeof RxResumeClientImpl; +}; + +const globalRxResumeClientClass = globalThis as GlobalRxResumeClientClass; + +const rxResumeClientClass = + globalRxResumeClientClass.__jobOpsRxResumeClientClass ?? RxResumeClientImpl; + +globalRxResumeClientClass.__jobOpsRxResumeClientClass = rxResumeClientClass; + +export const RxResumeClient = rxResumeClientClass; + +export type RxResumeClient = InstanceType; + function sanitizeResponseSnippet(text: string): string { if (!text) return ""; const compact = normalizeWhitespace(text); diff --git a/orchestrator/src/server/services/rxresume/index.test.ts b/orchestrator/src/server/services/rxresume/index.test.ts index 1758a30..ea22557 100644 --- a/orchestrator/src/server/services/rxresume/index.test.ts +++ b/orchestrator/src/server/services/rxresume/index.test.ts @@ -31,6 +31,7 @@ vi.mock("./client", () => ({ import { getSetting } from "@server/repositories/settings"; import { RxResumeClient } from "./client"; import { + clearRxResumeResumeCache, extractProjectsFromResume, getResume as getResumeFromAdapter, listResumes, @@ -53,6 +54,8 @@ function mockSettings(map: SettingMap): void { describe("rxresume adapter", () => { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); + clearRxResumeResumeCache(); delete process.env.RXRESUME_API_KEY; delete process.env.RXRESUME_EMAIL; delete process.env.RXRESUME_PASSWORD; @@ -222,6 +225,109 @@ describe("rxresume adapter", () => { }); }); + it("caches successful resume fetches", async () => { + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + vi.mocked(v5.getResume).mockResolvedValue({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + } as any); + + const first = await getResumeFromAdapter("resume-1"); + const second = await getResumeFromAdapter("resume-1"); + + expect(v5.getResume).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(first).not.toBe(second); + }); + + it("expires cached resumes after the ttl", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + vi.mocked(v5.getResume).mockResolvedValue({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + } as any); + + await getResumeFromAdapter("resume-1"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await getResumeFromAdapter("resume-1"); + + expect(v5.getResume).toHaveBeenCalledTimes(2); + }); + + it("supports forceRefresh for cached resumes", async () => { + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + vi.mocked(v5.getResume).mockResolvedValue({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + } as any); + + await getResumeFromAdapter("resume-1"); + await getResumeFromAdapter("resume-1", { forceRefresh: true }); + + expect(v5.getResume).toHaveBeenCalledTimes(2); + }); + + it("clears the centralized resume cache on demand", async () => { + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + vi.mocked(v5.getResume).mockResolvedValue({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + } as any); + + await getResumeFromAdapter("resume-1"); + clearRxResumeResumeCache(); + await getResumeFromAdapter("resume-1"); + + expect(v5.getResume).toHaveBeenCalledTimes(2); + }); + + it("coalesces in-flight resume fetches", async () => { + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + let resolveResume: ((value: Record) => void) | undefined; + vi.mocked(v5.getResume).mockImplementation( + () => + new Promise((resolve) => { + resolveResume = resolve; + }) as Promise, + ); + + const first = getResumeFromAdapter("resume-1"); + const second = getResumeFromAdapter("resume-1"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(v5.getResume).toHaveBeenCalledTimes(1); + resolveResume?.({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + }); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toEqual(secondResult); + }); + + it("creates separate cache entries for different credential fingerprints", async () => { + vi.mocked(v5.getResume).mockResolvedValue({ + id: "resume-1", + name: "Resume One", + data: { basics: { name: "Test User" } }, + } as any); + + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key-one" }); + await getResumeFromAdapter("resume-1"); + + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key-two" }); + await getResumeFromAdapter("resume-1"); + + expect(v5.getResume).toHaveBeenCalledTimes(2); + }); + it("prepares tailored v5 resume payload without relying on v4 fields", async () => { const v5ResumeData = { basics: { diff --git a/orchestrator/src/server/services/rxresume/index.ts b/orchestrator/src/server/services/rxresume/index.ts index 507299e..c54170b 100644 --- a/orchestrator/src/server/services/rxresume/index.ts +++ b/orchestrator/src/server/services/rxresume/index.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { getSetting } from "@server/repositories/settings"; import { pickProjectIdsForJob } from "@server/services/projectSelection"; import { resolveResumeProjectsSettings } from "@server/services/resumeProjects"; @@ -71,6 +72,7 @@ export class RxResumeRequestError extends Error { type ResolveModeOptions = { mode?: RxResumeMode; + forceRefresh?: boolean; v4?: { email?: string | null; password?: string | null; @@ -81,6 +83,91 @@ type ResolveModeOptions = { type V4Credentials = Awaited>; type V5Credentials = Awaited>; +type ResolvedOperationContext = + | { mode: "v4"; creds: V4Credentials } + | { mode: "v5"; creds: V5Credentials }; + +const RXRESUME_RESUME_CACHE_TTL_MS = 5 * 60 * 1000; + +type RxResumeResumeCacheEntry = { + expiresAt: number; + resume: RxResumeResume; +}; + +const rxResumeResumeCache = new Map(); +const inFlightResumeRequests = new Map>(); +let rxResumeResumeCacheGeneration = 0; + +function hasOverrideKey( + value: T | undefined, + key: PropertyKey, +): boolean { + return value !== undefined && Object.hasOwn(value, key); +} + +function resolveOverrideValue(args: { + overrideValue?: string | null; + hasOverride: boolean; + storedValue?: string | null; + envValue?: string | null; + fallback?: string; +}): string { + if (args.hasOverride) { + const trimmed = args.overrideValue?.trim() ?? ""; + return trimmed || args.envValue?.trim() || args.fallback || ""; + } + + return ( + args.storedValue?.trim() || args.envValue?.trim() || args.fallback || "" + ); +} + +function cloneResume(resume: RxResumeResume): RxResumeResume { + return structuredClone(resume) as RxResumeResume; +} + +function normalizeBaseUrlForCache(baseUrl: string): string { + const trimmed = baseUrl.trim(); + try { + const url = new URL(trimmed); + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +function buildCredentialFingerprint(context: ResolvedOperationContext): string { + const normalizedCredential = + context.mode === "v5" + ? context.creds.apiKey.trim() + : `${context.creds.email.trim().toLowerCase()}:${context.creds.password.trim()}`; + + return createHash("sha256") + .update(normalizedCredential) + .digest("hex") + .slice(0, 12); +} + +function buildResumeCacheKey( + resumeId: string, + context: ResolvedOperationContext, +): string { + return [ + context.mode, + normalizeBaseUrlForCache(context.creds.baseUrl), + resumeId.trim(), + buildCredentialFingerprint(context), + ].join("::"); +} + +export function clearRxResumeResumeCache(): void { + rxResumeResumeCacheGeneration += 1; + rxResumeResumeCache.clear(); + inFlightResumeRequests.clear(); +} function toV4Override( input?: ResolveModeOptions["v4"], @@ -177,21 +264,25 @@ async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) { getSetting("rxresumePassword"), getSetting("rxresumeUrl"), ]); - const email = - overrides?.email?.trim() || - storedEmail?.trim() || - process.env.RXRESUME_EMAIL?.trim() || - ""; - const password = - overrides?.password?.trim() || - storedPassword?.trim() || - process.env.RXRESUME_PASSWORD?.trim() || - ""; - const baseUrl = - overrides?.baseUrl?.trim() || - storedBaseUrl?.trim() || - process.env.RXRESUME_URL?.trim() || - "https://v4.rxresu.me"; + const email = resolveOverrideValue({ + overrideValue: overrides?.email, + hasOverride: hasOverrideKey(overrides, "email"), + storedValue: storedEmail, + envValue: process.env.RXRESUME_EMAIL, + }); + const password = resolveOverrideValue({ + overrideValue: overrides?.password, + hasOverride: hasOverrideKey(overrides, "password"), + storedValue: storedPassword, + envValue: process.env.RXRESUME_PASSWORD, + }); + const baseUrl = resolveOverrideValue({ + overrideValue: overrides?.baseUrl, + hasOverride: hasOverrideKey(overrides, "baseUrl"), + storedValue: storedBaseUrl, + envValue: process.env.RXRESUME_URL, + fallback: "https://v4.rxresu.me", + }); return { email, password, baseUrl, available: Boolean(email && password) }; } @@ -200,60 +291,25 @@ async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) { getSetting("rxresumeApiKey"), getSetting("rxresumeUrl"), ]); - const apiKey = - overrides?.apiKey?.trim() || - storedApiKey?.trim() || - process.env.RXRESUME_API_KEY?.trim() || - ""; - const baseUrl = - overrides?.baseUrl?.trim() || - storedBaseUrl?.trim() || - process.env.RXRESUME_URL?.trim() || - "https://rxresu.me"; + const apiKey = resolveOverrideValue({ + overrideValue: overrides?.apiKey, + hasOverride: hasOverrideKey(overrides, "apiKey"), + storedValue: storedApiKey, + envValue: process.env.RXRESUME_API_KEY, + }); + const baseUrl = resolveOverrideValue({ + overrideValue: overrides?.baseUrl, + hasOverride: hasOverrideKey(overrides, "baseUrl"), + storedValue: storedBaseUrl, + envValue: process.env.RXRESUME_URL, + fallback: "https://rxresu.me", + }); return { apiKey, baseUrl, available: Boolean(apiKey) }; } -export async function resolveRxResumeMode( +async function resolveOperationContext( options: ResolveModeOptions = {}, -): Promise { - const mode = options.mode ?? (await readConfiguredMode()); - const [v5Creds, v4Creds] = await Promise.all([ - readV5Credentials(options.v5), - readV4Credentials(options.v4), - ]); - - if (mode === "v5") { - if (!v5Creds.available) { - throw new RxResumeAuthConfigError( - "v5", - "Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.", - ); - } - return "v5"; - } - - if (mode === "v4") { - if (!v4Creds.available) { - throw new RxResumeAuthConfigError( - "v4", - "Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.", - ); - } - return "v4"; - } - throw new RxResumeAuthConfigError( - mode, - "Reactive Resume mode must be set to v4 or v5.", - ); -} - -async function runRxResumeOperation( - options: ResolveModeOptions, - handlers: { - v4: (creds: V4Credentials) => Promise; - v5: (creds: V5Credentials) => Promise; - }, -): Promise { +): Promise { const requestedMode = options.mode ?? (await readConfiguredMode()); const [v5Creds, v4Creds] = await Promise.all([ readV5Credentials(options.v5), @@ -267,11 +323,7 @@ async function runRxResumeOperation( "Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.", ); } - try { - return await handlers.v5(v5Creds); - } catch (error) { - throw normalizeError(error); - } + return { mode: "v5", creds: v5Creds }; } if (!v4Creds.available) { @@ -280,8 +332,65 @@ async function runRxResumeOperation( "Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.", ); } + + return { mode: "v4", creds: v4Creds }; +} + +export async function resolveRxResumeMode( + options: ResolveModeOptions = {}, +): Promise { + const context = await resolveOperationContext(options); + return context.mode; +} + +async function runRxResumeOperation( + options: ResolveModeOptions, + handlers: { + v4: (creds: V4Credentials) => Promise; + v5: (creds: V5Credentials) => Promise; + }, +): Promise { + const context = await resolveOperationContext(options); try { - return await handlers.v4(v4Creds); + if (context.mode === "v5") { + return await handlers.v5(context.creds); + } + return await handlers.v4(context.creds); + } catch (error) { + throw normalizeError(error); + } +} + +async function fetchResumeFromUpstream( + resumeId: string, + context: ResolvedOperationContext, +): Promise { + try { + if (context.mode === "v5") { + const resume = normalizeV5ResumeResponse( + await v5.getResume(resumeId, { + apiKey: context.creds.apiKey, + baseUrl: context.creds.baseUrl, + }), + ) as RxResumeResume; + return { + ...resume, + mode: "v5", + title: + typeof resume.name === "string" && resume.name.trim() + ? resume.name + : (resume.slug ?? resume.id), + data: resume.data, + } as RxResumeResume; + } + + return { + ...((await v4.getResume( + resumeId, + toV4Override(context.creds), + )) as RxResumeResume), + mode: "v4", + }; } catch (error) { throw normalizeError(error); } @@ -304,32 +413,46 @@ export async function getResume( resumeId: string, options: ResolveModeOptions = {}, ): Promise { - return runRxResumeOperation(options, { - v5: async (creds) => { - const resume = normalizeV5ResumeResponse( - await v5.getResume(resumeId, { - apiKey: creds.apiKey, - baseUrl: creds.baseUrl, - }), - ) as RxResumeResume; - return { - ...resume, - mode: "v5", - title: - typeof resume.name === "string" && resume.name.trim() - ? resume.name - : (resume.slug ?? resume.id), - data: resume.data, - } as RxResumeResume; - }, - v4: async (creds) => ({ - ...((await v4.getResume( - resumeId, - toV4Override(creds), - )) as RxResumeResume), - mode: "v4", - }), - }); + const context = await resolveOperationContext(options); + const cacheKey = buildResumeCacheKey(resumeId, context); + const now = Date.now(); + + if (!options.forceRefresh) { + const cached = rxResumeResumeCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return cloneResume(cached.resume); + } + if (cached) { + rxResumeResumeCache.delete(cacheKey); + } + + const inFlight = inFlightResumeRequests.get(cacheKey); + if (inFlight) { + return cloneResume(await inFlight); + } + } + + const generation = rxResumeResumeCacheGeneration; + let request: Promise; + request = fetchResumeFromUpstream(resumeId, context) + .then((resume) => { + const cachedResume = cloneResume(resume); + if (generation === rxResumeResumeCacheGeneration) { + rxResumeResumeCache.set(cacheKey, { + expiresAt: Date.now() + RXRESUME_RESUME_CACHE_TTL_MS, + resume: cachedResume, + }); + } + return cloneResume(cachedResume); + }) + .finally(() => { + if (inFlightResumeRequests.get(cacheKey) === request) { + inFlightResumeRequests.delete(cacheKey); + } + }); + + inFlightResumeRequests.set(cacheKey, request); + return request; } export async function validateResumeSchema( diff --git a/orchestrator/src/server/services/settings-update/apply-updates.test.ts b/orchestrator/src/server/services/settings-update/apply-updates.test.ts index 14fc129..263b710 100644 --- a/orchestrator/src/server/services/settings-update/apply-updates.test.ts +++ b/orchestrator/src/server/services/settings-update/apply-updates.test.ts @@ -67,6 +67,7 @@ describe("applySettingsUpdates", () => { "app-key", ); expect(plan.shouldRefreshBackupScheduler).toBe(false); + expect(plan.shouldClearRxResumeCaches).toBe(false); }); it("marks backup scheduler refresh when backup settings are changed", async () => { @@ -84,6 +85,7 @@ describe("applySettingsUpdates", () => { ]), ); expect(plan.shouldRefreshBackupScheduler).toBe(true); + expect(plan.shouldClearRxResumeCaches).toBe(false); }); it("resolves and persists normalized resumeProjects", async () => { @@ -131,4 +133,25 @@ describe("applySettingsUpdates", () => { JSON.stringify(normalized), ); }); + + it("marks Reactive Resume cache clearing when RxResume settings change", async () => { + const settingsRepo = await import("@server/repositories/settings"); + + const plan = await applySettingsUpdates({ + rxresumeMode: "v4", + rxresumeUrl: "https://resume.example.com", + rxresumeBaseResumeId: "resume-123", + }); + + expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual( + expect.arrayContaining([ + ["rxresumeMode", "v4"], + ["rxresumeUrl", "https://resume.example.com"], + ["rxresumeBaseResumeId", "resume-123"], + ["rxresumeBaseResumeIdV4", "resume-123"], + ]), + ); + expect(plan.shouldClearRxResumeCaches).toBe(true); + expect(plan.shouldRefreshBackupScheduler).toBe(false); + }); }); diff --git a/orchestrator/src/server/services/settings-update/apply-updates.ts b/orchestrator/src/server/services/settings-update/apply-updates.ts index 07fbb53..6626eed 100644 --- a/orchestrator/src/server/services/settings-update/apply-updates.ts +++ b/orchestrator/src/server/services/settings-update/apply-updates.ts @@ -42,5 +42,6 @@ export async function applySettingsUpdates( shouldRefreshBackupScheduler: deferredSideEffects.has( "refreshBackupScheduler", ), + shouldClearRxResumeCaches: deferredSideEffects.has("clearRxResumeCaches"), }; } diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts index c08414f..e42680a 100644 --- a/orchestrator/src/server/services/settings-update/registry.ts +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -13,7 +13,9 @@ import { import { settingsRegistry } from "@shared/settings-registry"; import type { UpdateSettingsInput } from "@shared/settings-schema"; -export type DeferredSideEffect = "refreshBackupScheduler"; +export type DeferredSideEffect = + | "refreshBackupScheduler" + | "clearRxResumeCaches"; export type SettingsUpdateAction = { settingKey: SettingKey; @@ -38,6 +40,7 @@ export type SettingUpdateHandler = (args: { export type SettingsUpdatePlan = { shouldRefreshBackupScheduler: boolean; + shouldClearRxResumeCaches: boolean; }; function result( @@ -69,6 +72,15 @@ export const settingsUpdateRegistry: Partial<{ [K in keyof UpdateSettingsInput]: SettingUpdateHandler; }> = {}; +const RXRESUME_CACHE_INVALIDATION_KEYS = new Set([ + "rxresumeMode", + "rxresumeUrl", + "rxresumeApiKey", + "rxresumeEmail", + "rxresumePassword", + "rxresumeBaseResumeId", +]); + for (const [key, def] of Object.entries(settingsRegistry)) { if (def.kind === "virtual") continue; @@ -120,6 +132,7 @@ for (const [key, def] of Object.entries(settingsRegistry)) { persistAction("rxresumeBaseResumeId", serialized), persistAction(modeSpecificKey, serialized), ], + deferred: ["clearRxResumeCaches"], }); }; continue; @@ -142,10 +155,19 @@ for (const [key, def] of Object.entries(settingsRegistry)) { applyEnvValue((def as any).envKey, serialized); } : undefined; + const deferred: DeferredSideEffect[] = []; + if (isBackup) { + deferred.push("refreshBackupScheduler"); + } + if ( + RXRESUME_CACHE_INVALIDATION_KEYS.has(key as keyof UpdateSettingsInput) + ) { + deferred.push("clearRxResumeCaches"); + } return result({ actions: [persistAction(targetKey, serialized, sideEffect)], - deferred: isBackup ? ["refreshBackupScheduler"] : [], + deferred, }); }; } diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 1cb4629..438762b 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -127,6 +127,7 @@ export interface ProfileStatusResponse { export interface ValidationResult { valid: boolean; message: string | null; + status?: number | null; } export interface DemoInfoResponse {