diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 41a2f220e5..670c315589 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5886,6 +5886,17 @@ successMessage = "Your license has been successfully activated. You can now clos deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmTitle = "Delete {{label}} policy?" +[policies.activity] +enforced = "enforced" +enforcing = "Enforcing..." +failed = "Enforcement failed" +outputsUnavailable = "Policy outputs are no longer available to download." +partialOutputsUnavailable = "Some policy outputs are no longer available to download." +retrying = "Busy, retrying..." +runNotFound = "The enforcement run could no longer be found." +step = "step {{current}}/{{total}}" +timedOut = "Enforcement timed out before the run could finish." + [policies.catalog] compliance = "Compliance" ingestion = "Ingestion" diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs index 28d2ddd376..b906bbbf7d 100644 --- a/frontend/editor/src-tauri/src/commands/auth.rs +++ b/frontend/editor/src-tauri/src/commands/auth.rs @@ -400,6 +400,7 @@ struct SupabaseUser { #[derive(Debug, Deserialize)] struct SupabaseLoginResponse { access_token: String, + refresh_token: Option, user: SupabaseUser, } @@ -408,6 +409,7 @@ pub struct LoginResponse { pub token: String, pub username: String, pub email: Option, + pub refresh_token: Option, } /// Login command - makes HTTP request from Rust to bypass CORS @@ -513,6 +515,7 @@ pub async fn login( token: login_response.access_token, username, email, + refresh_token: login_response.refresh_token, }) } else { // Spring Boot authentication flow @@ -615,6 +618,7 @@ pub async fn login( token: login_response.session.access_token, username: login_response.user.username, email: login_response.user.email, + refresh_token: None, }) } } diff --git a/frontend/editor/src/desktop/services/authService.ts b/frontend/editor/src/desktop/services/authService.ts index 6f5d3214f3..63181ef1dd 100644 --- a/frontend/editor/src/desktop/services/authService.ts +++ b/frontend/editor/src/desktop/services/authService.ts @@ -30,6 +30,7 @@ interface LoginResponse { token: string; username: string; email: string | null; + refresh_token: string | null; } interface OAuthCallbackResult { @@ -347,11 +348,18 @@ export class AuthService { saasServerUrl: STIRLING_SAAS_URL, }); - const { token, username: returnedUsername, email } = response; + const { + token, + username: returnedUsername, + email, + refresh_token: refreshToken, + } = response; - // Save token to all storage locations + // Save token to all storage locations. Supabase (SaaS) logins include a + // refresh token so the short-lived access token can be renewed; self-hosted + // logins return null here and refresh via the current access token instead. try { - await this.saveTokenEverywhere(token); + await this.saveTokenEverywhere(token, refreshToken); } catch (error) { console.error("[Desktop AuthService] Failed to save token:", error); throw new Error("Failed to save authentication token", { diff --git a/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..82b452aa0c --- /dev/null +++ b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts @@ -0,0 +1,12 @@ +import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Desktop: a policy run's outputs live on the backend that executed it. + */ +export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string { + if (target === "saas") { + return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, ""); + } + return ""; +} diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 922e64650f..2034866b87 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -18,6 +18,7 @@ function rec(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 10, + target: "saas", status: "PENDING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 295c45d737..45a8066bf0 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -10,7 +10,10 @@ */ import { useSyncExternalStore } from "react"; -import type { PolicyRunStatus } from "@app/services/policyPipeline"; +import type { + PolicyExecutionTarget, + PolicyRunStatus, +} from "@app/services/policyPipeline"; export interface PolicyRunRecord { runId: string; @@ -18,6 +21,7 @@ export interface PolicyRunRecord { fileId: string; fileName: string; fileSize: number; + target: PolicyExecutionTarget; status: PolicyRunStatus; /** Pipeline progress reported by the run-status endpoint: the 1-based step * currently running, and the total step count. Drive the "step X/Y" label @@ -72,6 +76,8 @@ function read(): RunState { importedFileIds: Array.isArray(r.importedFileIds) ? r.importedFileIds : [], + // Records predating per-run targets all executed on SaaS. + target: r.target === "local" ? "local" : "saas", })) : [], dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [], diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx index 36763a3e37..6090557c70 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx @@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({ getPolicyRun: vi.fn(), listPolicyRuns: mocks.listPolicyRuns, downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { @@ -75,6 +76,7 @@ function recordCompletedRun() { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "COMPLETED", outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index a0891a28f5..7acc1ae2bb 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({ runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, @@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "RUNNING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 8440d53b5b..19f88c9304 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -20,11 +20,13 @@ import { import { fileStorage } from "@app/services/fileStorage"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import i18n from "@app/i18n"; import { runStoredPolicy, getPolicyRun, listPolicyRuns, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; import type { PolicyRunStatus, @@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000; * to an instance that hasn't seen it) then fail, rather than polling forever. */ const MAX_NOT_FOUND = 3; -/** A 404 from the run-status endpoint, across the web (axios) and desktop - * (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ -function isRunNotFound(err: unknown): boolean { +/** A 404 (run status gone, or output file gone), across the web (axios) and + * desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ +function isNotFoundError(err: unknown): boolean { const e = err as | { code?: string; status?: number; response?: { status?: number } } | null @@ -354,6 +356,9 @@ async function reconcileServerRuns( fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, + // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs + // live on the cloud backend. + target: "saas", status: view.status, outputs: view.outputs, error: view.error, @@ -403,9 +408,9 @@ async function importOutputs( const targetName = ctx.outputName ? undefined // use the run's per-output (renamed) name below : run.fileName; - const results = await Promise.allSettled( + const settled = await Promise.allSettled( pending.map(async (out) => { - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, run.target); return { fileId: out.fileId, file: new File([blob], targetName ?? out.fileName ?? run.fileName, { @@ -414,13 +419,33 @@ async function importOutputs( }; }), ); - const fetched = results + const fetched = settled .filter( (r): r is PromiseFulfilledResult<{ fileId: string; file: File }> => r.status === "fulfilled", ) .map((r) => r.value); - if (fetched.length === 0) return; // all failed — retry the lot on a later tick + // A 404 means the backend no longer has that output (past its retention + // window); retrying it can never succeed, so don't loop on it forever. Any + // other rejection is transient and worth retrying on a later tick. + const rejections = settled + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason); + const allFailuresPermanent = + rejections.length > 0 && rejections.every(isNotFoundError); + + if (fetched.length === 0) { + if (allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.outputsUnavailable", + "Policy outputs are no longer available to download.", + ), + ); + } + return; // transient/mixed: retry the lot later; permanent: already failed. + } // Deliver, then mark exactly those imported. If delivery throws we don't mark // them, so they retry (without having been added). @@ -472,12 +497,26 @@ async function importOutputs( deliveredIds = added.map((f) => f.fileId as string); } const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; + const imported = run.outputs.every((out) => + importedFileIds.includes(out.fileId), + ); updateRun(run.runId, { importedFileIds, // Accumulate across partial-import retries rather than overwriting. outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds], - imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)), + imported, }); + // Some outputs landed but the rest are permanently gone (404): finalize so the + // run stops re-fetching the missing ones on every tick. + if (!imported && allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.partialOutputsUnavailable", + "Some policy outputs are no longer available to download.", + ), + ); + } } /** @@ -515,6 +554,7 @@ export async function runPolicyOnFile( return; } try { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ @@ -523,6 +563,7 @@ export async function runPolicyOnFile( fileId, fileName, fileSize: file.size, + target, status: "PENDING", outputs: [], error: null, @@ -562,9 +603,15 @@ export async function poll( // The server lost the run's (in-memory) state — a restart, or a poll that // hopped to an instance without it. Tolerate a brief blip, then fail so // the file stops enforcing forever; the user can retry. - if (isRunNotFound(err)) { + if (isNotFoundError(err)) { if (++notFoundStreak >= MAX_NOT_FOUND) { - failRun(runId, "The enforcement run could no longer be found."); + failRun( + runId, + i18n.t( + "policies.activity.runNotFound", + "The enforcement run could no longer be found.", + ), + ); return; } } else { @@ -591,5 +638,11 @@ export async function poll( } // Budget exhausted without a terminal status — stop here and fail it, so the // file doesn't enforce forever and reloads don't re-poll it. - failRun(runId, "Enforcement timed out — the run didn't finish in time."); + failRun( + runId, + i18n.t( + "policies.activity.timedOut", + "Enforcement timed out before the run could finish.", + ), + ); } diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index 30c5a83952..db69bb77af 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -15,6 +15,7 @@ function run(overrides: Partial): PolicyRunRecord { fileId: "in", fileName: "in.pdf", fileSize: 1, + target: "saas", status: "COMPLETED", outputs: [], outputFileIds: ["out"], diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index 1936cf0894..b3f1fd999f 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -6,9 +6,11 @@ */ import apiClient from "@app/services/apiClient"; +import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl"; import type { BackendPipelineDefinition, BackendPolicy, + PolicyExecutionTarget, PolicyRunView, } from "@app/services/policyPipeline"; @@ -88,10 +90,25 @@ export async function runPolicyPipeline( return res.data.jobId; } -/** Download a run's output file by id (via the shared general-files endpoint). */ -export async function downloadPolicyOutput(fileId: string): Promise { +/** + * Where a policy run executes, and thus the backend that holds its outputs. + */ +export function resolvePolicyRunTarget(): PolicyExecutionTarget { + return "saas"; +} + +/** + * Download a run's output file by id (via the shared general-files endpoint). + * `target` is where the run executed: it selects the backend the file is fetched + * from, so a SaaS run's output isn't looked for on the bundled local backend. + */ +export async function downloadPolicyOutput( + fileId: string, + target: PolicyExecutionTarget, +): Promise { + const base = getPolicyOutputBaseUrl(target); const res = await apiClient.get( - `/api/v1/general/files/${encodeURIComponent(fileId)}`, + `${base}/api/v1/general/files/${encodeURIComponent(fileId)}`, { responseType: "blob" }, ); return res.data; diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index f4dce74873..92b14712dd 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -16,7 +16,9 @@ import { runStoredPolicy, getPolicyRun, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; import { recordRunStart, isDispatched, @@ -52,6 +54,7 @@ interface ExportPolicy { interface PolicyRunResult { file: File; runId: string; + target: PolicyExecutionTarget; outputs: { fileId: string; fileName: string }[]; } @@ -84,6 +87,7 @@ async function runToCompletion( backendId: string, file: File, ): Promise { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); for (let i = 0; i < MAX_POLLS; i++) { await delay(POLL_MS); @@ -96,12 +100,12 @@ async function runToCompletion( if (view.status === "COMPLETED") { const out = view.outputs?.[0]; if (!out) throw new Error("policy produced no output"); - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, target); // Keep the export's filename; only the bytes are the enforced result. const enforced = new File([blob], file.name, { type: blob.type || file.type || "application/pdf", }); - return { file: enforced, runId, outputs: view.outputs ?? [] }; + return { file: enforced, runId, target, outputs: view.outputs ?? [] }; } if (view.status === "FAILED" || view.status === "CANCELLED") { throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); @@ -219,6 +223,7 @@ export async function enforceExportPolicies( fileId, fileName: file.name, fileSize: file.size, + target: versionRun!.target, status: "COMPLETED", outputs: versionRun.outputs, error: null, diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts index 38b96c845e..c3016f071b 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.test.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -14,6 +14,7 @@ function run(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 0, + target: "saas", status: "COMPLETED", outputs: [], error: null, @@ -47,7 +48,7 @@ describe("runsToActivity", () => { expect(activity[0]).toMatchObject({ doc: "fresh.pdf", status: "processing", - action: "Enforcing…", + action: "Enforcing...", }); expect(activity[1]).toMatchObject({ doc: "contract.pdf", @@ -66,9 +67,9 @@ describe("runsToActivity", () => { run({ runId: "a", status: "RUNNING", currentStep: 1, stepCount: 2 }), run({ runId: "b", status: "RUNNING" }), ]); - expect(withStep.action).toBe("Enforcing… · step 1/2"); + expect(withStep.action).toBe("Enforcing... · step 1/2"); // Before the first status report (no step yet) it stays the plain label. - expect(noStep.action).toBe("Enforcing…"); + expect(noStep.action).toBe("Enforcing..."); }); it("shows a queue-rejected run awaiting retry as busy, not a failure", () => { @@ -81,7 +82,7 @@ describe("runsToActivity", () => { }), ]); expect(item.status).toBe("processing"); - expect(item.action).toBe("Busy — retrying…"); + expect(item.action).toBe("Busy, retrying..."); }); it("shows a queue rejection that has exhausted its retries as a failure", () => { diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts index 67d53f9c5e..56401f4d19 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -5,6 +5,7 @@ * policy's actual enforcement history — not a cosmetic file listing. */ +import i18n from "@app/i18n"; import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; @@ -58,17 +59,20 @@ function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] { function activityAction(run: PolicyRunRecord): string { switch (activityStatus(run)) { case "enforced": - return `${formatBytes(run.fileSize)} • enforced`; + return `${formatBytes(run.fileSize)} • ${i18n.t("policies.activity.enforced", "enforced")}`; case "flagged": - return run.error ?? "Enforcement failed"; + return ( + run.error ?? i18n.t("policies.activity.failed", "Enforcement failed") + ); default: { - if (run.retrying) return "Busy — retrying…"; - // Show pipeline progress while running, once the status endpoint reports - // it — turns a static "Enforcing…" into visible movement on slow steps. + if (run.retrying) + return i18n.t("policies.activity.retrying", "Busy, retrying..."); + const enforcing = i18n.t("policies.activity.enforcing", "Enforcing..."); + // Show pipeline progress while running, once the status endpoint reports it const { currentStep, stepCount } = run; return currentStep && stepCount - ? `Enforcing… · step ${currentStep}/${stepCount}` - : "Enforcing…"; + ? `${enforcing} · ${i18n.t("policies.activity.step", "step {{current}}/{{total}}", { current: currentStep, total: stepCount })}` + : enforcing; } } } diff --git a/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..c5a127c0d5 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts @@ -0,0 +1,13 @@ +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Base URL for downloading a policy run's output file, given where the run + * executed. + * + * Web builds are served from their own backend, so a relative request resolves + * to the right place regardless of where the run ran, hence "" for every + * target. + */ +export function getPolicyOutputBaseUrl(_target: PolicyExecutionTarget): string { + return ""; +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index 21e304e4fb..b27bd09e4d 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -56,6 +56,12 @@ export interface BackendPolicy { output: BackendOutputSpec; } +/** + * Where a policy run executes, and therefore where its output files live and + * are downloaded from. + */ +export type PolicyExecutionTarget = "local" | "saas"; + /** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ export type PolicyRunStatus = | "PENDING"