From 11ab762f579e1a574678ea7018e41b23799e561b Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:45:22 +0100 Subject: [PATCH] feat(policies): config refinements + new-version output (post-#6598) (#6604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6598 (squash-merged into `SaaS`). These are the policy refinements made after that merge, against the current `SaaS` tip. ## Changes - **Simplify Security config + plain-language info buttons** — Redact config reduced to the PII field; Sanitise has no config (JavaScript-removal only) with a non-technical info button; per-tool info buttons reworded to match the tool-steps style. - **Hide 'Flatten PDF pages to images' from the watermark policy config** — new `PolicyWatermarkConfig` wrapping the watermark settings with the flatten checkbox gated off. - **Flatten-to-image on by default for redact + watermark** — both normalise `convertPDFToImage: true` on mount. - **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates a backing folder whose `folderId` no longer resolves (preferring the backend's stored automation), instead of hanging Edit Settings on a permanent "Loading…". - **Version the input file on 'new version' output mode** — completed runs whose policy output mode is `new_version` replace the input file with a versioned child (origin tool `automate`) rather than adding a separate file; falls back to a new file if the input is gone. `outputMode` is plumbed through `PolicyState`, the local-cache default, and backend reconciliation. ## Verification - `typecheck:proprietary` + `typecheck:core` clean - policy + hooks vitest: 17 passing - eslint + prettier clean on all changed files --- .../AddWatermarkSingleStepSettings.tsx | 4 + .../addWatermark/WatermarkFormatting.tsx | 27 +-- .../components/policies/PolicyDetailPanel.tsx | 2 + .../policies/PolicyRedactConfig.tsx | 129 +++------------ .../components/policies/PolicySetupWizard.tsx | 106 ++++-------- .../components/policies/PolicyToolConfig.tsx | 49 +++++- .../policies/PolicyWatermarkConfig.tsx | 38 +++++ .../components/policies/policyRunStore.ts | 4 + .../components/policies/usePolicyAutoRun.ts | 156 +++++++++++++++--- .../proprietary/data/policyDefinitions.tsx | 28 ++-- .../src/proprietary/hooks/usePolicies.ts | 34 +++- .../proprietary/hooks/usePolicyFileBadges.ts | 26 +-- .../src/proprietary/services/policyBackend.ts | 2 + .../proprietary/services/policyPipeline.ts | 7 +- .../src/proprietary/services/policyStorage.ts | 4 + .../editor/src/proprietary/types/policies.ts | 7 + 16 files changed, 373 insertions(+), 250 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx diff --git a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx index b9f60bf9e..b78ab666b 100644 --- a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx +++ b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx @@ -21,12 +21,15 @@ interface AddWatermarkSingleStepSettingsProps { value: AddWatermarkParameters[K], ) => void; disabled?: boolean; + /** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */ + showFlatten?: boolean; } const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false, + showFlatten = true, }: AddWatermarkSingleStepSettingsProps) => { return ( @@ -69,6 +72,7 @@ const AddWatermarkSingleStepSettings = ({ parameters={parameters} onParameterChange={onParameterChange} disabled={disabled} + showFlatten={showFlatten} /> )} diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.tsx index add061280..fe79dd702 100644 --- a/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.tsx +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.tsx @@ -10,12 +10,15 @@ interface WatermarkFormattingProps { value: AddWatermarkParameters[K], ) => void; disabled?: boolean; + /** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */ + showFlatten?: boolean; } const WatermarkFormatting = ({ parameters, onParameterChange, disabled = false, + showFlatten = true, }: WatermarkFormattingProps) => { const { t } = useTranslation(); @@ -95,17 +98,19 @@ const WatermarkFormatting = ({ {/* Advanced Options */} - - onParameterChange("convertPDFToImage", event.currentTarget.checked) - } - disabled={disabled} - /> + {showFlatten && ( + + onParameterChange("convertPDFToImage", event.currentTarget.checked) + } + disabled={disabled} + /> + )} ); }; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx index bda878299..e30aa5adf 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -9,6 +9,7 @@ import AutorenewIcon from "@mui/icons-material/Autorenew"; import LockIcon from "@mui/icons-material/Lock"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import { PanelHeader } from "@shared/components/PanelHeader"; +import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import { Card } from "@shared/components/Card"; import { ChipFlow } from "@shared/components/ChipFlow"; import { StatusBadge } from "@shared/components/StatusBadge"; @@ -121,6 +122,7 @@ export function PolicyDetailPanel({
; @@ -20,109 +8,38 @@ interface PolicyRedactConfigProps { } /** - * Redact configuration for a policy: a PII preset dropdown plus a separate field - * for the user's own words / regexes, then the advanced redact options. The two - * lists are kept disjoint — the dropdown owns the preset patterns, the custom - * field owns everything else — so selecting presets and typing custom patterns - * don't clobber each other. Regex matching is always on (a plain word is a - * literal regex), so the Use-Regex toggle is omitted. Mode stays automatic since - * policies run headless and manual redaction needs the canvas. + * Redact configuration for a policy — reduced to just the PII type picker. The + * runtime params are fixed (mode automatic, regex on, flatten-to-image on so the + * redacted text is truly removed) and normalised once on mount; the dropdown + * only chooses which PII patterns are redacted. */ export function PolicyRedactConfig({ parameters, onChange, disabled, }: PolicyRedactConfigProps) { - const words = Array.isArray(parameters.wordsToRedact) - ? (parameters.wordsToRedact as string[]) - : []; - // Split the stored list: presets are driven by the dropdown, the rest by the - // custom field below. Each editor only ever rewrites its own half. - const presetWords = words.filter((w) => PRESET_PATTERNS.has(w)); - const customWords = words.filter((w) => !PRESET_PATTERNS.has(w)); - - const redactColor = - typeof parameters.redactColor === "string" - ? parameters.redactColor - : "#000000"; - const customPadding = - typeof parameters.customPadding === "number" - ? parameters.customPadding - : 0.1; - const wholeWordSearch = parameters.wholeWordSearch === true; - const convertPDFToImage = parameters.convertPDFToImage !== false; // default on - - // Regex is always on for policies; heal any older/default-false value once on - // mount (empty deps — we only need to normalise the persisted flag). + // Lock the non-PII params once on mount (empty deps); the dropdown manages the + // pattern list and preserves these via its spread. useEffect(() => { - if (parameters.useRegex !== true) { - onChange({ ...parameters, useRegex: true }); + if ( + parameters.mode !== "automatic" || + parameters.useRegex !== true || + parameters.convertPDFToImage !== true + ) { + onChange({ + ...parameters, + mode: "automatic", + useRegex: true, + convertPDFToImage: true, + }); } }, []); - // Every edit keeps mode automatic + regex on (the two policy invariants). - const patch = (next: Record) => - onChange({ ...parameters, mode: "automatic", useRegex: true, ...next }); - return ( - - - - - - - patch({ wordsToRedact: [...presetWords, ...next] }) - } - disabled={disabled} - /> - - - - patch({ redactColor: value })} - disabled={disabled} - size="sm" - format="hex" - popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} - /> - - - patch({ customPadding: typeof value === "number" ? value : 0.1 }) - } - min={0} - max={10} - step={0.1} - disabled={disabled} - size="sm" - placeholder="0.1" - /> - - patch({ wholeWordSearch: e.currentTarget.checked })} - disabled={disabled} - size="sm" - /> - - patch({ convertPDFToImage: e.currentTarget.checked })} - disabled={disabled} - size="sm" - /> - + ); } diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index 7078277af..2231d5112 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -2,19 +2,16 @@ import { useState, useMemo, useRef } from "react"; import CloseIcon from "@mui/icons-material/Close"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { PanelHeader } from "@shared/components/PanelHeader"; +import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import { Card } from "@shared/components/Card"; import { Button } from "@shared/components/Button"; -import { ChipFlow } from "@shared/components/ChipFlow"; -import { DataRow } from "@shared/components/DataRow"; import { Input } from "@shared/components/Input"; import { Select } from "@shared/components/Select"; import { SettingsRow } from "@shared/components/SettingsRow"; -import { FormField } from "@shared/components/FormField"; import { Checkbox } from "@shared/components/Checkbox"; import { Banner } from "@shared/components/Banner"; import { EmptyState } from "@shared/components/EmptyState"; import { StepIndicator } from "@shared/components/StepIndicator"; -import { IconBadge } from "@shared/components/IconBadge"; import type { PolicyCategory, PolicyConfigDef, @@ -43,7 +40,7 @@ import { getPolicyToolChain } from "@app/components/policies/policyToolChains"; // Sources are always "editor" for this release, so the Sources step is dropped // from the flow (its panel code is kept below for when other sources return). const SOURCES_IN_FLOW = false; -const TOTAL_STEPS = SOURCES_IN_FLOW ? 4 : 3; +const TOTAL_STEPS = SOURCES_IN_FLOW ? 3 : 2; interface PolicySetupWizardProps { category: PolicyCategory; @@ -80,10 +77,11 @@ interface PolicySetupWizardProps { } /** - * The shared policy wizard, used for both setup and edit. Four steps: - * Workflow (the tool pipeline, reusing the Watch Folders builder) → Settings - * (the policy fields) → Sources → Review. The workflow builder is kept mounted - * across steps so the final action can trigger its save. + * The shared policy wizard, used for both setup and edit. Two steps: Workflow + * (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy + * fields + output/retry config). The workflow builder is kept mounted across + * steps so the final action can trigger its save. (A Sources step exists in + * code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.) */ export function PolicySetupWizard({ category, @@ -115,14 +113,13 @@ export function PolicySetupWizard({ ); const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0); const [scopeTypes, setScopeTypes] = useState(initial.scopeTypes); - // Default flagged-document reviewer to the signed-in user. - const [reviewerEmail, setReviewerEmail] = useState( - initial.reviewerEmail || user?.email || "", - ); + // Reviewer is no longer configured in the flow (there's no human-review step), + // but the field is kept in the saved policy, defaulted to the signed-in user. + const reviewerEmail = initial.reviewerEmail || user?.email || ""; // Output + retry settings — the real, working folder settings (the engine // applies them). Pre-filled from the backing folder in edit mode. const [outputMode, setOutputMode] = useState<"new_file" | "new_version">( - initialFolder?.outputMode ?? "new_file", + initialFolder?.outputMode ?? "new_version", ); const [outputName, setOutputName] = useState(initialFolder?.outputName ?? ""); const [outputNamePosition, setOutputNamePosition] = useState< @@ -157,6 +154,7 @@ export function PolicySetupWizard({
@@ -265,6 +263,7 @@ export function PolicySetupWizard({
setOutputName(e.target.value)} - placeholder="optional" - aria-label="Output name" - /> - } - /> -
-
-
+
+ setOutputName(e.target.value)} + placeholder="optional" + aria-label="Text to add" + /> + } + /> +
)} - - {step === TOTAL_STEPS && ( - <> -

- When Stirling has low confidence in an enforcement action, it will - send the document for human review. -

-

Reviewer

- - - setReviewerEmail(e.target.value)} - placeholder="email@company.com" - /> - - - -

Summary

- -
- - {category.icon} - - - {category.label} Policy - -
-
- - - - {SOURCES_IN_FLOW && ( - {sources.length} selected - )} - - {reviewerEmail || Not set} - -
-
- - )}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx index 497cad780..ba8a3842f 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx @@ -2,10 +2,22 @@ import { Suspense } from "react"; import { Loader } from "@mantine/core"; import { ToggleSwitch } from "@shared/components/ToggleSwitch"; import { Card } from "@shared/components/Card"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; import type { ToolRegistry } from "@app/data/toolsTaxonomy"; import type { ToolId } from "@app/types/toolId"; +/** Plain-language, non-technical descriptions shown by each tool's info button. */ +const TOOL_PLAIN_INFO: Record = { + redact: + "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.", + sanitize: + "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.", + watermark: "Stamps a visible mark (e.g. “Confidential”) across every page.", +}; + /** One tool in a policy's fixed chain: whether it runs + its configured params. */ export interface PolicyToolState { /** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */ @@ -54,6 +66,26 @@ export function PolicyToolConfig({ {entry?.name ?? tool.operation} + {TOOL_PLAIN_INFO[tool.operation] && ( + + + + )} {tool.enabled && (tool.operation === "redact" ? ( - // Redact has a bespoke config: PII preset dropdown + a custom - // word/regex field + advanced options, mode + regex locked on. + // Redact config is reduced to just the PII type picker.
+ ) : tool.operation === "sanitize" ? ( + // Sanitize is config-less: it only removes JavaScript (params + // are fixed in the policy preset), so no settings are shown. + <> + ) : tool.operation === "watermark" ? ( + // Watermark: full settings minus the "Flatten PDF pages to + // images" toggle (hidden), with flatten forced on. +
+ patchTool(index, { parameters })} + disabled={!editable} + /> +
) : Settings ? (
}> diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx new file mode 100644 index 000000000..692508a92 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"; +import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +interface PolicyWatermarkConfigProps { + parameters: Record; + onChange: (parameters: Record) => void; + disabled?: boolean; +} + +/** + * Watermark configuration for a policy: the full watermark settings minus the + * "Flatten PDF pages to images" toggle (hidden), with flatten forced on so the + * watermark is baked into the page and can't be stripped out. Normalised once + * on mount. + */ +export function PolicyWatermarkConfig({ + parameters, + onChange, + disabled, +}: PolicyWatermarkConfigProps) { + useEffect(() => { + if (parameters.convertPDFToImage !== true) { + onChange({ ...parameters, convertPDFToImage: true }); + } + }, []); + + return ( + + onChange({ ...parameters, [key]: value }) + } + disabled={disabled} + showFlatten={false} + /> + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 77eb931b5..434b84dfa 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -26,6 +26,10 @@ export interface PolicyRunRecord { /** Output fileIds already imported — tracked per-file so a partial failure * retries only the missing ones and never re-adds the ones that succeeded. */ importedFileIds?: string[]; + /** Workspace fileIds of the imported output files (the versioned child for + * "new version", or the added file for "new file"). Drives the policy badge, + * which marks the policy's OUTPUT — not the input it ran on. */ + outputFileIds?: string[]; error: string | null; /** Epoch ms when the run was dispatched. */ startedAt: number; diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index a9407a6d0..4cfc537f4 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -12,7 +12,11 @@ */ import { useEffect, useRef } from "react"; -import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileManagement, + useFileContext, +} from "@app/contexts/FileContext"; import { fileStorage } from "@app/services/fileStorage"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; import { @@ -22,8 +26,11 @@ import { } from "@app/services/policyApi"; import type { PolicyRunStatus } from "@app/services/policyPipeline"; import type { FileId } from "@app/types/file"; +import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import { usePolicies } from "@app/hooks/usePolicies"; import { + dispatchKey, isDispatched, markDispatched, recordRunStart, @@ -36,6 +43,12 @@ import { const POLL_MS = 2000; const MAX_POLLS = 75; +/** How long to wait for an upload's bytes to land in IndexedDB before giving up + * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its + * bytes are committed, so a too-eager fetch would otherwise miss the file. */ +const FILE_WAIT_TRIES = 20; +const FILE_WAIT_MS = 250; + function isTerminal(status: PolicyRunStatus): boolean { return ( status === "COMPLETED" || status === "FAILED" || status === "CANCELLED" @@ -47,11 +60,14 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export function usePolicyAutoRun(): void { const { fileStubs } = useAllFiles(); const { addFiles } = useFileManagement(); + const { consumeFiles } = useFileContext(); const { policies } = usePolicies(); const runs = usePolicyRuns(); - // Run ids currently being polled / imported, so the effects never double-fire. + // Keys (run ids / dispatch keys) currently in flight, so the effects never + // double-fire across re-renders while their first async step is pending. const polling = useRef>(new Set()); const importing = useRef>(new Set()); + const dispatching = useRef>(new Set()); // Dispatch: for each active policy × each session file not yet run, fire a run. useEffect(() => { @@ -61,14 +77,25 @@ export function usePolicyAutoRun(): void { ); for (const [categoryId, s] of active) { for (const stub of fileStubs) { - if (isDispatched(categoryId, stub.id)) continue; - // runPolicyOnFile marks dispatched synchronously before its first await. + const key = dispatchKey(categoryId, stub.id); + // Skip if already run (persisted) or a dispatch is mid-flight (this + // session) — runPolicyOnFile now waits for the file's bytes to commit, + // so the in-memory guard, not an eager mark, prevents double-firing. + if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) { + continue; + } + dispatching.current.add(key); void runPolicyOnFile( categoryId, s.backendId as string, stub.id, stub.name, - ); + ) + .catch(() => { + // runPolicyOnFile handles its own failures; this is just a backstop + // so an unexpected rejection never becomes an unhandled rejection. + }) + .finally(() => dispatching.current.delete(key)); } } }, [fileStubs, policies]); @@ -97,23 +124,53 @@ export function usePolicyAutoRun(): void { continue; } importing.current.add(run.runId); - void importOutputs(run, addFiles).finally(() => - importing.current.delete(run.runId), - ); + // Honour the policy's output mode: a new file, or a new version of the + // input file it ran on (needs that input's stub, still in the workspace). + const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; + const outputName = policies[run.categoryId]?.outputName ?? ""; + const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); + void importOutputs(run, { + addFiles, + consumeFiles, + outputMode, + outputName, + parentStub, + }).finally(() => importing.current.delete(run.runId)); } - }, [runs, addFiles]); + }, [runs, addFiles, consumeFiles, policies, fileStubs]); +} + +interface ImportContext { + addFiles: (files: File[]) => Promise; + consumeFiles: ( + inputFileIds: FileId[], + outputs: StirlingFile[], + stubs: StirlingFileStub[], + ) => Promise; + /** "new_file" adds the output as a separate file; "new_version" versions the input. */ + outputMode: "new_file" | "new_version"; + /** Rename rule. Empty → keep the input's filename; set → use the policy's + * renamed output (applied server-side per the name-position setting). */ + outputName: string; + /** The input file's stub — required to version it; absent if it's been removed. */ + parentStub: StirlingFileStub | undefined; } /** - * Fetch a completed run's not-yet-imported output files and add them to the - * workspace. Per-output, via allSettled: each output is tracked once imported, + * Fetch a completed run's not-yet-imported output files and deliver them to the + * workspace. Per-output, via allSettled: each output is tracked once delivered, * so a partial failure retries only the missing files on a later tick and the * ones that succeeded are never added twice. `imported` flips true only once * every output has landed. + * + * Delivery honours the policy's output mode: "new_version" replaces the input + * file with a versioned child (its history chain), "new_file" adds the output + * as a standalone file. Versioning falls back to a new file if the input is + * gone (no parent stub). */ async function importOutputs( run: PolicyRunRecord, - addFiles: (files: File[]) => Promise, + ctx: ImportContext, ): Promise { const done = new Set(run.importedFileIds ?? []); const pending = run.outputs.filter((out) => !done.has(out.fileId)); @@ -122,12 +179,18 @@ async function importOutputs( return; } + // Keep the input's original filename unless a rename rule is set — without a + // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would + // otherwise rename every output. + const targetName = ctx.outputName + ? undefined // use the run's per-output (renamed) name below + : run.fileName; const results = await Promise.allSettled( pending.map(async (out) => { const blob = await downloadPolicyOutput(out.fileId); return { fileId: out.fileId, - file: new File([blob], out.fileName || run.fileName, { + file: new File([blob], targetName ?? out.fileName ?? run.fileName, { type: blob.type || "application/pdf", }), }; @@ -141,12 +204,38 @@ async function importOutputs( .map((r) => r.value); if (fetched.length === 0) return; // all failed — retry the lot on a later tick - // Add the freshly-fetched files, then mark exactly those imported. If addFiles - // throws we don't mark them, so they retry (without having been added). - await addFiles(fetched.map((f) => f.file)); + // Deliver, then mark exactly those imported. If delivery throws we don't mark + // them, so they retry (without having been added). + const files = fetched.map((f) => f.file); + // Workspace fileIds of the delivered outputs — the policy badge marks these + // (the policy's output), not the input it ran on. Set in both branches below. + let deliveredIds: string[]; + if (ctx.outputMode === "new_version" && ctx.parentStub) { + // Replace the input file with a versioned child (preserves its history). + // The version records "automate" as its origin tool — a policy is a + // multi-tool automation, not any single tool (redact/watermark/sanitize/…). + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + files, + ctx.parentStub, + "automate", + ); + // Mark the outputs handled BEFORE adding them, so the auto-run never enforces + // the policy on its own output — that would version endlessly in a loop. + for (const s of stubs) markDispatched(run.categoryId, s.id); + deliveredIds = stubs.map((s) => s.id as string); + await ctx.consumeFiles([run.fileId as FileId], stirlingFiles, stubs); + } else { + const added = await ctx.addFiles(files); + // Same loop-guard for new-file output: the produced file is a new workspace + // file the auto-run would otherwise re-enforce indefinitely. + for (const f of added) markDispatched(run.categoryId, f.fileId); + deliveredIds = added.map((f) => f.fileId as string); + } const importedFileIds = [...done, ...fetched.map((f) => f.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)), }); } @@ -161,13 +250,33 @@ export async function runPolicyOnFile( fileId: FileId, fileName: string, ): Promise { - // Mark synchronously, before any await, so neither the dispatch effect nor a - // rapid Retry click can double-fire while the file bytes load. - markDispatched(categoryId, fileId); + // A freshly-uploaded file's bytes are written to IndexedDB asynchronously, so + // its stub can appear in the file list a beat before getStirlingFile resolves + // it. Wait briefly rather than bail — and DON'T mark dispatched until we hold + // the file, or a too-early miss would skip enforcement on that file forever. + // (The caller's in-flight guard prevents double-dispatch during this wait.) + // A transient IndexedDB error is treated as a miss (not a throw), so it retries + // and then marks dispatched rather than rejecting into a hot re-dispatch loop. + const tryGetFile = async (): Promise => { + try { + return await fileStorage.getStirlingFile(fileId); + } catch { + return null; + } + }; + let file = await tryGetFile(); + for (let i = 0; i < FILE_WAIT_TRIES && !file; i++) { + await delay(FILE_WAIT_MS); + file = await tryGetFile(); + } + if (!file) { + // File genuinely gone (removed before it could run) — mark so we don't loop. + markDispatched(categoryId, fileId); + return; + } try { - const file = await fileStorage.getStirlingFile(fileId); - if (!file) return; // file gone; nothing to run (already marked above). const runId = await runStoredPolicy(backendId, [file]); + // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ runId, categoryId, @@ -180,8 +289,9 @@ export async function runPolicyOnFile( startedAt: Date.now(), }); } catch { - // Dispatch failed (offline / backend error). Already marked dispatched so we - // don't hammer; the absent run simply won't appear in the activity feed. + // Dispatch failed (offline / backend error). Mark dispatched so we don't + // hammer; the absent run simply won't appear in the activity feed. + markDispatched(categoryId, fileId); } } diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 5151c2c96..07601a0d3 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -180,27 +180,29 @@ export const POLICY_CONFIG: Record = { parameters: { mode: "automatic", useRegex: true, + // Flatten to image so redacted text is truly removed, not just hidden + // behind a box (heavier, but real redaction). + convertPDFToImage: true, wordsToRedact: DEFAULT_PII_PATTERNS, }, }, - { operation: "sanitize", parameters: {} }, + { + // Sanitize is fixed to JavaScript removal only (no per-policy config). + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, + removeXMPMetadata: false, + }, + }, ], scopeLabel: "All PDFs on this device", // Policy-level controls only — detection/encryption/signing/watermark are // per-tool and now live in the Workflow step. fields: [ - { - label: "Default PII response", - key: "defaultResponse", - type: "select", - value: "Highlight & tag", - options: [ - "Highlight & tag", - "Prompt on export", - "Auto-redact on export", - "Block export", - ], - }, { label: "User can override", key: "userOverride", diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index f5fee714e..8e954b23f 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -128,6 +128,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -161,6 +163,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -222,6 +226,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -249,17 +255,35 @@ export function usePolicies() { }, []); /** - * Ensure a configured policy has a backing folder (its editable pipeline), - * creating one from the preset if missing. Returns the folder id. + * Ensure a configured policy has a *valid* backing folder (its editable + * pipeline) and return its id. Self-heals a stale `folderId` — one that no + * longer resolves to a real folder (cleared storage, or a folder left in an + * old IndexedDB after a rename/migration) — which would otherwise hang the + * Edit-Settings view on a permanent "Loading…". When recreating, the backend's + * stored automation is used if present so the configured pipeline survives; + * otherwise it falls back to the preset. */ const ensurePolicyFolder = useCallback(async (id: string) => { - const existing = loadPolicies()[id]?.folderId; - if (existing) return existing; + const state = loadPolicies()[id]; + const existing = state?.folderId; + // A healthy backing folder resolves to an automation; if it does, keep it. + if (existing && (await getPolicyAutomation(existing))) return existing; const catalog = loadPolicyCatalog(); const category = catalog.categories.find((c) => c.id === id); const config = catalog.configs[id]; if (!category || !config) return undefined; - const folder = await createPolicyFolder(category, config.defaultOperations); + // Stale/missing folder → recreate. Prefer the backend's stored automation + // (preserves the user's configured steps); else seed from the preset. + let operations = config.defaultOperations; + if (state?.backendId) { + const decoded = await fetchPoliciesByCategory() + .then((m) => m.get(id)) + .catch(() => undefined); + if (decoded?.automation?.operations?.length) { + operations = decoded.automation.operations; + } + } + const folder = await createPolicyFolder(category, operations); updatePolicy(id, { folderId: folder.id }); return folder.id; }, []); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 5f076225f..6afc43a48 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -14,9 +14,11 @@ const ACCENT_VAR: Record = { }; /** - * Distinct policies that have run on each file, keyed by fileId, derived from the - * reactive policy run store. Drives the file sidebar's shield badges. Shadows the - * core stub via the {@code @app/*} alias cascade. + * Distinct policies that have produced each file, keyed by fileId, derived from + * the reactive policy run store. Drives the file sidebar's shield badges. The + * badge marks a policy's OUTPUT (the versioned/added result), not the input it + * ran on — so it keys off each run's imported output fileIds. Shadows the core + * stub via the {@code @app/*} alias cascade. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); @@ -28,14 +30,16 @@ export function usePolicyFileBadges(): Map { for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; - const list = byFile.get(run.fileId) ?? []; - if (!list.some((p) => p.id === run.categoryId)) { - list.push({ - id: run.categoryId, - name, - accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], - }); - byFile.set(run.fileId, list); + for (const fileId of run.outputFileIds ?? []) { + const list = byFile.get(fileId) ?? []; + if (!list.some((p) => p.id === run.categoryId)) { + list.push({ + id: run.categoryId, + name, + accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + }); + byFile.set(fileId, list); + } } } return byFile; diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 4fd9694d9..77ff6ecbd 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -51,6 +51,8 @@ export function decodedToState( scopeTypes: decoded.scopeTypes, reviewerEmail: decoded.reviewerEmail, fieldValues: decoded.fieldValues, + outputMode: decoded.folder.outputMode, + outputName: decoded.folder.outputName, folderId: localFolderId, backendId: decoded.id, // Catalog-category policies are built-in defaults (not deletable). diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index b3b088ecd..abbd28dd9 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -203,7 +203,7 @@ export interface DecodedPolicy { } const DEFAULT_FOLDER: PolicyFolderSettings = { - outputMode: "new_file", + outputMode: "new_version", outputName: "", outputNamePosition: "prefix", maxRetries: 3, @@ -265,7 +265,10 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy { fieldValues: (output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, folder: { - outputMode: output.mode === "new_version" ? "new_version" : "new_file", + // Default to versioning unless the stored policy explicitly says new_file, + // so a missing/legacy output.mode follows the new-version default rather + // than silently flipping a reconciled policy to spawning separate files. + outputMode: output.mode === "new_file" ? "new_file" : "new_version", outputName: str(output.name), outputNamePosition: output.position === "suffix" diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index 7aa25c23e..87616555c 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -22,6 +22,10 @@ function defaultState(): PolicyState { // Empty by default; the wizard defaults the reviewer to the signed-in user. reviewerEmail: "", fieldValues: {}, + // Default to versioning the input file rather than spawning a separate one. + outputMode: "new_version", + // No rename by default — the output keeps the input's filename. + outputName: "", // Every catalog category is a shipped, built-in policy → default (not // deletable). User-created policies (later) will set this false. isDefault: true, diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index 979d69625..7cc5dc27c 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -125,6 +125,13 @@ export interface PolicyState { reviewerEmail: string; /** Saved field values, keyed by field key (overrides the definition default). */ fieldValues: Record; + /** How a run's output is delivered: a separate new file, or a new version of + * the input file the policy ran on. Defaults to "new_version". */ + outputMode?: "new_file" | "new_version"; + /** Rename rule for the output. When empty (the default) the output keeps the + * input's filename; when set, it's applied as a prefix/suffix per the policy's + * name-position setting. */ + outputName?: string; /** * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that * holds this policy's editable steps (its automation), output config and run