;
@@ -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({
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.
+
}>
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