mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
+4
@@ -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,6 +98,7 @@ const WatermarkFormatting = ({
|
||||
</Group>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showFlatten && (
|
||||
<Checkbox
|
||||
label={t(
|
||||
"watermark.settings.convertToImage",
|
||||
@@ -106,6 +110,7 @@ const WatermarkFormatting = ({
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// 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) ?? [];
|
||||
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(run.fileId, list);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user