feat(policies): backend-driven policy enforcement (frontend) (#6598)

## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
This commit is contained in:
Reece Browne
2026-06-10 15:57:08 +01:00
committed by GitHub
parent ebc28b0a14
commit 8dde4262ec
91 changed files with 7250 additions and 99 deletions
@@ -0,0 +1,438 @@
/* ============================ Policies ============================ */
/* The Policies surface is docked in the right tool sidebar: a list section */
/* above Tools, a detail takeover that replaces Tools when a policy is open, */
/* and a collapsed-rail of policy icons. */
/* */
/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
/* (@shared/components). The bespoke .pol-* bits below are thin layout */
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
/* scale and colour to the SUI token set so it reads as one product. */
/* ---- List ---- */
.pol-list {
width: 100%;
display: flex;
flex-direction: column;
}
/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
mirroring the back-button + title layout inside an open policy. */
.pol-list-head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
}
.pol-list-head .sui-sectionhdr {
flex: 1;
min-width: 0;
}
/* Small "what is a policy?" info button on the header. */
/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
.pol-info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
transition: opacity var(--motion-fast);
}
.pol-info-btn:hover {
opacity: 0.7;
}
.pol-list-rows {
display: flex;
flex-direction: column;
padding: var(--space-1) var(--space-1_5);
gap: 0.0625rem;
}
/* A policy row: tinted icon tile + label + trailing status/CTA. */
.pol-row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1_5) var(--space-2);
border: none;
border-radius: var(--radius-lg);
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--motion-fast);
}
.pol-row:hover {
background: var(--color-bg-hover);
}
.pol-row:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -2px;
}
.pol-row-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.pol-row-trail {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-left: auto;
}
/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
.pol-row-setup {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-blue);
}
/* Trailing drill-in chevron on each policy row (after the status / CTA). */
.pol-row-chevron {
color: var(--color-text-4);
flex-shrink: 0;
}
/* Retry button on a failed activity row. */
/* Expandable error text in the activity feed — long backend errors are clamped
and collapsed by default so they don't blow up the row. */
.pol-activity-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.pol-activity-error__text {
white-space: pre-wrap;
word-break: break-word;
}
.pol-activity-error__text--clamped {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pol-activity-error__toggle {
border: none;
background: none;
padding: 0;
font-size: inherit;
font-weight: 600;
color: var(--color-blue);
cursor: pointer;
}
.pol-activity-error__toggle:hover {
text-decoration: underline;
}
/* Locked "Coming soon" row — muted, not interactive. */
.pol-row--soon {
opacity: 0.55;
cursor: default;
}
.pol-row--soon:hover {
background: transparent;
}
.pol-row-soon {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-4);
white-space: nowrap;
}
/* In-progress activity icon spins gently. */
.pol-spin {
animation: pol-spin 1.2s linear infinite;
}
@keyframes pol-spin {
to {
transform: rotate(360deg);
}
}
/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
.pol-tool-config {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pol-tool-head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
}
.pol-tool-icon {
display: inline-flex;
align-items: center;
color: var(--color-text-3);
}
.pol-tool-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
margin-right: auto;
}
.pol-tool-body {
padding: 0 var(--space-3) var(--space-3);
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
}
/* ---- Detail container (wizard / narrative / settings share this) ---- */
.pol-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
max-width: 32rem;
width: 100%;
}
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
.pol-steps {
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
/* ---- Scroll body ---- */
.pol-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-3) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pol-desc {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-4);
margin: 0;
}
.pol-section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
/* Bottom gap so the label doesn't hug its card/chips. */
margin: 0 0 var(--space-2);
}
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
.pol-field {
padding: 0.7rem 0.875rem;
background: var(--color-surface);
}
.pol-field:not([data-first]) {
border-top: 1px solid var(--color-border);
}
.pol-field-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.pol-field-count {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.pol-field-chips-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.pol-field-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
}
/* ---- Sources ---- */
.pol-source {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.7rem 0.875rem;
cursor: pointer;
background: var(--color-surface);
}
.pol-source:not([data-first]) {
border-top: 1px solid var(--color-border);
}
/* ---- Doc types ---- */
.pol-link {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-blue);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.pol-doctypes-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0.875rem;
}
.pol-doctypes {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) 0.875rem var(--space-3);
border-top: 1px solid var(--color-border);
}
/* ---- Summary ---- */
.pol-summary-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.pol-summary-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.pol-summary-rows {
display: flex;
flex-direction: column;
gap: var(--space-1_5);
}
/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
.pol-muted {
color: var(--color-text-4);
}
/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
.pol-rule-flow {
margin-bottom: var(--space-2);
}
/* ---- Meta / note ---- */
.pol-meta-row {
display: flex;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
.pol-meta-item {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
font-size: 0.75rem;
color: var(--color-text-4);
}
.pol-note {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-bg-muted);
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ---- Stats: one grouped card with three divided columns. ---- */
.pol-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.pol-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0_5);
padding: var(--space-3) var(--space-2);
text-align: center;
}
.pol-stat:not(:first-child) {
border-left: 1px solid var(--color-border);
}
.pol-stat-value {
font-size: 1rem;
font-weight: 600;
line-height: 1.1;
color: var(--color-text-1);
}
.pol-stat-label {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* ---- Footer (hosts SUI Buttons) ---- */
.pol-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.pol-footer-end {
justify-content: flex-end;
}
/* ---- Detail takeover (fills the rail when a policy is open) ---- */
.pol-takeover {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ---- Collapsed rail policy icons ---- */
.pol-crail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.pol-crail-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition: background var(--motion-fast);
}
.pol-crail-btn:hover {
background: var(--color-blue-light);
}
.pol-crail-btn[data-status="active"] {
color: var(--color-blue);
}
.pol-crail-btn[data-status="paused"] {
color: var(--color-amber);
}
.pol-crail-dot {
position: absolute;
top: 0.1rem;
right: 0.1rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
border: 2px solid var(--bg-toolbar, var(--color-surface));
}
.pol-crail-dot[data-status="active"] {
background: var(--color-green);
}
.pol-crail-dot[data-status="paused"] {
background: var(--color-amber);
}
@@ -0,0 +1,163 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
import { PoliciesSection } from "@app/components/policies/PoliciesSidebar";
import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
import "@app/components/policies/Policies.css";
/**
* The Policies surface lives in the editor's right tool sidebar. These stories
* render the three rich detail surfaces (narrative / setup wizard / settings)
* inside a frame the width of the rail when a policy is open (25rem), so the
* SUI composition can be reviewed in isolation — no app shell, login, or
* backend required. Toggle the Storybook theme switcher to check dark mode.
*/
const RAIL_WIDTH = "25rem";
/** Frame that mimics the right rail's open width + surface so the panel reads true. */
function RailFrame({ children }: { children: React.ReactNode }) {
return (
<div
style={{
width: RAIL_WIDTH,
height: "780px",
display: "flex",
flexDirection: "column",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}
>
{children}
</div>
);
}
const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!;
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
// Illustrative activity/stats for the static stories. In the app these are
// derived live from the user's real uploaded files (see policyLiveData).
const sampleActivity: PolicyActivityItem[] = [
{
doc: "MSA_Acme_2026.pdf",
action: "1.2 MB • enforced on upload",
time: "2h ago",
status: "enforced",
},
{
doc: "scan_002.pdf",
action: "Low confidence • flagged for review",
time: "Yesterday",
status: "flagged",
},
];
const sampleStats: PolicyStats = {
enforced: 1284,
dataProcessed: "3.2 GB",
activeFor: "18d",
};
const noop = () => {};
const meta: Meta = {
title: "Editor/Policies",
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj;
/** Configured policy, running — Enforces / Activity / Stats narrative. */
export const DetailActive: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={ingestion}
config={POLICY_CONFIG.ingestion}
status="active"
steps={POLICY_CONFIG.ingestion.defaultOperations}
activity={[
{
doc: "Q4_Report.pdf",
action: "Enforcing…",
time: "Just now",
status: "processing",
},
...sampleActivity,
]}
stats={sampleStats}
canConfigure
canDelete
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** Configured policy, paused — amber accent + warning badge. */
export const DetailPaused: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={security}
config={POLICY_CONFIG.security}
status="paused"
activity={sampleActivity}
stats={sampleStats}
canConfigure
canDelete
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** Read-only view for a member without configure permission. */
export const DetailManaged: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={ingestion}
config={POLICY_CONFIG.ingestion}
status="active"
activity={sampleActivity}
stats={sampleStats}
canConfigure={false}
canDelete={false}
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** The policy list section (above Tools). */
export const ListSection: Story = {
render: () => (
<div
style={{
width: RAIL_WIDTH,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
padding: "var(--space-2) 0",
}}
>
<PoliciesSection />
</div>
),
};
// Note: the setup + edit wizard now embeds the Watch Folders automation builder
// (its Workflow step), which needs the ToolWorkflow context — so the wizard is
// exercised in-app, not via an isolated story here.
@@ -0,0 +1,152 @@
import "fake-indexeddb/auto";
import type { ReactNode } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// The shared Tooltip (used by the "what is a policy?" info button) pulls in
// preferences/sidebar contexts we don't set up here — passthrough it.
vi.mock("@app/components/shared/Tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => children,
}));
// The wizard's Workflow step embeds the Watch Folders automation builder, which
// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to
// hand back the seed automation, so the wizard's submit still completes.
vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({
AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" },
PolicyWorkflowStep: (props: {
automation: unknown;
saveTriggerRef: { current: (() => void) | null };
onComplete: (automation: unknown, toolRegistry: unknown) => void;
}) => {
props.saveTriggerRef.current = () => props.onComplete(props.automation, {});
return null;
},
}));
// The backend is the source of truth, but these UI tests run offline: list
// rejects (so the mount reconcile keeps the local cache), while save/delete
// resolve so the enable flow completes.
vi.mock("@app/services/policyApi", () => ({
listPolicies: vi.fn().mockRejectedValue(new Error("offline")),
savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({
...p,
id: p.id && p.id.length > 0 ? p.id : "be-test",
})),
getPolicy: vi.fn(),
deletePolicy: vi.fn().mockResolvedValue(undefined),
runStoredPolicy: vi.fn(),
runPolicyPipeline: vi.fn(),
getPolicyRun: vi.fn(),
}));
// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB);
// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids.
if (typeof globalThis.crypto?.randomUUID !== "function") {
const orig = globalThis.crypto;
vi.stubGlobal("crypto", {
getRandomValues: orig?.getRandomValues?.bind(orig),
randomUUID: () =>
`p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
});
}
import {
PoliciesSection,
PolicyDetailTakeover,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { resetPolicySelection } from "@app/components/policies/policySelectionStore";
/**
* Mirrors how RightSidebar swaps the policy list for the detail takeover: the
* list shows above Tools when nothing is open, the takeover replaces it when a
* policy is selected.
*/
function PoliciesHost() {
const active = usePolicyDetailActive();
return active ? <PolicyDetailTakeover /> : <PoliciesSection />;
}
function renderHost() {
return render(
<MantineProvider>
<PoliciesHost />
</MantineProvider>,
);
}
describe("Policies right-sidebar surface", () => {
beforeEach(() => {
localStorage.clear();
// Seed a configured Security policy (the only live category) in the local
// cache. The backend list is mocked to reject (offline), so the mount
// reconcile leaves it in place — giving the narrative tests a policy to open.
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
security: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
},
}),
);
resetPolicySelection();
});
it("renders the policy list with every category", () => {
renderHost();
expect(screen.getByText("Policies")).toBeInTheDocument();
for (const label of [
"Ingestion",
"Security",
"Compliance",
"Routing",
"Retention",
]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("shows Security as active and the unbuilt categories as Coming soon", () => {
renderHost();
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
// Ingestion, Compliance, Routing, Retention are locked for this release.
expect(screen.getAllByText("Coming soon")).toHaveLength(4);
});
it("does not open a Coming soon policy when its row is clicked", () => {
renderHost();
fireEvent.click(screen.getByText("Ingestion"));
// The locked row isn't a button — we stay on the list, nothing opens.
expect(screen.getByText("Policies")).toBeInTheDocument();
expect(screen.queryByText("Enforces")).not.toBeInTheDocument();
});
it("opens the narrative view when a live policy is clicked", () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Enforces")).toBeInTheDocument();
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
});
it("shows an honest empty activity feed when no files have been uploaded", async () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
// Activity is derived from real uploads; with none, the empty state shows.
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
});
it("returns to the list via the back button", () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Enforces")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Back"));
expect(screen.getByText("Policies")).toBeInTheDocument();
});
});
@@ -0,0 +1,453 @@
/**
* Proprietary implementation of the right-sidebar Policies surface.
*
* Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx}
* via the {@code @app/*} alias cascade. Three slots, all driven by the shared
* {@link policySelectionStore} so they stay in sync:
* • {@link PoliciesSection} — the collapsible policy list, rendered above the
* Tools section in {@code RightSidebar} when no policy is open.
* • {@link PolicyDetailTakeover} — the detail / wizard / settings view, which
* replaces the Tools area when a policy is open.
* • {@link PoliciesCollapsedButton} — the icon rail shown when the sidebar is
* collapsed; clicking an icon selects the policy and expands the rail.
*/
import { useState, useEffect, useMemo, type ReactNode } from "react";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LocalIcon from "@app/components/shared/LocalIcon";
import { usePolicies } from "@app/hooks/usePolicies";
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
import { getPolicyAutomation } from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { runsToActivity, runsToStats } from "@app/services/policyLiveData";
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
import type { FileId } from "@app/types/file";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { WatchedFolder } from "@app/types/watchedFolders";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { IconBadge } from "@shared/components/IconBadge";
import {
deriveRowStatus,
STATUS_LABEL,
ROW_ACCENT,
} from "@app/components/policies/policyStatus";
import { StatusBadge } from "@shared/components/StatusBadge";
import { SectionHeader } from "@shared/components/SectionHeader";
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
import type { PolicyConfigResult } from "@app/types/policies";
import {
usePolicySelection,
selectPolicy,
setPolicyDetailView,
closePolicy,
} from "@app/components/policies/policySelectionStore";
import "@app/components/policies/Policies.css";
/** localStorage key persisting the Policies section's expand/collapse state. */
const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed";
/** Whether the right rail should host the Policies section. True in proprietary. */
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED;
}
/**
* Whether a policy is open — i.e. its detail view should take over the rail in
* place of the tool list. False when the feature is off or nothing is selected.
*/
export function usePolicyDetailActive(): boolean {
const { selectedId } = usePolicySelection();
return POLICIES_ENABLED && selectedId != null;
}
/** The collapsible policy list, rendered above the Tools section. */
export function PoliciesSection({
leadingControl,
}: {
/** Optional control rendered to the left of the header (e.g. the sidebar
* collapse button), mirroring the back-button + title in a policy. */
leadingControl?: ReactNode;
} = {}) {
const pol = usePolicies();
const { categories } = usePolicyCatalog();
// Persist the expand/collapse state across refreshes.
const [expanded, setExpanded] = useState(() => {
try {
return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1";
} catch {
return true;
}
});
const toggleExpanded = () =>
setExpanded((open) => {
const next = !open;
try {
localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1");
} catch {
// Best-effort; ignore quota/availability failures.
}
return next;
});
if (!POLICIES_ENABLED) return null;
// The header tally counts every CONFIGURED policy (active + paused), not just
// the active ones.
const configuredCount = categories.filter(
(c) => pol.policies[c.id]?.configured,
).length;
return (
<div className="pol-list">
<div className="pol-list-head">
{leadingControl}
<SectionHeader
title="Policies"
count={`${configuredCount} active`}
collapsible
expanded={expanded}
onToggle={toggleExpanded}
/>
<AppTooltip
content="A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
sidebarTooltip
pinOnClick
>
<button
type="button"
className="pol-info-btn"
aria-label="What is a policy?"
>
<LocalIcon
icon="info-outline-rounded"
width="1.25rem"
height="1.25rem"
style={{ color: "var(--icon-files-color)" }}
/>
</button>
</AppTooltip>
</div>
{expanded && (
<>
<div className="pol-list-rows">
{categories.map((cat) => {
if (cat.comingSoon) {
return (
<div
key={cat.id}
className="pol-row pol-row--soon"
aria-disabled="true"
>
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
<span className="pol-row-label">{cat.label}</span>
<span className="pol-row-trail">
<span className="pol-row-soon">Coming soon</span>
</span>
</div>
);
}
const status = deriveRowStatus(pol.policies[cat.id]);
return (
<button
key={cat.id}
type="button"
className="pol-row"
onClick={() => selectPolicy(cat.id)}
>
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
<span className="pol-row-label">{cat.label}</span>
<span className="pol-row-trail">
{status === "setup" ? (
<span className="pol-row-setup">Set up</span>
) : (
<StatusBadge
tone={status === "active" ? "success" : "warning"}
size="sm"
>
{STATUS_LABEL[status]}
</StatusBadge>
)}
<ChevronRightIcon
className="pol-row-chevron"
sx={{ fontSize: "1rem" }}
/>
</span>
</button>
);
})}
</div>
</>
)}
</div>
);
}
/**
* The open-policy view — narrative detail, setup wizard, or edit-settings —
* which replaces the Tools area while a policy is selected.
*/
export function PolicyDetailTakeover() {
const pol = usePolicies();
const { categories, configs, sources, docTypes } = usePolicyCatalog();
const { selectedId, detailView } = usePolicySelection();
// The configured policy's backing folder + automation (its real, editable
// pipeline). `reloadKey` bumps after the edit modal saves so the detail
// reflects the new steps. Falls back to the preset's rules when unconfigured.
const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined;
const [steps, setSteps] = useState<AutomationOperation[]>([]);
const [backingFolder, setBackingFolder] = useState<WatchedFolder | null>(
null,
);
const [backingAutomation, setBackingAutomation] =
useState<AutomationConfig | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [confirmingDelete, setConfirmingDelete] = useState(false);
useEffect(() => {
if (!folderId) {
setSteps([]);
setBackingFolder(null);
setBackingAutomation(null);
return;
}
let cancelled = false;
void (async () => {
const [folder, automation] = await Promise.all([
watchedFolderStorage.getFolder(folderId),
getPolicyAutomation(folderId),
]);
if (cancelled) return;
setBackingFolder(folder);
setBackingAutomation(automation);
setSteps(automation?.operations ?? []);
})();
return () => {
cancelled = true;
};
}, [folderId, reloadKey]);
// Activity/stats come from the real backend runs the auto-run controller fires
// on every upload (policyRunStore), filtered to this policy's category. The
// store is reactive, so the feed updates live as runs progress — no polling
// here (the controller does the run-status polling).
const allRuns = usePolicyRuns();
const categoryRuns = useMemo(
() => allRuns.filter((r) => r.categoryId === selectedId),
[allRuns, selectedId],
);
if (!POLICIES_ENABLED || selectedId == null) return null;
const category = categories.find((c) => c.id === selectedId);
const state = pol.policies[selectedId];
const config = configs[selectedId];
if (!category || !state || !config) return null;
// Coming-soon categories can't be opened (the list row is locked anyway).
if (category.comingSoon) return null;
const status = deriveRowStatus(state);
const onSetupClassification = () => {
const classifier = categories.find((c) => c.providesClassification);
if (classifier) selectPolicy(classifier.id);
};
// Preset (tool-chain) policies configure via the wizard's locked tool-config
// step instead of the add/remove builder; the wizard fires onCommitConfig for
// them (and onComplete for builder-based categories). One commit path serves
// both first-time configure and edits.
const commitConfig = (result: PolicyConfigResult) =>
pol.commitPolicyConfig(selectedId, result).then(() => {
setReloadKey((k) => k + 1);
setPolicyDetailView("detail");
});
// Setup: the shared wizard in create mode.
if (!state.configured) {
return (
<PolicySetupWizard
key={selectedId}
category={category}
config={config}
initial={state}
sources={sources}
docTypes={docTypes}
canConfigure={pol.canConfigure}
// No standalone classification policy exists yet to enable doc-type
// narrowing, so it stays off for this release.
classificationEnabled={false}
mode="create"
onCancel={() => closePolicy()}
onComplete={(result) =>
pol
.enablePolicy(selectedId, result)
.then(() => setPolicyDetailView("detail"))
}
onCommitConfig={commitConfig}
onSetupClassification={onSetupClassification}
/>
);
}
// Edit: the same wizard in edit mode, pre-filled — so editing has the settings
// steps (not just the workflow). Wait for the backing automation to load so
// the workflow step edits the real pipeline.
if (detailView === "settings" && pol.canConfigure) {
if (!backingAutomation) {
return (
<div className="pol-detail">
<div className="pol-scroll">
<p className="pol-desc">Loading</p>
</div>
</div>
);
}
return (
<PolicySetupWizard
key={`edit-${selectedId}`}
category={category}
config={config}
initial={state}
sources={sources}
docTypes={docTypes}
canConfigure={pol.canConfigure}
classificationEnabled={false}
mode="edit"
existingAutomation={backingAutomation}
initialFolder={backingFolder ?? undefined}
onCancel={() => setPolicyDetailView("detail")}
onComplete={(result) =>
pol.savePolicyConfig(selectedId, result).then(() => {
setReloadKey((k) => k + 1);
setPolicyDetailView("detail");
})
}
onCommitConfig={commitConfig}
onSetupClassification={onSetupClassification}
/>
);
}
return (
<>
<PolicyDetailPanel
category={category}
config={config}
status={status}
steps={steps}
activity={runsToActivity(categoryRuns)}
stats={runsToStats(categoryRuns, backingFolder?.createdAt)}
canConfigure={pol.canConfigure}
canDelete={!state.isDefault}
onBack={() => closePolicy()}
onEditSettings={() => {
// Seeded/active policies may have no backing folder yet — create one
// from the preset so there's a workflow to edit, then open settings.
void pol
.ensurePolicyFolder(selectedId)
.then(() => setPolicyDetailView("settings"));
}}
onTogglePause={() =>
status === "paused"
? pol.resumePolicy(selectedId)
: pol.pausePolicy(selectedId)
}
onDelete={() => setConfirmingDelete(true)}
onRetry={(item) => {
if (item.fileId && state.backendId) {
void runPolicyOnFile(
selectedId,
state.backendId,
item.fileId as FileId,
item.doc,
);
}
}}
/>
{confirmingDelete && (
<PolicyDeleteConfirmModal
opened
label={category.label}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => {
setConfirmingDelete(false);
closePolicy();
void pol.deletePolicy(selectedId);
}}
/>
)}
</>
);
}
/**
* Collapsed-rail policy icons. Each tints blue when active and carries a small
* status dot (green active / amber paused). Clicking selects the policy and
* expands the rail.
*/
export function PoliciesCollapsedButton({
onExpand,
}: {
onExpand: () => void;
}) {
const pol = usePolicies();
const { categories } = usePolicyCatalog();
if (!POLICIES_ENABLED) return null;
return (
<>
<div className="pol-crail">
{categories
.filter((cat) => !cat.comingSoon)
.map((cat) => {
const status = deriveRowStatus(pol.policies[cat.id]);
const suffix =
status === "active"
? " (Active)"
: status === "paused"
? " (Paused)"
: "";
return (
<AppTooltip
key={cat.id}
content={`${cat.label}${suffix}`}
position="left"
arrow
delay={300}
>
<button
type="button"
className="pol-crail-btn"
data-status={status}
aria-label={`${cat.label} policy — ${STATUS_LABEL[status]}`}
onClick={() => {
selectPolicy(cat.id);
onExpand();
}}
>
{cat.icon}
{(status === "active" || status === "paused") && (
<span className="pol-crail-dot" data-status={status} />
)}
</button>
</AppTooltip>
);
})}
</div>
<div className="tool-panel__collapsed-divider" />
</>
);
}
@@ -0,0 +1,11 @@
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
/**
* Headless controller that drives policy auto-run (enforce every enabled policy
* on every uploaded file). Mounted once wherever the editor is open so runs fire
* regardless of whether the policy panel is visible. Renders nothing.
*/
export function PolicyAutoRunController() {
usePolicyAutoRun();
return null;
}
@@ -0,0 +1,46 @@
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface PolicyDeleteConfirmModalProps {
opened: boolean;
/** The policy's display label (the category name). */
label: string;
onConfirm: () => void;
onCancel: () => void;
}
/** Confirm before deleting a policy — removing it discards its backing workflow. */
export function PolicyDeleteConfirmModal({
opened,
label,
onConfirm,
onCancel,
}: PolicyDeleteConfirmModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("policies.deleteConfirmTitle", `Delete ${label} policy?`)}
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
{t(
"policies.deleteConfirmBody",
"This removes the policy and its workflow. Documents already processed are not affected.",
)}
</Text>
<Group gap="sm" justify="flex-end">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" size="sm" onClick={onConfirm}>
{t("delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,279 @@
import { useState } from "react";
import PublicIcon from "@mui/icons-material/Public";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HistoryIcon from "@mui/icons-material/History";
import DescriptionIcon from "@mui/icons-material/Description";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
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 { Card } from "@shared/components/Card";
import { ChipFlow } from "@shared/components/ChipFlow";
import { StatusBadge } from "@shared/components/StatusBadge";
import { EmptyState } from "@shared/components/EmptyState";
import { Button } from "@shared/components/Button";
import { Banner } from "@shared/components/Banner";
import { ListRow } from "@shared/components/ListRow";
import type {
PolicyActivityItem,
PolicyCategory,
PolicyConfigDef,
PolicyRowStatus,
PolicyStats,
} from "@app/types/policies";
import type { AutomationOperation } from "@app/types/automation";
interface PolicyDetailPanelProps {
category: PolicyCategory;
config: PolicyConfigDef;
/** Derived display status. */
status: PolicyRowStatus;
/**
* The policy's real configured steps (from its backing automation). When
* present these drive the Enforces flow; otherwise the preset's decorative
* `rules` are shown (e.g. before configuration).
*/
steps?: AutomationOperation[];
/** Activity feed derived from the user's files; empty until files exist. */
activity?: PolicyActivityItem[];
/** Summary stats derived from the user's files. */
stats?: PolicyStats;
canConfigure: boolean;
/** Default (built-in) policies aren't deletable, so the Delete action hides. */
canDelete: boolean;
onBack: () => void;
onEditSettings: () => void;
onTogglePause: () => void;
onDelete: () => void;
/** Re-run a failed activity item's policy on its file. */
onRetry?: (item: PolicyActivityItem) => void;
}
/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */
function humanizeOperation(op: string): string {
return op
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.trim();
}
/**
* A failed run's error in the activity feed. Backend errors can be long (or
* multi-line stack traces) and would otherwise blow up the row, so anything
* lengthy is clamped and collapsed by default with a Show more/less toggle.
* Short messages (e.g. "Enforcement failed") render plainly with no toggle.
*/
function ActivityError({ message }: { message: string }) {
const [expanded, setExpanded] = useState(false);
const needsToggle = message.length > 80 || message.includes("\n");
if (!needsToggle) return <>{message}</>;
return (
<span className="pol-activity-error">
<span
className={`pol-activity-error__text${expanded ? "" : " pol-activity-error__text--clamped"}`}
>
{message}
</span>
<button
type="button"
className="pol-activity-error__toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "Show less" : "Show more"}
</button>
</span>
);
}
/** Narrative view for a configured policy (Enforces / Activity / Stats). */
export function PolicyDetailPanel({
category,
config,
status,
steps,
activity,
stats,
canConfigure,
canDelete,
onBack,
onEditSettings,
onTogglePause,
onDelete,
onRetry,
}: PolicyDetailPanelProps) {
const isPaused = status === "paused";
// Real configured steps drive the flow; fall back to the preset's rule labels.
const enforceItems =
steps && steps.length > 0
? steps.map((s) => humanizeOperation(s.operation))
: config.rules;
// Activity + stats are derived from the user's real files; until they load (or
// if none exist) show an honest empty feed / zeroed stats.
const activityItems = activity ?? [];
const statValues = stats ?? {
enforced: 0,
dataProcessed: "0 B",
activeFor: "—",
};
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
title={category.label}
onBack={onBack}
actions={
<StatusBadge
tone={isPaused ? "warning" : "success"}
showDot
pulse={!isPaused}
>
{isPaused ? "Paused" : "Active"}
</StatusBadge>
}
/>
<div className="pol-scroll">
{/* Enforces */}
<div>
<p className="pol-section-label">Enforces</p>
<Card padding="default">
<div className="pol-rule-flow">
<ChipFlow items={enforceItems} separator="arrow" />
</div>
<div className="pol-meta-row">
<span className="pol-meta-item">
<PublicIcon sx={{ fontSize: "0.8rem" }} />
{config.scopeLabel}
</span>
<span className="pol-meta-item">
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
On every upload
</span>
</div>
<div className="pol-note">
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
Originals stay untouched Enforced version saved alongside
</div>
</Card>
</div>
{/* Recent Activity */}
<div>
<p className="pol-section-label">Recent Activity</p>
{activityItems.length > 0 ? (
<Card padding="none">
{activityItems.map((item, i) => (
<ListRow
key={item.runId ?? `${item.doc}-${item.time}`}
divider={i > 0}
leadingTone={
item.status === "flagged"
? "warning"
: item.status === "processing"
? "info"
: "success"
}
leading={
item.status === "flagged" ? (
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
) : item.status === "processing" ? (
<AutorenewIcon
className="pol-spin"
sx={{ fontSize: "0.85rem" }}
/>
) : (
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
)
}
title={item.doc}
description={
item.status === "flagged" ? (
<ActivityError message={item.action} />
) : (
item.action
)
}
meta={item.time}
trailing={
item.status === "flagged" && onRetry ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRetry(item)}
>
Retry
</Button>
) : undefined
}
/>
))}
</Card>
) : (
<Card padding="default">
<EmptyState
size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title="No activity yet"
description="Documents will appear here once this policy runs."
/>
</Card>
)}
</div>
{/* Stats — one grouped card with divided columns, intentionally
unlabelled for a quiet summary footer. */}
<Card padding="none">
<div className="pol-stats">
<div className="pol-stat">
<span className="pol-stat-value">
{statValues.enforced.toLocaleString()}
</span>
<span className="pol-stat-label">Docs enforced</span>
</div>
<div className="pol-stat">
<span className="pol-stat-value">{statValues.dataProcessed}</span>
<span className="pol-stat-label">Data processed</span>
</div>
<div className="pol-stat">
<span className="pol-stat-value">{statValues.activeFor}</span>
<span className="pol-stat-label">Active</span>
</div>
</div>
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
description="Managed by your organization. Contact an admin to change settings."
/>
)}
</div>
{canConfigure && (
<div className={`pol-footer${canDelete ? "" : " pol-footer-end"}`}>
{canDelete && (
<Button
variant="ghost"
accent="red"
size="sm"
leadingIcon={<DeleteOutlineIcon sx={{ fontSize: "0.9rem" }} />}
onClick={onDelete}
style={{ marginRight: "auto" }}
>
Delete
</Button>
)}
<Button variant="outline" size="sm" onClick={onTogglePause}>
{isPaused ? "Resume" : "Pause"}
</Button>
<Button variant="gradient" size="sm" onClick={onEditSettings}>
Edit Settings
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Select } from "@shared/components/Select";
import { Input } from "@shared/components/Input";
import { Chip } from "@shared/components/Chip";
import { SettingsRow } from "@shared/components/SettingsRow";
import type { PolicyField } from "@app/types/policies";
interface PolicyFieldRowProps {
field: PolicyField;
/** Effective current value (override or definition default). */
value: boolean | string | string[];
onChange: (value: boolean | string | string[]) => void;
/** First row in a group omits the top divider. */
first?: boolean;
}
/**
* Renders one policy setting: toggle, select, multi-select chips, or text.
* Controlled — the parent owns the value. Uses SUI controls (ToggleSwitch /
* Select / Input / Chip) so it matches the rest of the policy surface.
*/
export function PolicyFieldRow({
field,
value,
onChange,
first,
}: PolicyFieldRowProps) {
if (field.type === "chips") {
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) =>
onChange(
selected.includes(opt)
? selected.filter((o) => o !== opt)
: [...selected, opt],
);
return (
<div className="pol-field" data-first={first || undefined}>
<div className="pol-field-chips-head">
<span className="pol-field-label">{field.label}</span>
<span className="pol-field-count">{selected.length} selected</span>
</div>
<div className="pol-field-chips">
{(field.options ?? []).map((opt) => (
<Chip
key={opt}
tone={selected.includes(opt) ? "blue" : "neutral"}
size="sm"
onClick={() => toggle(opt)}
>
{opt}
</Chip>
))}
</div>
</div>
);
}
const control =
field.type === "toggle" ? (
<ToggleSwitch
size="sm"
checked={Boolean(value)}
onChange={(checked) => onChange(checked)}
aria-label={field.label}
/>
) : field.type === "select" ? (
<Select
inputSize="sm"
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
/>
) : (
<Input
inputSize="sm"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
/>
);
return (
<div className="pol-field" data-first={first || undefined}>
<SettingsRow label={field.label} control={control} />
</div>
);
}
@@ -0,0 +1,61 @@
import { MultiSelect } from "@mantine/core";
import { PII_PRESETS } from "@app/data/policyDefinitions";
/** The set of preset regexes — used to separate preset words from custom ones. */
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
interface PolicyPiiFieldProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
disabled?: boolean;
}
/**
* The redact step's PII preset picker — a dropdown of common PII types that
* writes the matching regexes into `wordsToRedact` (automatic + regex). It only
* manages the preset entries; any custom patterns the user added separately are
* left untouched, so this sits alongside the custom-entry field rather than
* owning the whole list.
*/
export function PolicyPiiField({
parameters,
onChange,
disabled,
}: PolicyPiiFieldProps) {
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
const selected = words
.map((w) => VALUE_BY_PATTERN.get(w))
.filter((v): v is string => Boolean(v));
const handleChange = (values: string[]) => {
const presetPatterns = values
.map((v) => PATTERN_BY_VALUE.get(v))
.filter((p): p is string => Boolean(p));
// Keep the user's custom patterns; only swap the preset selection.
const customWords = words.filter((w) => !PRESET_PATTERNS.has(w));
onChange({
...parameters,
mode: "automatic",
useRegex: true,
wordsToRedact: [...presetPatterns, ...customWords],
});
};
return (
<MultiSelect
size="sm"
label="PII to redact"
placeholder="Select PII types"
data={PII_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
value={selected}
onChange={handleChange}
disabled={disabled}
clearable
checkIconPosition="right"
/>
);
}
@@ -0,0 +1,128 @@
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";
interface PolicyRedactConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
disabled?: boolean;
}
/**
* 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.
*/
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).
useEffect(() => {
if (parameters.useRegex !== true) {
onChange({ ...parameters, useRegex: 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>
);
}
@@ -0,0 +1,602 @@
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 { 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,
PolicyConfigResult,
PolicySource,
PolicyState,
PolicyWizardResult,
} from "@app/types/policies";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WatchedFolder } from "@app/types/watchedFolders";
import { buildPipelineDefinition } from "@app/services/policyPipeline";
import { useAuth } from "@app/auth/UseSession";
import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow";
import { resolveFieldValues } from "@app/components/policies/policyValues";
import {
PolicyWorkflowStep,
AutomationMode,
} from "@app/components/policies/PolicyWorkflowStep";
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
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;
interface PolicySetupWizardProps {
category: PolicyCategory;
config: PolicyConfigDef;
initial: PolicyState;
/** Sources a policy can run over (catalog-supplied). */
sources: PolicySource[];
/** Document types scope can be narrowed to (catalog-supplied). */
docTypes: string[];
canConfigure: boolean;
/** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */
classificationEnabled: boolean;
/** "create" seeds the workflow from the preset; "edit" loads the backing automation. */
mode?: "create" | "edit";
/** The backing automation to edit (edit mode). */
existingAutomation?: AutomationConfig;
/** The backing folder, to pre-fill output + retry settings (edit mode). */
initialFolder?: WatchedFolder;
onCancel: () => void;
/**
* Fires on submit with the saved workflow + collected settings. May be async;
* if the returned promise rejects, the wizard re-enables submit and surfaces
* the failure rather than hanging on a permanently-disabled button.
*/
onComplete: (result: PolicyWizardResult) => void | Promise<void>;
/**
* For preset (tool-chain) policies whose Workflow step is the locked tool
* config: fires instead of `onComplete`, carrying the configured tools as
* operations + mapped pipeline steps. When absent the wizard uses the
* add/remove builder + `onComplete`.
*/
onCommitConfig?: (result: PolicyConfigResult) => void | Promise<void>;
onSetupClassification: () => void;
}
/**
* 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.
*/
export function PolicySetupWizard({
category,
config,
initial,
sources: sourceDefs,
docTypes,
canConfigure,
classificationEnabled,
mode = "create",
existingAutomation,
initialFolder,
onCancel,
onComplete,
onCommitConfig,
onSetupClassification,
}: PolicySetupWizardProps) {
const isEdit = mode === "edit";
// Preset (tool-chain) policies render the locked tool config as their Workflow
// step instead of the add/remove builder.
const toolChain = getPolicyToolChain(category.id);
const { user } = useAuth();
const [step, setStep] = useState(1);
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(config, initial),
);
const [sources, setSources] = useState<string[]>(
initial.sources.length ? initial.sources : ["editor"],
);
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 || "",
);
// 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",
);
const [outputName, setOutputName] = useState(initialFolder?.outputName ?? "");
const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number"
>(initialFolder?.outputNamePosition ?? "prefix");
const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3);
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
initialFolder?.retryDelayMinutes ?? 5,
);
const workflowSave = useRef<(() => void) | null>(null);
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Seed the workflow builder: the backing automation in edit, else a synthetic
// config carrying the category preset's operations (created on save).
const seedAutomation = useMemo<AutomationConfig>(
() =>
existingAutomation ?? {
id: "",
name: `${category.label} Policy`,
description: `${category.label} policy workflow`,
icon: "WorkIcon",
operations: config.defaultOperations,
createdAt: "",
updatedAt: "",
},
[existingAutomation, category.label, config.defaultOperations],
);
if (!canConfigure) {
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
onBack={onCancel}
/>
<div className="pol-scroll">
<EmptyState
title="Managed by your organization"
description="Contact an admin to change this policy."
/>
</div>
</div>
);
}
const back = () =>
step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel();
const toggleSource = (id: string) =>
setSources((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
);
// Once the builder persists the workflow, map its operations to backend
// endpoint paths (the registry only lives in the Workflow step) and hand the
// automation + built steps + settings to the host (which closes the wizard on
// success). If the host's async save rejects, recover so the submit button
// doesn't stay disabled forever.
const handleWorkflowSaved = (
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
) => {
const { definition, unresolved } = buildPipelineDefinition(
automation,
toolRegistry,
);
Promise.resolve(
onComplete({
automation,
fieldValues,
sources,
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
outputMode,
outputName: outputName.trim(),
outputNamePosition,
maxRetries,
retryDelayMinutes,
},
pipelineSteps: definition.steps,
unresolvedOps: unresolved,
}),
).catch(() => {
setSubmitting(false);
setSaveError("Couldn't save the policy. Please try again.");
});
};
// Tool-chain policies: the locked config step emits its enabled tools as
// operations + mapped steps; hand them to the host's commit path.
const handleToolConfigSaved = (
operations: AutomationOperation[],
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
unresolvedOps: string[],
) => {
Promise.resolve(
onCommitConfig?.({
operations,
pipelineSteps,
unresolvedOps,
fieldValues,
sources,
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
outputMode,
outputName: outputName.trim(),
outputNamePosition,
maxRetries,
retryDelayMinutes,
},
}),
).catch(() => {
setSubmitting(false);
setSaveError("Couldn't save the policy. Please try again.");
});
};
// Final submit: guard against double-submit (the step stays mounted, so a
// second click would persist twice), then trigger the step's save.
const submit = () => {
if (submitting) return;
setSaveError(null);
setSubmitting(true);
workflowSave.current?.();
};
// The builder couldn't save (e.g. no configured tools) — surface it and send
// the user back to the Workflow step to fix it.
const handleSaveFailed = () => {
setSubmitting(false);
setSaveError("Add at least one configured tool to the workflow first.");
setStep(1);
};
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
subtitle={`Step ${step} of ${TOTAL_STEPS}`}
onBack={back}
actions={
<Button
variant="ghost"
size="sm"
aria-label="Cancel"
onClick={onCancel}
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
/>
}
/>
<div className="pol-steps">
<StepIndicator total={TOTAL_STEPS} current={step} />
</div>
<div className="pol-scroll">
{saveError && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={saveError}
/>
)}
{/* Step 1 — Workflow. Kept mounted (hidden on other steps) so the final
submit can trigger its save. Preset (tool-chain) policies show the
locked, per-tool config; the rest show the add/remove builder. */}
<div style={{ display: step === 1 ? undefined : "none" }}>
{toolChain ? (
<>
<p className="pol-desc">
Configure the tools this policy runs on each document.
</p>
<PolicyToolConfigStep
chainIds={toolChain}
initialOperations={
existingAutomation?.operations ?? config.defaultOperations
}
presetOperations={config.defaultOperations}
categoryLabel={category.label}
saveTriggerRef={workflowSave}
onComplete={handleToolConfigSaved}
/>
</>
) : (
<>
<p className="pol-desc">
Build the sequence of tools this policy runs on each document.
</p>
<PolicyWorkflowStep
automation={seedAutomation}
mode={isEdit ? AutomationMode.EDIT : AutomationMode.SUGGESTED}
saveTriggerRef={workflowSave}
onComplete={handleWorkflowSaved}
onSaveFailed={handleSaveFailed}
/>
</>
)}
</div>
{step === 2 && (
<>
<p className="pol-desc">{category.desc}</p>
<Card padding="none">
{config.fields.map((f, i) => (
<PolicyFieldRow
key={f.key}
field={f}
value={fieldValues[f.key]}
first={i === 0}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
}
/>
))}
</Card>
{/* Real, working output + retry settings (applied by the engine). */}
<p className="pol-section-label">Output &amp; retries</p>
<Card padding="none">
<div className="pol-field" data-first>
<SettingsRow
label="Output"
control={
<Select
inputSize="sm"
value={outputMode}
onChange={(e) =>
setOutputMode(
e.target.value as "new_file" | "new_version",
)
}
aria-label="Output mode"
options={[
{ value: "new_file", label: "New file" },
{ value: "new_version", label: "New version" },
]}
/>
}
/>
</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"
control={
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
aria-label="Name position"
options={[
{ value: "prefix", label: "Prefix" },
{ value: "suffix", label: "Suffix" },
{ value: "auto-number", label: "Auto-number" },
]}
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Max retries"
control={
<Input
type="number"
inputSize="sm"
value={String(maxRetries)}
onChange={(e) =>
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
}
aria-label="Max retries"
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Retry delay (min)"
control={
<Input
type="number"
inputSize="sm"
value={String(retryDelayMinutes)}
onChange={(e) =>
setRetryDelayMinutes(
Math.max(0, Number(e.target.value) || 0),
)
}
aria-label="Retry delay minutes"
/>
}
/>
</div>
</Card>
</>
)}
{/* Sources step — kept in code, out of the flow for this release
(SOURCES_IN_FLOW), since sources are always "editor" for now. */}
{SOURCES_IN_FLOW && step === 3 && (
<>
<p className="pol-desc">
Choose where this policy runs and which document types it applies
to.
</p>
<p className="pol-section-label">Sources</p>
<Card padding="none">
{sourceDefs.map((src, i) => (
<div
key={src.id}
className="pol-source"
data-first={i === 0 || undefined}
>
<Checkbox
checked={sources.includes(src.id)}
onChange={() => toggleSource(src.id)}
leadingIcon={src.icon}
label={src.label}
description={src.desc}
/>
</div>
))}
</Card>
<p className="pol-section-label">Document types</p>
{!classificationEnabled ? (
<Banner
tone="warning"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
title="All document types"
description="Enable the Classification policy to filter by document type."
action={
<Button
variant="ghost"
size="sm"
onClick={onSetupClassification}
>
Set up Classification
</Button>
}
/>
) : (
<Card padding="none">
<div className="pol-doctypes-head">
<span className="pol-field-label">
{scopeTypes.length === 0
? "All document types"
: `${scopeTypes.length} types selected`}
</span>
<button
className="pol-link"
onClick={() => setScopeNarrow((v) => !v)}
>
{scopeNarrow ? "Clear" : "Edit"}
</button>
</div>
{scopeNarrow && (
<div className="pol-doctypes">
{docTypes.map((dt) => (
<Checkbox
key={dt}
checked={scopeTypes.includes(dt)}
onChange={() =>
setScopeTypes((prev) =>
prev.includes(dt)
? prev.filter((d) => d !== dt)
: [...prev, dt],
)
}
label={dt}
/>
))}
</div>
)}
</Card>
)}
</>
)}
{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">
<Button variant="ghost" size="sm" onClick={back}>
{step > 1 ? "Back" : "Cancel"}
</Button>
{step < TOTAL_STEPS ? (
<Button
variant="gradient"
size="sm"
style={{ marginLeft: "auto" }}
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
>
Continue
</Button>
) : (
<Button
variant="gradient"
size="sm"
style={{ marginLeft: "auto" }}
onClick={submit}
disabled={submitting}
>
{isEdit ? "Save Changes" : "Enable Policy"}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import { Suspense } from "react";
import { Loader } from "@mantine/core";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Card } from "@shared/components/Card";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { ToolId } from "@app/types/toolId";
/** 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). */
operation: string;
/** Whether this tool runs as part of the policy (the per-tool on/off). */
enabled: boolean;
/** Tool-specific parameters (the shape its endpoint accepts). */
parameters: Record<string, unknown>;
}
interface PolicyToolConfigProps {
/** The policy's fixed tool chain — locked (no add/remove), only configurable. */
tools: PolicyToolState[];
toolRegistry: Partial<ToolRegistry>;
onChange: (tools: PolicyToolState[]) => void;
/** Read-only when the policy is managed / the user can't configure. */
editable?: boolean;
}
/**
* Locked, configure-only tool panel for a policy. The chain is fixed (you can't
* add or remove tools); each tool is a section that renders its OWN settings form
* from the tool registry (`automationSettings`) — the same forms the automation
* builder uses — so the config is generated from the tools in the workflow, not
* hardcoded per policy. The parameters produced here are exactly what the backend
* engine POSTs to each tool's endpoint.
*/
export function PolicyToolConfig({
tools,
toolRegistry,
onChange,
editable = true,
}: PolicyToolConfigProps) {
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
return (
<div className="pol-tool-config">
{tools.map((tool, index) => {
const entry = toolRegistry[tool.operation as ToolId];
const Settings = entry?.automationSettings ?? null;
return (
<Card key={tool.operation} padding="none">
<div className="pol-tool-head">
<span className="pol-tool-icon">{entry?.icon}</span>
<span className="pol-tool-name">
{entry?.name ?? tool.operation}
</span>
<ToggleSwitch
size="sm"
checked={tool.enabled}
disabled={!editable}
onChange={(checked) => patchTool(index, { enabled: checked })}
aria-label={`Enable ${entry?.name ?? tool.operation}`}
/>
</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.
<div className="pol-tool-body">
<PolicyRedactConfig
parameters={tool.parameters}
onChange={(parameters) => patchTool(index, { parameters })}
disabled={!editable}
/>
</div>
) : Settings ? (
<div className="pol-tool-body">
<Suspense fallback={<Loader size="sm" />}>
<Settings
parameters={tool.parameters}
onParameterChange={(key: string, value: unknown) =>
patchTool(index, {
parameters: { ...tool.parameters, [key]: value },
})
}
disabled={!editable}
/>
</Suspense>
</div>
) : null)}
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,114 @@
import { useState, useEffect, type MutableRefObject } from "react";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { buildPipelineDefinition } from "@app/services/policyPipeline";
import {
PolicyToolConfig,
type PolicyToolState,
} from "@app/components/policies/PolicyToolConfig";
import type { ToolId } from "@app/types/toolId";
import type { AutomationOperation } from "@app/types/automation";
/**
* Seed a tool's parameters: start from the tool's own registry defaults, overlay
* the preset's defaults (e.g. the PII patterns), then apply only the saved
* values the user actually changed from the tool default. This means a policy
* saved while a param was at its default (an empty redact list) still inherits
* the preset value, while genuine user edits are preserved.
*/
function seedToolParameters(
registryDefaults: Record<string, unknown>,
presetParams: Record<string, unknown>,
savedParams: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = {
...registryDefaults,
...presetParams,
};
const eq = (a: unknown, b: unknown) =>
JSON.stringify(a) === JSON.stringify(b);
for (const [key, value] of Object.entries(savedParams)) {
if (!eq(value, registryDefaults[key])) merged[key] = value;
}
return merged;
}
interface PolicyToolConfigStepProps {
/** The fixed, configurable tool chain (locked set) for this policy. */
chainIds: string[];
/** Operations to seed enabled/params from (saved ops, or preset defaults). */
initialOperations: AutomationOperation[];
/**
* The preset's default operations. Their params seed any tool whose saved
* value is still at the tool's own default — so e.g. a policy saved before the
* PII patterns existed inherits them rather than running with an empty list.
*/
presetOperations: AutomationOperation[];
/** Used to name the built pipeline definition. */
categoryLabel: string;
/** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */
saveTriggerRef: MutableRefObject<(() => void) | null>;
/** Emits the enabled tools as operations + the endpoint-mapped backend steps. */
onComplete: (
operations: AutomationOperation[],
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
unresolvedOps: string[],
) => void;
}
/**
* The wizard's Workflow step for preset (tool-chain) policies: the locked,
* per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder.
* Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and
* wires the wizard's submit trigger to emit the configured tools as operations
* + the endpoint-mapped pipeline steps.
*/
export function PolicyToolConfigStep({
chainIds,
initialOperations,
presetOperations,
categoryLabel,
saveTriggerRef,
onComplete,
}: PolicyToolConfigStepProps) {
const { toolRegistry } = useToolWorkflow();
const [tools, setTools] = useState<PolicyToolState[]>(() =>
chainIds.map((op) => {
const saved = initialOperations.find((o) => o.operation === op);
const preset = presetOperations.find((o) => o.operation === op);
const defaults = (toolRegistry[op as ToolId]?.operationConfig
?.defaultParameters ?? {}) as Record<string, unknown>;
return {
operation: op,
enabled: Boolean(saved),
parameters: seedToolParameters(
defaults,
(preset?.parameters ?? {}) as Record<string, unknown>,
(saved?.parameters ?? {}) as Record<string, unknown>,
),
};
}),
);
// Re-wire the submit trigger whenever the tools change so it emits the latest.
useEffect(() => {
saveTriggerRef.current = () => {
const operations: AutomationOperation[] = tools
.filter((t) => t.enabled)
.map((t) => ({ operation: t.operation, parameters: t.parameters }));
const { definition, unresolved } = buildPipelineDefinition(
{ name: `${categoryLabel} Policy`, operations },
toolRegistry,
);
onComplete(operations, definition.steps, unresolved);
};
}, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]);
return (
<PolicyToolConfig
tools={tools}
toolRegistry={toolRegistry}
onChange={setTools}
/>
);
}
@@ -0,0 +1,61 @@
import type { MutableRefObject } from "react";
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
import { AutomationMode } from "@app/types/automation";
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface PolicyWorkflowStepProps {
/**
* The automation to seed/edit. For setup this is a synthetic config carrying
* the category preset's operations; for edit it's the policy's backing
* automation.
*/
automation: AutomationConfig;
/** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */
mode: AutomationMode;
/** The host (wizard) triggers the builder's save imperatively from its footer. */
saveTriggerRef: MutableRefObject<(() => void) | null>;
/**
* Called with the saved automation once the builder persists it, plus the tool
* registry (which lives here) so the wizard can map operations to backend
* endpoint paths without depending on the ToolWorkflow context itself.
*/
onComplete: (
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
) => void;
/** Called when save is triggered but the workflow isn't in a saveable state. */
onSaveFailed?: () => void;
}
/**
* The policy wizard's "Workflow" step: the Watch Folders automation builder
* ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept
* as its own component so the heavy builder + its ToolWorkflow dependency are
* isolated (and mockable in the rail tests).
*/
export function PolicyWorkflowStep({
automation,
mode,
saveTriggerRef,
onComplete,
onSaveFailed,
}: PolicyWorkflowStepProps) {
const { toolRegistry } = useToolWorkflow();
return (
<AutomationCreation
mode={mode}
existingAutomation={automation}
toolRegistry={toolRegistry}
hideMetadata
nameOverride={automation.name}
saveTriggerRef={saveTriggerRef}
onBack={() => {}}
onComplete={(saved) => onComplete(saved, toolRegistry)}
onSaveFailed={onSaveFailed}
/>
);
}
export { AutomationMode };
@@ -0,0 +1,86 @@
# Policies (frontend)
Automation-backed document-enforcement policies — conceptually like Watch
Folders but backend-driven, with non-folder triggers (editor save/export,
device sweeps, cloud connectors). **This is the frontend only**: per-policy
state is persisted locally (localStorage) and activity + stats are derived from
your real uploaded files; server persistence + real enforcement land in a
follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary
build = on while in development, core build = off).
## Layout
| Path | Role |
|------|------|
| `types/policies.ts` | Type model (category, fields, state). |
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. |
| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. |
| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). |
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. |
| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. |
| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. |
| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. |
| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. |
| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). |
| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). |
| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. |
| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). |
The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`;
`RightSidebar` (core) consumes the seam, so the section appears only in
proprietary builds.
## Design system (SUI + Mantine)
The surface is composed almost entirely from the shared SUI design system
(`@shared/components`), mixed with Mantine only where SUI has no equivalent.
SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`,
`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`,
`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`,
`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`,
`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`,
`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem`
accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`)
were **built up in SUI** as part of this work — each has a Storybook story.
Bootstrapping: the editor loads `@shared/tokens/tokens.css` globally via
`RainbowThemeProvider`, which also mirrors the Mantine colour scheme onto
`<html data-theme>` (SUI's dark palette keys on `data-theme`). A global
`@shared` alias in `editor/vite.config.ts` + `vitest.config.ts` resolves the
shared components' own `@shared/*.css` self-imports.
The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding
(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI
`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI
token set.
**Status-colour convention (locked):** blue = accent/identity (NavItem accent
bar, rail icon, detail Card accent — the prototype's blue); green `success`
StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot);
amber = paused. Configured rows render as raised cards (surface + border).
## Faithful to the prototype
5 categories (Ingestion, Security, Compliance, Routing, Retention), their full
field sets, the 3-step wizard (incl. the doc-type step gated behind the
Classification/ingestion policy), the configured narrative view (Enforces /
recent-activity feed / three-up stats), settings, the permission model
(owner/admin/member + solo), and the
docked right-sidebar placement: a collapsible **Policies** list above Tools, a
detail view that takes over the rail when a policy is open, and a collapsed-rail
of policy icons with active/paused status dots.
## Deviations / follow-ups
- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise
upgrade/commit/bespoke modals live in the Settings billing tab — a
billing-integration surface (Stripe/org state that doesn't exist yet),
deferred. The in-rail surface shows only the spend-limit warning chip, as in
the prototype's policy section.
- **Backend.** All persistence, enforcement, activity, and stats are mock. To
go live, replace `services/policyStorage.ts` and feed real activity/stats.
## Tests
`services/policyStorage.test.ts` (seed/update/reset/heal/events) and
`data/policyDefinitions.test.ts` (permission matrix + definition integrity).
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
dispatchKey,
isDispatched,
markDispatched,
recordRunStart,
updateRun,
resetPolicyRuns,
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
function rec(over: Partial<PolicyRunRecord>): PolicyRunRecord {
return {
runId: "r1",
categoryId: "security",
fileId: "f1",
fileName: "f.pdf",
fileSize: 10,
status: "PENDING",
outputs: [],
error: null,
startedAt: 1,
...over,
};
}
// The store reads localStorage at import; reset state + storage per test.
function read(key: string) {
return JSON.parse(localStorage.getItem(key) ?? "{}");
}
describe("policyRunStore", () => {
beforeEach(() => {
localStorage.clear();
resetPolicyRuns();
});
it("records a run start and marks the (policy, file) pair dispatched", () => {
expect(isDispatched("security", "f1")).toBe(false);
recordRunStart(rec({}));
expect(isDispatched("security", "f1")).toBe(true);
const stored = read("stirling-policy-runs");
expect(stored.runs).toHaveLength(1);
expect(stored.dispatched).toContain(dispatchKey("security", "f1"));
});
it("markDispatched is idempotent and independent of a run record", () => {
markDispatched("routing", "f9");
markDispatched("routing", "f9");
expect(isDispatched("routing", "f9")).toBe(true);
expect(read("stirling-policy-runs").dispatched).toHaveLength(1);
});
it("updateRun patches an in-flight run's status + outputs", () => {
recordRunStart(rec({ runId: "abc" }));
updateRun("abc", {
status: "COMPLETED",
outputs: [{ fileId: "out-1", fileName: "redacted.pdf" }],
});
const run = read("stirling-policy-runs").runs[0];
expect(run.status).toBe("COMPLETED");
expect(run.outputs).toEqual([
{ fileId: "out-1", fileName: "redacted.pdf" },
]);
});
it("updateRun ignores an unknown run id", () => {
recordRunStart(rec({ runId: "abc", status: "PENDING" }));
updateRun("nope", { status: "FAILED" });
expect(read("stirling-policy-runs").runs[0].status).toBe("PENDING");
});
it("caps stored runs at 50, newest first", () => {
for (let i = 0; i < 55; i++) {
recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i }));
}
const runs = read("stirling-policy-runs").runs;
expect(runs).toHaveLength(50);
expect(runs[0].runId).toBe("r54"); // most recent
});
});
@@ -0,0 +1,155 @@
/**
* External store for real backend policy runs (Phase B: auto-run on upload).
*
* The auto-run controller fires a backend run for each enabled policy × each
* newly-uploaded file and records it here; the detail view's activity feed reads
* from it. `dispatched` keys (`categoryId:fileId`) ensure a given file is only
* ever run once per policy, surviving remounts via localStorage.
*
* Read with {@code useSyncExternalStore}; mutated by the controller.
*/
import { useSyncExternalStore } from "react";
import type { PolicyRunStatus } from "@app/services/policyPipeline";
export interface PolicyRunRecord {
runId: string;
categoryId: string;
fileId: string;
fileName: string;
fileSize: number;
status: PolicyRunStatus;
/** Output files (downloadable via /api/v1/general/files/{id}) once done. */
outputs: { fileId: string; fileName: string }[];
/** True once ALL outputs have been imported into the workspace. */
imported?: boolean;
/** 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[];
error: string | null;
/** Epoch ms when the run was dispatched. */
startedAt: number;
}
interface RunState {
runs: PolicyRunRecord[];
dispatched: string[];
}
const STORAGE_KEY = "stirling-policy-runs";
/** Cap stored runs so the activity log can't grow without bound. */
const MAX_RUNS = 50;
function read(): RunState {
try {
const raw =
typeof localStorage !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (raw) {
const parsed = JSON.parse(raw) as Partial<RunState>;
return {
// Normalise older persisted records (which predate the `outputs` field)
// so consumers can always rely on `outputs` being an array.
runs: Array.isArray(parsed.runs)
? parsed.runs.map((r) => ({
...r,
outputs: Array.isArray(r.outputs) ? r.outputs : [],
importedFileIds: Array.isArray(r.importedFileIds)
? r.importedFileIds
: [],
}))
: [],
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
};
}
} catch {
// Corrupt/unavailable storage — start empty.
}
return { runs: [], dispatched: [] };
}
let state: RunState = read();
const listeners = new Set<() => void>();
function emit() {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
} catch {
// Best-effort persistence.
}
for (const l of listeners) l();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): RunState {
return state;
}
const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] };
function getServerSnapshot(): RunState {
return SERVER_SNAPSHOT;
}
/** Key identifying a single (policy, file) run attempt. */
export function dispatchKey(categoryId: string, fileId: string): string {
return `${categoryId}:${fileId}`;
}
/** Whether this (policy, file) pair has already been dispatched. */
export function isDispatched(categoryId: string, fileId: string): boolean {
return state.dispatched.includes(dispatchKey(categoryId, fileId));
}
/** Record a newly-dispatched run (marks it dispatched + adds the record). */
export function recordRunStart(record: PolicyRunRecord) {
const key = dispatchKey(record.categoryId, record.fileId);
state = {
runs: [record, ...state.runs].slice(0, MAX_RUNS),
dispatched: state.dispatched.includes(key)
? state.dispatched
: [...state.dispatched, key],
};
emit();
}
/** Mark a (policy, file) pair dispatched without a run (e.g. dispatch failed). */
export function markDispatched(categoryId: string, fileId: string) {
const key = dispatchKey(categoryId, fileId);
if (state.dispatched.includes(key)) return;
state = { ...state, dispatched: [...state.dispatched, key] };
emit();
}
/** Patch an in-flight run's status/outputs/error as it progresses. */
export function updateRun(runId: string, patch: Partial<PolicyRunRecord>) {
let changed = false;
const runs = state.runs.map((r) => {
if (r.runId !== runId) return r;
changed = true;
return { ...r, ...patch };
});
if (!changed) return;
state = { ...state, runs };
emit();
}
/** Reset the store — used by tests to isolate it. */
export function resetPolicyRuns() {
state = { runs: [], dispatched: [] };
emit();
}
export function usePolicyRuns(): PolicyRunRecord[] {
return useSyncExternalStore(
subscribe,
() => getSnapshot().runs,
() => getServerSnapshot().runs,
);
}
@@ -0,0 +1,71 @@
/**
* Tiny external store for the currently-selected policy and its detail sub-view.
*
* The Policies surface is split across two slots in the right tool sidebar — the
* list section (above Tools) and the detail takeover (which replaces Tools when a
* policy is open) — plus the collapsed-rail icons. They live in different parts of
* {@code RightSidebar}'s tree, so selection can't be component-local useState.
* This module-level store (read via {@code useSyncExternalStore}) lets all three
* stay in sync without threading a context through the core sidebar.
*/
import { useSyncExternalStore } from "react";
import type { PolicyDetailView } from "@app/types/policies";
interface PolicySelection {
selectedId: string | null;
detailView: PolicyDetailView;
}
let state: PolicySelection = { selectedId: null, detailView: "detail" };
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): PolicySelection {
return state;
}
/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */
const SERVER_SNAPSHOT: PolicySelection = {
selectedId: null,
detailView: "detail",
};
function getServerSnapshot(): PolicySelection {
return SERVER_SNAPSHOT;
}
/** Open a policy's detail (resets the sub-view to the narrative). */
export function selectPolicy(id: string | null) {
state = { selectedId: id, detailView: "detail" };
emit();
}
/** Switch the open policy between its narrative and edit-settings sub-views. */
export function setPolicyDetailView(view: PolicyDetailView) {
if (state.detailView === view) return;
state = { ...state, detailView: view };
emit();
}
/** Close the open policy and return to the list. */
export function closePolicy() {
selectPolicy(null);
}
/** Reset to the initial state — used by tests to isolate the module store. */
export function resetPolicySelection() {
state = { selectedId: null, detailView: "detail" };
emit();
}
export function usePolicySelection(): PolicySelection {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -0,0 +1,27 @@
import type { IconBadgeAccent } from "@shared/components/IconBadge";
import type { PolicyRowStatus, PolicyState } from "@app/types/policies";
/** Derive a single row/detail status from a policy's persisted state. */
export function deriveRowStatus(
state: PolicyState | undefined,
): PolicyRowStatus {
if (!state?.configured) return "setup";
if (state.status === "paused") return "paused";
return "active";
}
/** Human label for each row status. */
export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
active: "Active",
paused: "Paused",
setup: "Set up",
};
/** A soft tinted icon tile per category — gives each policy a calm identity colour. */
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
ingestion: "blue",
security: "purple",
compliance: "green",
routing: "amber",
retention: "red",
};
@@ -0,0 +1,18 @@
/**
* The fixed, configurable tool chain per policy category — the locked set the
* config page shows (one section per tool). Tools can be configured + toggled
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
* maps to that tool's backend endpoint via the registry's operationConfig).
*
* Only Security is wired today; other categories follow.
*/
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
// by default comes from the preset's defaultOperations, not this list.
security: ["redact", "watermark", "sanitize"],
};
/** The configurable tool chain for a category, or null if it has none yet. */
export function getPolicyToolChain(categoryId: string): string[] | null {
return POLICY_TOOL_CHAINS[categoryId] ?? null;
}
@@ -0,0 +1,16 @@
import type { PolicyConfigDef, PolicyState } from "@app/types/policies";
/**
* Resolve each field's effective value for a policy: the saved override from
* state, falling back to the definition's default.
*/
export function resolveFieldValues(
config: PolicyConfigDef,
state: PolicyState,
): Record<string, boolean | string | string[]> {
const out: Record<string, boolean | string | string[]> = {};
for (const f of config.fields) {
out[f.key] = state.fieldValues[f.key] ?? f.value;
}
return out;
}
@@ -0,0 +1,205 @@
/**
* Auto-run controller (Phase B): every enabled policy enforces on every uploaded
* file. Watches the session's files and, for each (active policy × not-yet-run
* file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
* to completion, recording progress in {@link policyRunStore} for the activity
* feed.
*
* Headless — call it from {@link PolicyAutoRunController}, which is mounted once
* wherever the editor is open so enforcement happens regardless of whether the
* policy panel is on screen. Each (policy, file) pair runs exactly once (tracked
* in the run store), so re-renders and remounts don't re-fire.
*/
import { useEffect, useRef } from "react";
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
import { fileStorage } from "@app/services/fileStorage";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import {
runStoredPolicy,
getPolicyRun,
downloadPolicyOutput,
} from "@app/services/policyApi";
import type { PolicyRunStatus } from "@app/services/policyPipeline";
import type { FileId } from "@app/types/file";
import { usePolicies } from "@app/hooks/usePolicies";
import {
isDispatched,
markDispatched,
recordRunStart,
updateRun,
usePolicyRuns,
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
/** Poll cadence + cap for a single run's status (≈2.5 min worst case). */
const POLL_MS = 2000;
const MAX_POLLS = 75;
function isTerminal(status: PolicyRunStatus): boolean {
return (
status === "COMPLETED" || status === "FAILED" || status === "CANCELLED"
);
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export function usePolicyAutoRun(): void {
const { fileStubs } = useAllFiles();
const { addFiles } = useFileManagement();
const { policies } = usePolicies();
const runs = usePolicyRuns();
// Run ids currently being polled / imported, so the effects never double-fire.
const polling = useRef<Set<string>>(new Set());
const importing = useRef<Set<string>>(new Set());
// Dispatch: for each active policy × each session file not yet run, fire a run.
useEffect(() => {
if (!POLICIES_ENABLED) return;
const active = Object.entries(policies).filter(
([, s]) => s.configured && s.status === "active" && s.backendId,
);
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.
void runPolicyOnFile(
categoryId,
s.backendId as string,
stub.id,
stub.name,
);
}
}
}, [fileStubs, policies]);
// Poll each in-flight run to a terminal state.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
polling.current.add(run.runId);
void poll(run.runId).finally(() => polling.current.delete(run.runId));
}
}, [runs]);
// Import each completed run's outputs into the workspace (each output once),
// so the enforced file appears in the app rather than only on the backend.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (
run.status !== "COMPLETED" ||
run.imported ||
!run.outputs?.length ||
importing.current.has(run.runId)
) {
continue;
}
importing.current.add(run.runId);
void importOutputs(run, addFiles).finally(() =>
importing.current.delete(run.runId),
);
}
}, [runs, addFiles]);
}
/**
* 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,
* 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.
*/
async function importOutputs(
run: PolicyRunRecord,
addFiles: (files: File[]) => Promise<unknown>,
): Promise<void> {
const done = new Set(run.importedFileIds ?? []);
const pending = run.outputs.filter((out) => !done.has(out.fileId));
if (pending.length === 0) {
updateRun(run.runId, { imported: true });
return;
}
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, {
type: blob.type || "application/pdf",
}),
};
}),
);
const fetched = results
.filter(
(r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
r.status === "fulfilled",
)
.map((r) => r.value);
if (fetched.length === 0) return; // all failed — retry the lot on a later tick
// 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));
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
updateRun(run.runId, {
importedFileIds,
imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
});
}
/**
* Resolve the file's bytes, fire a backend run, and record it. Exported so the
* activity feed's Retry action can re-run a policy on a previously-failed file.
*/
export async function runPolicyOnFile(
categoryId: string,
backendId: string,
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);
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({
runId,
categoryId,
fileId,
fileName,
fileSize: file.size,
status: "PENDING",
outputs: [],
error: null,
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.
}
}
/** Poll a run's status until it reaches a terminal state (or the cap). */
async function poll(runId: string): Promise<void> {
for (let i = 0; i < MAX_POLLS; i++) {
await delay(POLL_MS);
let view;
try {
view = await getPolicyRun(runId);
} catch {
continue; // transient — keep trying within the cap.
}
updateRun(runId, {
status: view.status,
outputs: view.outputs,
error: view.error,
});
if (isTerminal(view.status)) return;
}
}