feat(policies): config refinements + new-version output (post-#6598) (#6604)

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
This commit is contained in:
Reece Browne
2026-06-11 14:45:22 +01:00
committed by GitHub
parent 68e031ac55
commit 11ab762f57
16 changed files with 373 additions and 250 deletions
@@ -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 (
<Stack gap="lg">
@@ -69,6 +72,7 @@ const AddWatermarkSingleStepSettings = ({
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
showFlatten={showFlatten}
/>
)}
</Stack>
@@ -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 = ({
</Group>
{/* Advanced Options */}
<Checkbox
label={t(
"watermark.settings.convertToImage",
"Flatten PDF pages to images",
)}
checked={parameters.convertPDFToImage}
onChange={(event) =>
onParameterChange("convertPDFToImage", event.currentTarget.checked)
}
disabled={disabled}
/>
{showFlatten && (
<Checkbox
label={t(
"watermark.settings.convertToImage",
"Flatten PDF pages to images",
)}
checked={parameters.convertPDFToImage}
onChange={(event) =>
onParameterChange("convertPDFToImage", event.currentTarget.checked)
}
disabled={disabled}
/>
)}
</Stack>
);
};
@@ -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({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={category.label}
onBack={onBack}
actions={
@@ -1,17 +1,5 @@
import { useEffect } from "react";
import {
Stack,
Divider,
ColorInput,
NumberInput,
Checkbox,
} from "@mantine/core";
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
import {
PolicyPiiField,
PRESET_PATTERNS,
} from "@app/components/policies/PolicyPiiField";
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
interface PolicyRedactConfigProps {
parameters: Record<string, unknown>;
@@ -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<string, unknown>) =>
onChange({ ...parameters, mode: "automatic", useRegex: true, ...next });
return (
<Stack gap="md">
<PolicyPiiField
parameters={parameters}
onChange={onChange}
disabled={disabled}
/>
<Divider />
<WordsToRedactInput
wordsToRedact={customWords}
onWordsChange={(next) =>
patch({ wordsToRedact: [...presetWords, ...next] })
}
disabled={disabled}
/>
<Divider />
<ColorInput
label="Box colour"
value={redactColor}
onChange={(value) => patch({ redactColor: value })}
disabled={disabled}
size="sm"
format="hex"
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
<NumberInput
label="Custom extra padding"
value={customPadding}
onChange={(value) =>
patch({ customPadding: typeof value === "number" ? value : 0.1 })
}
min={0}
max={10}
step={0.1}
disabled={disabled}
size="sm"
placeholder="0.1"
/>
<Checkbox
label="Whole word search"
checked={wholeWordSearch}
onChange={(e) => patch({ wholeWordSearch: e.currentTarget.checked })}
disabled={disabled}
size="sm"
/>
<Checkbox
label="Convert PDF to PDF-image (removes text behind the box)"
checked={convertPDFToImage}
onChange={(e) => patch({ convertPDFToImage: e.currentTarget.checked })}
disabled={disabled}
size="sm"
/>
</Stack>
<PolicyPiiField
parameters={parameters}
onChange={onChange}
disabled={disabled}
/>
);
}
@@ -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<string[]>(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({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
onBack={onCancel}
/>
@@ -265,6 +263,7 @@ export function PolicySetupWizard({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
subtitle={`Step ${step} of ${TOTAL_STEPS}`}
onBack={back}
@@ -370,21 +369,7 @@ export function PolicySetupWizard({
</div>
<div className="pol-field">
<SettingsRow
label="Output name"
control={
<Input
inputSize="sm"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
placeholder="optional"
aria-label="Output name"
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Name position"
label="Add to filename"
control={
<Select
inputSize="sm"
@@ -394,7 +379,7 @@ export function PolicySetupWizard({
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
aria-label="Name position"
aria-label="Add to filename"
options={[
{ value: "prefix", label: "Prefix" },
{ value: "suffix", label: "Suffix" },
@@ -404,6 +389,20 @@ export function PolicySetupWizard({
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Text to add"
control={
<Input
inputSize="sm"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
placeholder="optional"
aria-label="Text to add"
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Max retries"
@@ -523,53 +522,6 @@ export function PolicySetupWizard({
)}
</>
)}
{step === TOTAL_STEPS && (
<>
<p className="pol-desc">
When Stirling has low confidence in an enforcement action, it will
send the document for human review.
</p>
<p className="pol-section-label">Reviewer</p>
<Card padding="default">
<FormField
label="Send flagged documents to:"
helperText="They'll open flagged documents directly in the Stirling editor."
>
<Input
type="email"
inputSize="sm"
value={reviewerEmail}
onChange={(e) => setReviewerEmail(e.target.value)}
placeholder="[email protected]"
/>
</FormField>
</Card>
<p className="pol-section-label">Summary</p>
<Card padding="default">
<div className="pol-summary-head">
<IconBadge accent="blue" size="sm">
{category.icon}
</IconBadge>
<span className="pol-summary-title">
{category.label} Policy
</span>
</div>
<div className="pol-summary-rows">
<DataRow label="Enforces" align="top">
<ChipFlow items={config.rules} />
</DataRow>
{SOURCES_IN_FLOW && (
<DataRow label="Sources">{sources.length} selected</DataRow>
)}
<DataRow label="Reviewer">
{reviewerEmail || <span className="pol-muted">Not set</span>}
</DataRow>
</div>
</Card>
</>
)}
</div>
<div className="pol-footer">
@@ -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<string, string> = {
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({
<span className="pol-tool-name">
{entry?.name ?? tool.operation}
</span>
{TOOL_PLAIN_INFO[tool.operation] && (
<AppTooltip
content={TOOL_PLAIN_INFO[tool.operation]}
sidebarTooltip
pinOnClick
>
<button
type="button"
className="pol-info-btn"
aria-label={`What does ${entry?.name ?? tool.operation} do?`}
>
<LocalIcon
icon="info-outline-rounded"
width="1rem"
height="1rem"
style={{ color: "var(--icon-files-color)" }}
/>
</button>
</AppTooltip>
)}
<ToggleSwitch
size="sm"
checked={tool.enabled}
@@ -64,8 +96,7 @@ export function PolicyToolConfig({
</div>
{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.
<div className="pol-tool-body">
<PolicyRedactConfig
parameters={tool.parameters}
@@ -73,6 +104,20 @@ export function PolicyToolConfig({
disabled={!editable}
/>
</div>
) : 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.
<div className="pol-tool-body">
<PolicyWatermarkConfig
parameters={tool.parameters}
onChange={(parameters) => patchTool(index, { parameters })}
disabled={!editable}
/>
</div>
) : Settings ? (
<div className="pol-tool-body">
<Suspense fallback={<Loader size="sm" />}>
@@ -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<string, unknown>;
onChange: (parameters: Record<string, unknown>) => 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 (
<AddWatermarkSingleStepSettings
parameters={parameters as unknown as AddWatermarkParameters}
onParameterChange={(key, value) =>
onChange({ ...parameters, [key]: value })
}
disabled={disabled}
showFlatten={false}
/>
);
}
@@ -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;
@@ -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<Set<string>>(new Set());
const importing = useRef<Set<string>>(new Set());
const dispatching = useRef<Set<string>>(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<StirlingFile[]>;
consumeFiles: (
inputFileIds: FileId[],
outputs: StirlingFile[],
stubs: StirlingFileStub[],
) => Promise<unknown>;
/** "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<unknown>,
ctx: ImportContext,
): Promise<void> {
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<void> {
// 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<StirlingFile | null> => {
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);
}
}
@@ -180,27 +180,29 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
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",
@@ -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;
}, []);
@@ -14,9 +14,11 @@ const ACCENT_VAR: Record<string, string> = {
};
/**
* 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<string, FileItemPolicyRef[]> {
const runs = usePolicyRuns();
@@ -28,14 +30,16 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
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;
@@ -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).
@@ -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"
@@ -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,
@@ -125,6 +125,13 @@ export interface PolicyState {
reviewerEmail: string;
/** Saved field values, keyed by field key (overrides the definition default). */
fieldValues: Record<string, boolean | string | string[]>;
/** 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