mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user