Delete dead policies code

This commit is contained in:
James Brunton
2026-09-02 14:45:29 +01:00
parent 73e89c3a74
commit 03f9897669
21 changed files with 78 additions and 1358 deletions
@@ -8541,7 +8541,6 @@ onEveryUpload = "On every upload"
outputAsNewFile = "as a new file"
outputAsNewVersion = "as a new version"
recentActivity = "Recent activity"
retry = "Retry"
showLess = "Show less"
showMore = "Show more"
sources = "Sources"
@@ -8552,7 +8551,6 @@ delete = "Delete"
editSettings = "Edit settings"
pause = "Pause"
resume = "Resume"
runNow = "Run now"
[portal.policies.detail.clearHistory]
body = "This pipeline will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
+4 -70
View File
@@ -30,15 +30,7 @@ import type {
WirePolicy,
} from "@app/policies/types";
export type {
PolicyActivityItem,
PolicyDecodedState,
PolicyRunView,
PolicyStats,
WireOutputOptions,
WireOutputSpec,
WirePolicy,
} from "@app/policies/types";
export type { PolicyRunView, WirePolicy } from "@app/policies/types";
// Re-export the wire step type under the legacy name components depend on.
export type { WirePipelineStep as PipelineStep } from "@app/policies/types";
@@ -47,9 +39,7 @@ export type { WirePipelineStep as PipelineStep } from "@app/policies/types";
/* Catalogue model — portal-specific */
/* ──────────────────────────────────────────────────────────────────────── */
export type PolicyStatus = "active" | "paused";
export type PolicyRowStatus = "active" | "paused" | "setup";
type PolicyStatus = "active" | "paused";
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
@@ -125,15 +115,7 @@ export interface DecoratedPolicy {
activity: import("@app/policies/types").PolicyActivityItem[];
}
export interface PoliciesSummary {
active: number;
paused: number;
categories: number;
docsEnforced: number;
}
export interface PoliciesResponse {
summary: PoliciesSummary;
catalogue: CatalogueEntry[];
}
@@ -151,7 +133,7 @@ export interface CatalogueEntry {
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
@@ -418,17 +400,6 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
},
};
export const POLICY_DOC_TYPES: string[] = [
"contracts",
"invoices",
"taxDocuments",
"hrRecords",
"insurance",
"medicalPhi",
"legalFilings",
"financialReports",
];
// ── Client-side catalogue assembly ───────────────────────────────────────────
function decoratePolicy(
@@ -511,25 +482,7 @@ export function assemblePolicies(
return { category, config: POLICY_CONFIG[category.id], policy };
});
const active = wirePolicies.filter((p) => p.enabled).length;
const paused = wirePolicies.filter((p) => !p.enabled).length;
const enabledPolicyIds = new Set(
wirePolicies.filter((p) => p.enabled).map((p) => p.id),
);
const docsEnforced = runs.filter(
(r) =>
r.status === "COMPLETED" &&
r.policyId != null &&
enabledPolicyIds.has(r.policyId),
).length;
const summary: PoliciesSummary = {
active,
paused,
categories: POLICY_CATEGORIES.length,
docsEnforced,
};
return { summary, catalogue };
return { catalogue };
}
/**
@@ -599,13 +552,6 @@ export function parseSimplePolicy(
return { category, config, policy: decorated };
}
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
export async function fetchPolicy(id: string): Promise<WirePolicy> {
return apiClient.local.json<WirePolicy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
}
/**
* POST /api/v1/policies — create (blank id) or update (matched id). The
* backend stamps owner + teamId server-side and returns the stored record.
@@ -725,15 +671,3 @@ export function buildWireFromState(
}),
};
}
/**
* POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
* real endpoint is multipart; the portal sends no files, relying on whatever
* the backend has queued for this policy.
*/
export async function runPolicy(id: string): Promise<{ runId: string }> {
return apiClient.local.json<{ runId: string }>(
`/api/v1/policies/${encodeURIComponent(id)}/run`,
{ method: "POST" },
);
}
@@ -9,10 +9,8 @@ const meta: Meta<typeof PolicyDetailPanel> = {
args: {
onClose: () => {},
onEdit: () => {},
onRun: () => {},
onTogglePause: () => {},
onDelete: () => {},
onRetry: () => {},
},
};
export default meta;
@@ -8,11 +8,7 @@ import {
StatTile,
StatusBadge,
} from "@app/ui";
import {
humanizeEndpoint,
type DecoratedPolicy,
type PolicyActivityItem,
} from "@portal/api/policies";
import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies";
import "@portal/views/Policies.css";
interface PolicyDetailPanelProps {
@@ -20,11 +16,9 @@ interface PolicyDetailPanelProps {
busy?: boolean;
onClose: () => void;
onEdit: () => void;
onRun?: () => void;
onTogglePause: () => void;
onDelete: () => void;
onClearHistory?: () => void;
onRetry?: (item: PolicyActivityItem) => void;
}
function CheckIcon() {
@@ -104,11 +98,9 @@ export function PolicyDetailPanel({
busy = false,
onClose,
onEdit,
onRun,
onTogglePause,
onDelete,
onClearHistory,
onRetry,
}: PolicyDetailPanelProps) {
const { t } = useTranslation();
const [confirmingClear, setConfirmingClear] = useState(false);
@@ -155,17 +147,6 @@ export function PolicyDetailPanel({
{t("portal.policies.detail.actions.delete")}
</Button>
)}
{onRun && (
<Button
variant="secondary"
size="sm"
onClick={onRun}
disabled={busy}
style={canDelete ? undefined : { marginRight: "auto" }}
>
{t("portal.policies.detail.actions.runNow")}
</Button>
)}
{canClearHistory && (
<Button
variant="secondary"
@@ -295,16 +276,6 @@ export function PolicyDetailPanel({
<span className="portal-policies__activity-time">
{item.time}
</span>
{item.status === "flagged" && onRetry && (
<Button
type="button"
variant="quiet"
className="portal-policies__link portal-policies__activity-retry"
onClick={() => onRetry(item)}
>
{t("portal.policies.detail.retry")}
</Button>
)}
</div>
))}
</Card>
@@ -5,7 +5,6 @@ import {
buildStepParameters,
emptyOperationValues,
operationById,
operationFormValid,
searchOperations,
} from "@portal/components/policies/stepOperations";
import { CREATABLE_CONNECTION_TYPES } from "@portal/components/sources/connectionTypes";
@@ -115,18 +114,10 @@ describe("buildStepParameters", () => {
});
describe("operation form", () => {
it("seeds defaults and enforces required fields", () => {
it("seeds field defaults", () => {
const elastic = operationById("elasticIndex")!;
const seeded = emptyOperationValues(elastic);
expect(seeded.index).toBe("stirling-audit");
expect(operationFormValid(elastic, seeded)).toBe(true);
expect(operationFormValid(elastic, { index: " " })).toBe(false);
});
it("an operation with no fields is immediately valid", () => {
const cloudmersive = operationById("cloudmersiveScan")!;
expect(operationFormValid(cloudmersive, {})).toBe(true);
});
});
@@ -747,15 +747,6 @@ export function emptyOperationValues(
return values;
}
export function operationFormValid(
op: StepOperation,
values: Record<string, string>,
): boolean {
return (op.fields ?? []).every(
(field) => !field.required || (values[field.key] ?? "").trim() !== "",
);
}
/**
* Turn a chosen operation plus the operator's answers into the parameters the
* `external-api-call` step takes.
@@ -20,14 +20,6 @@ import type { PolicyRunView, WirePolicy } from "@app/policies/types";
let store: WirePolicy[] = seedPolicies();
let runs: PolicyRunView[] = seedPolicyRuns();
export function resetPoliciesStore(
seed?: WirePolicy[],
seedRuns?: PolicyRunView[],
): void {
store = seed ? [...seed] : seedPolicies();
runs = seedRuns ? [...seedRuns] : seedPolicyRuns();
}
/**
* The catalogue (suggested-policy) records, for the unified Pipelines overview to merge in - the
* real backend keeps a single store, so its overview already sees these; the mock's two stores must
@@ -218,11 +218,6 @@
white-space: nowrap;
}
.portal-policies__activity-retry {
flex-shrink: 0;
font-size: 0.75rem;
}
/* Error expand/collapse for long activity messages */
.portal-policies__activity-error {
display: block;
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
isClassificationCategory,
localVerdictNeedsEscalation,
orderRewritesFirst,
orderedRewritingCategories,
policyDeliversOutputFiles,
policyRewritesDocument,
@@ -41,32 +40,6 @@ describe("policy capabilities", () => {
});
});
describe("orderRewritesFirst", () => {
it("moves annotating policies to the end, preserving other order", () => {
expect(
orderRewritesFirst(["classification", "security", "compliance"]),
).toEqual(["security", "compliance", "classification"]);
});
it("leaves an order without an annotating policy untouched", () => {
expect(orderRewritesFirst(["security", "compliance"])).toEqual([
"security",
"compliance",
]);
});
it("is a no-op when the annotating policy is already last", () => {
expect(orderRewritesFirst(["security", "classification"])).toEqual([
"security",
"classification",
]);
});
it("handles the annotating policy as the only one", () => {
expect(orderRewritesFirst(["classification"])).toEqual(["classification"]);
});
});
describe("orderedRewritingCategories", () => {
it("lists only file-producing policies, ordered by order, excluding classification", () => {
const policies = {
@@ -44,14 +44,6 @@ export function policyRequiresAiEngine(categoryId: string): boolean {
return isClassificationCategory(categoryId);
}
/** Order annotating policies last; everything else keeps the order it was given. */
export function orderRewritesFirst(categoryIds: string[]): string[] {
return [
...categoryIds.filter(policyRewritesDocument),
...categoryIds.filter((id) => !policyRewritesDocument(id)),
];
}
/**
* The active editor upload policies the generic runner dispatches and chains, in run order. Only
* file-producing policies: an annotating policy (classification) has no output to chain onto and
@@ -2,73 +2,20 @@ import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
// Enable/delete create + remove the backing Watched Folders WatchedFolder
// (IndexedDB); jsdom's crypto lacks randomUUID, used 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)}`,
});
}
// In-memory stand-in for the backend policy store, so the hook's persistence
// path is exercised without a real server.
const api = vi.hoisted(() => ({
store: new Map<string, { id: string }>(),
seq: 0,
}));
// In-memory stand-in for the backend policy store, so the hook's reconcile path
// is exercised without a real server. The editor only reads policies now, so the
// read (`listPolicies`) is all the hook touches.
const api = vi.hoisted(() => ({ store: new Map<string, { id: string }>() }));
vi.mock("@app/services/policyApi", () => ({
listPolicies: vi.fn(async () => [...api.store.values()]),
savePolicy: vi.fn(async (p: { id?: string }) => {
const id = p.id && p.id.length > 0 ? p.id : `be-${++api.seq}`;
const saved = { ...p, id };
api.store.set(id, saved);
return saved;
}),
getPolicy: vi.fn(async (id: string) => api.store.get(id)),
deletePolicy: vi.fn(async (id: string) => {
api.store.delete(id);
}),
runStoredPolicy: vi.fn(),
runPolicyPipeline: vi.fn(),
getPolicyRun: vi.fn(),
}));
import { usePolicies } from "@app/hooks/usePolicies";
// A minimal wizard result (workflow already saved + mapped by the builder).
const wizardResult = {
automation: {
id: "auto-1",
name: "Test",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
fieldValues: {},
sources: [],
runsOnEditor: true,
scopeTypes: [],
reviewerEmail: "reviewer@x.com",
folder: {
runOn: "upload" as const,
outputMode: "new_file" as const,
outputName: "",
outputNamePosition: "prefix" as const,
maxRetries: 3,
retryDelayMinutes: 5,
},
pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
unresolvedOps: [],
};
describe("usePolicies", () => {
beforeEach(() => {
localStorage.clear();
api.store.clear();
api.seq = 0;
});
it("starts with every category unconfigured (no seed)", async () => {
@@ -79,80 +26,21 @@ describe("usePolicies", () => {
expect(result.current.policies.security.configured).toBe(false);
});
it("enabling a policy persists it to the backend + marks it configured", async () => {
it("reconciles a configured category policy from the backend on mount", async () => {
api.store.set("be-sec", {
id: "be-sec",
name: "Security",
enabled: true,
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: { type: "inline", options: { categoryId: "security" } },
editor: { allowed: true, runOn: "upload" },
} as unknown as { id: string });
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("security", wizardResult);
});
await waitFor(() =>
expect(result.current.policies.security.configured).toBe(true),
);
expect(result.current.policies.security.status).toBe("active");
expect(result.current.policies.security.folderId).toBeTruthy();
expect(result.current.policies.security.backendId).toBeTruthy();
expect(result.current.policies.security.reviewerEmail).toBe(
"reviewer@x.com",
);
// The mapped pipeline (endpoint path) reached the backend store.
const stored = [...api.store.values()][0] as unknown as {
steps: unknown[];
};
expect(stored.steps).toHaveLength(1);
});
it("reconciles configured policies from the backend on mount", async () => {
// Enable on one instance (persists to the backend store)...
const first = renderHook(() => usePolicies());
await act(async () => {
await first.result.current.enablePolicy("security", wizardResult);
});
// ...a fresh instance should pick it up from the backend.
const second = renderHook(() => usePolicies());
await waitFor(() =>
expect(second.result.current.policies.security.configured).toBe(true),
);
expect(second.result.current.policies.security.backendId).toBeTruthy();
});
it("pausing then resuming flips status", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("ingestion", wizardResult);
});
await act(async () => {
await result.current.pausePolicy("ingestion");
});
expect(result.current.policies.ingestion.status).toBe("paused");
await act(async () => {
await result.current.resumePolicy("ingestion");
});
expect(result.current.policies.ingestion.status).toBe("active");
});
it("deleting a policy reverts it + removes it from the backend", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("routing", wizardResult);
});
await waitFor(() =>
expect(result.current.policies.routing.configured).toBe(true),
);
await act(async () => {
await result.current.deletePolicy("routing");
});
expect(result.current.policies.routing.configured).toBe(false);
expect(result.current.policies.routing.status).toBe("default");
expect(result.current.policies.routing.folderId).toBeUndefined();
expect(result.current.policies.routing.backendId).toBeUndefined();
expect(api.store.size).toBe(0);
});
it("ensurePolicyFolder creates a backing folder for a folderless policy", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.ensurePolicyFolder("ingestion");
});
expect(result.current.policies.ingestion.folderId).toBeTruthy();
expect(result.current.policies.security.backendId).toBe("be-sec");
});
// A builder pipeline has no category tile, so the reconcile must key it by id to reach the map
@@ -1,48 +1,25 @@
/**
* State + actions for Policies. The backend (`/api/v1/policies`) is the source
* of truth: on mount we reconcile the local cache against the stored policies,
* and every lifecycle action (enable/save/pause/resume/delete) is mirrored to
* the backend. localStorage is a fast-render cache + offline fallback; the
* IndexedDB backing folder still holds the editable automation + run state.
* Read-only Policies state for the editor's enforcement path. The backend
* (`/api/v1/policies`) is the source of truth: on mount we reconcile the local
* cache against the stored policies. localStorage is a fast-render cache +
* offline fallback. Managing policies (create/edit/pause/delete) lives on the
* portal Pipelines page, not here; the editor only reads them and runs them.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import {
loadPolicies,
onPoliciesChange,
updatePolicy,
resetPolicy,
forgetPolicies,
reorderPolicies as persistPolicyOrder,
} from "@app/services/policyStorage";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import {
createPolicyFolder,
createPolicyFolderForAutomation,
deletePolicyFolder,
getPolicyAutomation,
setPolicyFolderPaused,
updatePolicyFolderSettings,
updatePolicyOperations,
} from "@app/services/policyFolders";
import {
fetchPoliciesByCategory,
decodedToState,
findBackendId,
persistPolicy,
setPolicyEnabled,
removePolicy,
} from "@app/services/policyBackend";
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
import { orderRewritesFirst } from "@app/data/classificationPolicy";
import { type PolicyToStore } from "@app/services/policyPipeline";
import type {
PoliciesByCategory,
PolicyConfigResult,
PolicyWizardResult,
} from "@app/types/policies";
import type { PoliciesByCategory } from "@app/types/policies";
/** Cold-start reconcile retry budget + capped backoff (≈0.5s→5s, ~1 min total),
* enough to outlast a backend that starts a little after the frontend. */
@@ -50,34 +27,9 @@ const RECONCILE_MAX_ATTEMPTS = 15;
const reconcileRetryDelay = (attempt: number) =>
Math.min(500 * 2 ** attempt, 5000);
/** Build the backend store-request for a category from a wizard result. */
function toStoreRequest(
categoryId: string,
categoryLabel: string,
result: PolicyWizardResult,
enabled: boolean,
backendId: string | undefined,
): PolicyToStore {
return {
id: backendId,
categoryId,
name: `${categoryLabel} Policy`,
enabled,
automation: result.automation,
pipelineSteps: result.pipelineSteps,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
fieldValues: result.fieldValues,
folder: result.folder,
};
}
export function usePolicies() {
const [policies, setPolicies] = useState<PoliciesByCategory>(loadPolicies);
const { config, refetch: refetchAppConfig } = useAppConfig();
const { isTeamLeader } = useSaaSTeam();
const { refetch: refetchAppConfig } = useAppConfig();
useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []);
@@ -144,276 +96,5 @@ export function usePolicies() {
};
}, []);
/**
* Enable a new policy from the wizard result: persist it to the backend (the
* source of truth), then create the backing folder holding its editable
* automation, and cache the result locally. Throws (surfacing in the wizard)
* if the category is unknown or the backend save fails.
*/
const enablePolicy = useCallback(
async (id: string, result: PolicyWizardResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// One policy per category, ever: reuse any existing backend record.
const existingBackendId =
loadPolicies()[id]?.backendId ??
(await findBackendId(id).catch(() => undefined));
const backendId = await persistPolicy(
toStoreRequest(id, category.label, result, true, existingBackendId),
);
const folder = await createPolicyFolderForAutomation(
category,
result.automation.id,
);
await updatePolicyFolderSettings(folder.id, result.folder);
updatePolicy(id, {
configured: true,
status: "active",
folderId: folder.id,
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode,
outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn,
});
},
[],
);
/**
* Save edits from the wizard. The workflow automation is updated in place by
* the builder; persist the updated policy to the backend and the folder's
* output/retry settings + the rest of the settings locally.
*/
const savePolicyConfig = useCallback(
async (id: string, result: PolicyWizardResult) => {
const current = loadPolicies()[id];
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
const backendId = await persistPolicy(
toStoreRequest(
id,
category.label,
result,
current?.status !== "paused",
current?.backendId,
),
);
if (current?.folderId) {
await updatePolicyFolderSettings(current.folderId, result.folder);
}
updatePolicy(id, {
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode,
outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn,
});
},
[],
);
/**
* Create-or-update a policy from the locked tool-config page. Unlike the
* wizard path this works straight from the tool `operations` (the config page
* owns the chain): it creates the backing folder + automation on first
* configure, or updates the existing automation's operations on edit, then
* mirrors the whole policy to the backend. One method serves both because a
* preset policy has no separate "create" — you're just configuring it.
*/
const commitPolicyConfig = useCallback(
async (id: string, result: PolicyConfigResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
const current = loadPolicies()[id];
// One policy per category, ever: reuse the existing backend record (even
// if the local link was lost) so a save never creates a duplicate.
const existingBackendId =
current?.backendId ?? (await findBackendId(id).catch(() => undefined));
let folderId = current?.folderId;
if (folderId) {
await updatePolicyOperations(folderId, result.operations);
} else {
const folder = await createPolicyFolder(category, result.operations);
folderId = folder.id;
}
await updatePolicyFolderSettings(folderId, result.folder);
// The saved automation (with its id) is the lossless round-trip blob.
const automation = await getPolicyAutomation(folderId);
const store: PolicyToStore = {
id: existingBackendId,
categoryId: id,
name: `${category.label} Policy`,
enabled: current?.status !== "paused",
automation: automation ?? {
id: "",
name: `${category.label} Policy`,
operations: result.operations,
createdAt: "",
updatedAt: "",
},
pipelineSteps: result.pipelineSteps,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
fieldValues: result.fieldValues,
folder: result.folder,
};
const backendId = await persistPolicy(store);
updatePolicy(id, {
configured: true,
status: current?.status === "paused" ? "paused" : "active",
folderId,
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode,
outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn,
});
},
[],
);
const pausePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) {
await setPolicyEnabled(current.backendId, false).catch((err: unknown) => {
if (
(err as { response?: { status?: number } })?.response?.status === 404
) {
updatePolicy(id, {
backendId: undefined,
configured: false,
status: "default",
});
return;
}
throw err;
});
}
if (current?.folderId) await setPolicyFolderPaused(current.folderId, true);
updatePolicy(id, { status: "paused" });
}, []);
const resumePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) {
await setPolicyEnabled(current.backendId, true).catch((err: unknown) => {
if (
(err as { response?: { status?: number } })?.response?.status === 404
) {
updatePolicy(id, {
backendId: undefined,
configured: false,
status: "default",
});
return;
}
throw err;
});
}
if (current?.folderId) await setPolicyFolderPaused(current.folderId, false);
updatePolicy(id, { status: "active" });
}, []);
const deletePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await removePolicy(current.backendId);
if (current?.folderId) await deletePolicyFolder(current.folderId);
resetPolicy(id);
}, []);
/**
* Persist a new execution order for the given categories (in the sequence
* provided). The order is server-side and team-wide: it's mirrored to the
* backend (mapping each category to its stored policy id) so it survives a
* cleared browser and is shared by the whole team. The local cache is updated
* first for an instant re-render; the next reconcile re-reads the server order.
*/
const reorderPolicies = useCallback((orderedCategoryIds: string[]) => {
// Annotating policies last, so the persisted order matches execution order
// (see usePolicyAutoRun).
const ordered = orderRewritesFirst(orderedCategoryIds);
persistPolicyOrder(ordered);
const current = loadPolicies();
const backendIds = ordered
.map((categoryId) => current[categoryId]?.backendId)
.filter((id): id is string => !!id);
if (backendIds.length > 0) {
// Fire-and-forget: on failure (offline / not a team leader) the optimistic
// local order stands until the next reconcile re-reads the server's order.
void reorderBackendPolicies(backendIds).catch(() => {});
}
}, []);
/**
* Ensure a configured policy has a *valid* backing folder (its editable
* pipeline) and return its id. Self-heals a stale `folderId` — one that no
* longer resolves to a real folder (cleared storage, or a folder left in an
* old IndexedDB after a rename/migration) — which would otherwise hang the
* Edit-Settings view on a permanent "Loading…". When recreating, the backend's
* stored automation is used if present so the configured pipeline survives;
* otherwise it falls back to the preset.
*/
const ensurePolicyFolder = useCallback(async (id: string) => {
const state = loadPolicies()[id];
const existing = state?.folderId;
// A healthy backing folder resolves to an automation; if it does, keep it.
if (existing && (await getPolicyAutomation(existing))) return existing;
const catalog = loadPolicyCatalog();
const category = catalog.categories.find((c) => c.id === id);
const config = catalog.configs[id];
if (!category || !config) return undefined;
// Stale/missing folder → recreate. Prefer the backend's stored automation
// (preserves the user's configured steps); else seed from the preset.
let operations = config.defaultOperations;
if (state?.backendId) {
const decoded = await fetchPoliciesByCategory()
.then((m) => m.get(id))
.catch(() => undefined);
if (decoded?.automation?.operations?.length) {
operations = decoded.automation.operations;
}
}
const folder = await createPolicyFolder(category, operations);
updatePolicy(id, { folderId: folder.id });
return folder.id;
}, []);
// Only a team leader (SaaS) or a global admin (self-hosted) may configure;
// everyone else gets the read-only surface. Login disabled (single-user)
// always can. Stays closed until config loads, so edit controls never flash
// for users who can't use them.
const canConfigure =
config != null &&
(!config.enableLogin || isTeamLeader || config.isAdmin === true);
return {
policies,
canConfigure,
enablePolicy,
savePolicyConfig,
commitPolicyConfig,
pausePolicy,
resumePolicy,
deletePolicy,
reorderPolicies,
ensurePolicyFolder,
};
return { policies };
}
@@ -8,7 +8,6 @@
import apiClient from "@app/services/apiClient";
import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl";
import type {
BackendPipelineDefinition,
BackendPolicy,
PolicyExecutionTarget,
PolicyRunView,
@@ -20,16 +19,6 @@ interface JobResponse {
result: unknown;
}
// --- Policy config persistence (server-side store, JPA-backed) ---
/** Create or update a policy; the backend assigns a blank id and returns it. */
export async function savePolicy(
policy: BackendPolicy,
): Promise<BackendPolicy> {
const res = await apiClient.post<BackendPolicy>("/api/v1/policies", policy);
return res.data;
}
/** List all stored policies. */
export async function listPolicies(): Promise<BackendPolicy[]> {
const res = await apiClient.get<BackendPolicy[]>("/api/v1/policies", {
@@ -38,28 +27,6 @@ export async function listPolicies(): Promise<BackendPolicy[]> {
return res.data;
}
/** Fetch a stored policy by id. */
export async function getPolicy(id: string): Promise<BackendPolicy> {
const res = await apiClient.get<BackendPolicy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
return res.data;
}
/** Delete a stored policy by id. */
export async function deletePolicy(id: string): Promise<void> {
await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`);
}
/**
* Persist the team's run order (server-side, shared by the whole team). Sends the
* ordered backend policy ids; the backend maps position → order and ignores any
* id outside the caller's team. Team-leader/admin only (403 otherwise).
*/
export async function reorderPolicies(orderedIds: string[]): Promise<void> {
await apiClient.put("/api/v1/policies/order", orderedIds);
}
/**
* Run a stored policy by id; returns the run id. `fileId` is this workspace's own opaque id, recorded
* against any failure of the run. Only honoured for a single-document run, and never a filename.
@@ -83,28 +50,6 @@ export async function runStoredPolicy(
return res.data.jobId;
}
// --- Ad-hoc pipeline runs (no stored policy) ---
/**
* Run an ad-hoc pipeline on the backend over the given documents. Returns the
* run id; poll {@link getPolicyRun} for status + output file ids.
*/
export async function runPolicyPipeline(
definition: BackendPipelineDefinition,
files: File[],
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
// The backend binds this as a typed @RequestPart, so it must be an application/json part.
form.append(
"json",
new Blob([JSON.stringify(definition)], { type: "application/json" }),
);
// No Content-Type: let the client set multipart/form-data with its boundary.
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form);
return res.data.jobId;
}
/**
* Where a policy run executes, and thus the backend that holds its outputs.
*/
@@ -1,20 +1,17 @@
/**
* Backend source-of-truth layer for Policies. Wraps the raw `policyApi` client +
* the `policyPipeline` mapper into category-shaped operations the hook can use:
* fetch the stored policies (grouped by catalog category), persist one, flip its
* enabled flag, and delete it.
* Backend source-of-truth read layer for Policies: fetch the stored policies
* (grouped by catalog category) and decode them onto the frontend's per-category
* state for the editor's enforcement path.
*
* The frontend is category-keyed (one policy per catalog category); the backend
* is a flat list with assigned ids. The bridge is `trigger.options.categoryId`,
* which `policyPipeline` encodes on save and decodes on read.
* which `policyPipeline` decodes on read.
*/
import * as policyApi from "@app/services/policyApi";
import {
buildBackendPolicy,
fromBackendPolicy,
type DecodedPolicy,
type PolicyToStore,
} from "@app/services/policyPipeline";
import type { PolicyState } from "@app/types/policies";
@@ -70,39 +67,3 @@ export function decodedToState(
isDefault: Boolean(decoded.categoryId),
};
}
/**
* The backend id of the stored policy for a category, if one exists. Used to
* enforce one-policy-per-category: a save reuses this id (update) rather than
* creating a duplicate, even if the local cache lost the link.
*/
export async function findBackendId(
categoryId: string,
): Promise<string | undefined> {
const byCategory = await fetchPoliciesByCategory();
return byCategory.get(categoryId)?.id;
}
/** Persist a policy (create or update); returns the backend-assigned id. */
export async function persistPolicy(store: PolicyToStore): Promise<string> {
const saved = await policyApi.savePolicy(buildBackendPolicy(store));
return saved.id;
}
/**
* Flip a stored policy's `enabled` flag (pause/resume) — the backend gates
* automatic triggering on it. Reads the current policy so the rest of its config
* is preserved on the round-trip.
*/
export async function setPolicyEnabled(
backendId: string,
enabled: boolean,
): Promise<void> {
const current = await policyApi.getPolicy(backendId);
await policyApi.savePolicy({ ...current, enabled });
}
/** Delete a stored policy by its backend id. */
export async function removePolicy(backendId: string): Promise<void> {
await policyApi.deletePolicy(backendId);
}
@@ -1,85 +0,0 @@
import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, vi } from "vitest";
// jsdom's crypto has no 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 {
createPolicyFolder,
getPolicyOperations,
updatePolicyOperations,
setPolicyFolderPaused,
deletePolicyFolder,
} from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { automationStorage } from "@app/services/automationStorage";
import type { PolicyCategory } from "@app/types/policies";
const category: PolicyCategory = {
id: "security",
label: "Security",
icon: null,
desc: "Detect PII, encrypt, verify.",
};
const steps = [
{ operation: "sanitize", parameters: {} },
{ operation: "addPassword", parameters: {} },
];
describe("policyFolders backing-folder layer", () => {
beforeEach(async () => {
// Clean slate between tests (fake-indexeddb persists within a run).
for (const f of await watchedFolderStorage.getAllFolders()) {
await watchedFolderStorage.deleteFolder(f.id);
}
for (const a of await automationStorage.getAllAutomations()) {
await automationStorage.deleteAutomation(a.id);
}
});
it("creates a folder + automation tagged with the policy category", async () => {
const folder = await createPolicyFolder(category, steps);
expect(folder.policyCategoryId).toBe("security");
expect(folder.automationId).toBeTruthy();
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["sanitize", "addPassword"]);
});
it("updates the steps through the backing automation", async () => {
const folder = await createPolicyFolder(category, steps);
await updatePolicyOperations(folder.id, [
{ operation: "compress", parameters: {} },
]);
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["compress"]);
});
it("pauses/resumes via the backing folder flag", async () => {
const folder = await createPolicyFolder(category, steps);
await setPolicyFolderPaused(folder.id, true);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
true,
);
await setPolicyFolderPaused(folder.id, false);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
false,
);
});
it("deletes the folder and its automation", async () => {
const folder = await createPolicyFolder(category, steps);
const automationId = folder.automationId;
await deletePolicyFolder(folder.id);
expect(await watchedFolderStorage.getFolder(folder.id)).toBeNull();
expect(await automationStorage.getAutomation(automationId)).toBeNull();
});
});
@@ -1,137 +0,0 @@
/**
* Backing-folder layer for Policies. A configured policy's folder trigger,
* editable steps, output and run-state all live in a Watched Folders
* {@link WatchedFolder} (+ its {@link AutomationConfig}) — the policy reuses the
* Watched Folders engine rather than re-implementing execution. This module is
* the seam that creates and manages that backing record.
*
* The folder is tagged with `policyCategoryId` so the Watched Folders UI can
* filter it out (it's owned by Policies). The backing automation also rides
* along to the backend (in the saved policy's output.options) for round-trip;
* this folder remains the locally-editable copy.
*/
import { automationStorage } from "@app/services/automationStorage";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { WatchedFolder } from "@app/types/watchedFolders";
import type { PolicyCategory, PolicyFolderSettings } from "@app/types/policies";
/** Folder icon (a name string) used for each policy category's backing folder. */
const CATEGORY_FOLDER_ICON: Record<string, string> = {
ingestion: "StorageIcon",
security: "SecurityIcon",
compliance: "CheckIcon",
routing: "SwapHorizIcon",
retention: "StorageIcon",
};
const POLICY_FOLDER_ACCENT = "#3b82f6";
/**
* Create the backing folder for a policy: persist an automation from the given
* steps, then a WatchedFolder (the folder trigger) referencing it, tagged with
* the policy's category id. Returns the created folder.
*/
export async function createPolicyFolder(
category: PolicyCategory,
operations: AutomationOperation[],
): Promise<WatchedFolder> {
const automation = await automationStorage.saveAutomation({
name: `${category.label} Policy`,
description: `Pipeline for the ${category.label} policy`,
operations,
});
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId: automation.id,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/**
* Create the backing folder for a policy from an *already-saved* automation
* (e.g. one the workflow builder just created). Pairs with the wizard, where
* AutomationCreation persists the automation and we link a folder to it.
*/
export async function createPolicyFolderForAutomation(
category: PolicyCategory,
automationId: string,
): Promise<WatchedFolder> {
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/** The policy's current steps, resolved through its backing folder's automation. */
export async function getPolicyOperations(
folderId: string,
): Promise<AutomationOperation[]> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return [];
const automation = await automationStorage.getAutomation(folder.automationId);
return automation?.operations ?? [];
}
/** The policy's backing automation (its editable pipeline), via its folder. */
export async function getPolicyAutomation(
folderId: string,
): Promise<AutomationConfig | null> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return null;
return automationStorage.getAutomation(folder.automationId);
}
/** Replace the policy's steps by updating its backing automation. */
export async function updatePolicyOperations(
folderId: string,
operations: AutomationOperation[],
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
const automation = await automationStorage.getAutomation(folder.automationId);
if (!automation) return;
await automationStorage.updateAutomation({ ...automation, operations });
}
/** Apply output + retry settings to the policy's backing folder. */
export async function updatePolicyFolderSettings(
folderId: string,
settings: PolicyFolderSettings,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, ...settings });
}
/** Pause/resume the policy by toggling its backing folder's paused flag. */
export async function setPolicyFolderPaused(
folderId: string,
paused: boolean,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, isPaused: paused });
}
/** Delete the policy's backing folder and its automation. */
export async function deletePolicyFolder(folderId: string): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (folder) {
await automationStorage.deleteAutomation(folder.automationId);
}
await watchedFolderStorage.deleteFolder(folderId);
}
@@ -1,150 +1,44 @@
import { describe, it, expect } from "vitest";
import {
buildPipelineDefinition,
buildBackendPolicy,
fromBackendPolicy,
type BackendPolicy,
} from "@app/services/policyPipeline";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
// Minimal registry: a static-endpoint tool and a function-endpoint tool.
const registry = {
compress: { operationConfig: { endpoint: "/api/v1/misc/compress-pdf" } },
rotate: {
operationConfig: {
endpoint: (p: Record<string, unknown>) =>
`/api/v1/general/rotate-pdf?angle=${p.angle}`,
const backendPolicy: BackendPolicy = {
id: "p1",
name: "Security",
owner: "",
enabled: true,
trigger: null,
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: {
type: "inline",
options: {
mode: "new_version",
name: "secured",
position: "suffix",
maxRetries: 2,
retryDelayMinutes: 10,
automation: {
id: "auto-1",
name: "Security",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
categoryId: "security",
sources: [],
scopeTypes: ["Contracts"],
reviewerEmail: "me@x.com",
fieldValues: { minConfidence: "80%" },
},
},
} as unknown as Partial<ToolRegistry>;
describe("buildPipelineDefinition", () => {
it("maps frontend operations to backend endpoint steps", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "Secure Ingestion",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "rotate", parameters: { angle: 90 } },
],
},
registry,
);
expect(unresolved).toEqual([]);
expect(definition.name).toBe("Secure Ingestion");
expect(definition.outputs).toEqual([{ type: "inline", options: {} }]);
expect(definition.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
{
operation: "/api/v1/general/rotate-pdf?angle=90",
parameters: { angle: 90 },
},
]);
});
it("drops + reports operations with no resolvable endpoint", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "X",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "notARealTool", parameters: {} },
],
},
registry,
);
expect(unresolved).toEqual(["notARealTool"]);
expect(definition.steps).toHaveLength(1);
});
it("runs each tool's buildFormData so stored params match its endpoint", () => {
// A redact-like tool whose UI param (wordsToRedact[]) is transformed into
// the endpoint's field (listOfText) by buildFormData — the "marry up".
const redactish = {
redact: {
operationConfig: {
endpoint: () => "/api/v1/security/auto-redact",
buildFormData: (
p: { wordsToRedact?: string[]; useRegex?: boolean },
file: File,
) => {
const fd = new FormData();
fd.append("fileInput", file);
fd.append("listOfText", (p.wordsToRedact ?? []).join("\n"));
fd.append("useRegex", String(p.useRegex ?? false));
return fd;
},
},
},
} as unknown as Partial<ToolRegistry>;
const { definition } = buildPipelineDefinition(
{
name: "Security",
operations: [
{
operation: "redact",
parameters: { wordsToRedact: ["SSN", "Account"], useRegex: true },
},
],
},
redactish,
);
// The document field is dropped; the UI param became the endpoint field.
expect(definition.steps[0]).toEqual({
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "SSN\nAccount", useRegex: "true" },
});
});
});
const samplePolicy = {
categoryId: "security",
name: "Security",
enabled: true,
automation: {
id: "auto-1",
name: "Security",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
sources: [],
runsOnEditor: true,
scopeTypes: ["Contracts"],
reviewerEmail: "me@x.com",
fieldValues: { minConfidence: "80%" },
folder: {
runOn: "export" as const,
outputMode: "new_version" as const,
outputName: "secured",
outputNamePosition: "suffix" as const,
maxRetries: 2,
retryDelayMinutes: 10,
},
editor: { allowed: true, runOn: "export" },
};
describe("buildBackendPolicy", () => {
it("maps a frontend policy to the backend Policy shape", () => {
const policy = buildBackendPolicy(samplePolicy);
expect(policy.id).toBe(""); // blank → backend assigns
expect(policy.name).toBe("Security");
expect(policy.enabled).toBe(true);
expect(policy.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
]);
// Manual-only policy: no server-side trigger, extras ride in output.options.
expect(policy.trigger).toBeNull();
expect(policy.output.options.categoryId).toBe("security");
expect(policy.output.options.reviewerEmail).toBe("me@x.com");
expect(policy.output.options.maxRetries).toBe(2);
});
it("round-trips losslessly through fromBackendPolicy", () => {
const policy = buildBackendPolicy(samplePolicy);
const decoded = fromBackendPolicy({ ...policy, id: "p1" });
describe("fromBackendPolicy", () => {
it("decodes a stored policy's output.options bag into frontend settings", () => {
const decoded = fromBackendPolicy(backendPolicy);
expect(decoded.id).toBe("p1");
expect(decoded.categoryId).toBe("security");
expect(decoded.enabled).toBe(true);
@@ -153,9 +47,16 @@ describe("buildBackendPolicy", () => {
expect(decoded.scopeTypes).toEqual(["Contracts"]);
expect(decoded.reviewerEmail).toBe("me@x.com");
expect(decoded.fieldValues).toEqual({ minConfidence: "80%" });
expect(decoded.folder).toEqual(samplePolicy.folder);
expect(decoded.automation?.operations).toEqual(
samplePolicy.automation.operations,
);
expect(decoded.folder).toEqual({
runOn: "export",
outputMode: "new_version",
outputName: "secured",
outputNamePosition: "suffix",
maxRetries: 2,
retryDelayMinutes: 10,
});
expect(decoded.automation?.operations).toEqual([
{ operation: "compress", parameters: {} },
]);
});
});
@@ -13,7 +13,6 @@
import { resolveRunOn, type PolicyRunOn } from "@app/policies/runOn";
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { PolicyFolderSettings } from "@app/types/policies";
/** A single backend pipeline step: a tool endpoint path + its scalar params. */
@@ -29,14 +28,6 @@ export interface BackendOutputSpec {
options: Record<string, unknown>;
}
/** The engine-level pipeline the `/run` endpoint accepts (as JSON). */
export interface BackendPipelineDefinition {
name: string;
steps: BackendPipelineStep[];
/** Destinations a run's files are delivered to; a single inline entry for one-off/editor runs. */
outputs: BackendOutputSpec[];
}
/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
export interface BackendTriggerConfig {
type: string;
@@ -111,127 +102,6 @@ export interface PolicyRunView {
createdAt: number;
}
/**
* Operations that run as policy pipeline steps but are NOT user-facing tools, so
* they have no tool-registry entry and never appear in the tool picker. Maps the
* operation id straight to its backend endpoint.
*/
const POLICY_OPERATION_ENDPOINTS: Record<string, string> = {
// Document classification — dispatched only by the Classification policy.
classify: "/api/v1/ai/tools/classify-and-label",
};
/** Resolve a frontend operation id to its backend tool endpoint path. */
function resolveEndpoint(
operation: string,
parameters: Record<string, unknown>,
toolRegistry: Partial<ToolRegistry>,
): string | null {
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
const endpoint = config?.endpoint;
if (endpoint) {
const resolved =
typeof endpoint === "function" ? endpoint(parameters) : endpoint;
if (resolved) return resolved;
}
// Policy-only operations have no registry entry; resolve them directly.
return POLICY_OPERATION_ENDPOINTS[operation] ?? null;
}
/**
* Convert a tool's UI parameters into the exact scalar form-fields its backend
* endpoint expects, by running the same `buildFormData` the client-side runner
* uses (the one source of truth for the request shape) and keeping its non-file
* fields. This is what makes the stored steps "marry up" with the engine: e.g.
* redact's `wordsToRedact: string[]` becomes the `listOfText` string the
* /auto-redact endpoint reads. Falls back to the raw params if the tool has no
* transform (or it throws), so tools without one are unaffected.
*/
function toApiParameters(
config: ToolRegistry[keyof ToolRegistry]["operationConfig"] | undefined,
parameters: Record<string, unknown>,
): Record<string, unknown> {
const build = config?.buildFormData;
if (typeof build !== "function") return parameters;
const dummy = new File([], "input.pdf", { type: "application/pdf" });
// buildFormData takes a File (single-file tools) or File[] (multi) — try both.
for (const fileArg of [dummy, [dummy]]) {
try {
const formData = build(parameters, fileArg as never);
const out: Record<string, unknown> = {};
// Keep scalar fields; skip File entries (the document(s) the engine feeds
// separately, and any supporting-file blobs).
formData.forEach((value, key) => {
if (typeof value === "string") out[key] = value;
});
return out;
} catch {
// Wrong file-arg shape for this tool — try the other, then give up.
}
}
return parameters;
}
/**
* Map a frontend automation to the backend pipeline definition. Steps whose
* endpoint can't be resolved from the registry are dropped (and reported), so
* the backend never receives an unrunnable operation id.
*/
export function buildPipelineDefinition(
automation: Pick<AutomationConfig, "name" | "operations">,
toolRegistry: Partial<ToolRegistry>,
): { definition: BackendPipelineDefinition; unresolved: string[] } {
const unresolved: string[] = [];
const steps: BackendPipelineStep[] = [];
for (const op of automation.operations) {
const parameters = (op.parameters ?? {}) as Record<string, unknown>;
const endpoint = resolveEndpoint(op.operation, parameters, toolRegistry);
if (!endpoint) {
unresolved.push(op.operation);
continue;
}
const config =
toolRegistry[op.operation as keyof ToolRegistry]?.operationConfig;
steps.push({
operation: endpoint,
parameters: toApiParameters(config, parameters),
});
}
return {
definition: {
name: automation.name,
steps,
outputs: [{ type: "inline", options: {} }],
},
unresolved,
};
}
/** A frontend policy ready to persist on the backend (the full settings set). */
export interface PolicyToStore {
/** Existing backend id (blank/omitted → create). */
id?: string;
/** The frontend catalog category this policy belongs to (1 policy per category). */
categoryId: string;
name: string;
/** Active (enabled) vs paused/off. */
enabled: boolean;
/** Full frontend automation, stashed for a lossless UI round-trip. */
automation: AutomationConfig;
/**
* The engine-runnable steps (endpoint paths), pre-built from `automation` via
* the tool registry by the caller that has it (the wizard). The store layer
* has no registry, so it receives these ready-made.
*/
pipelineSteps: BackendPipelineStep[];
sources: string[];
runsOnEditor: boolean;
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
folder: PolicyFolderSettings;
}
/** The decoded policy read back from the backend. */
export interface DecodedPolicy {
id: string;
@@ -262,50 +132,6 @@ const DEFAULT_FOLDER: PolicyFolderSettings = {
retryDelayMinutes: 5,
};
/**
* Map a frontend policy to the backend {@link BackendPolicy} for persistence.
* Policies are manual-only (client-driven): the editor fires runs on upload /
* before export via /run, so `trigger` is null (a server-side folder-watch or
* schedule trigger doesn't fit the in-editor model, and a null trigger skips
* trigger validation on the backend). The backend models only
* name/enabled/trigger/steps/output, so the policy-level extras (categoryId,
* sources, scope, reviewer, fields) and the output + retry settings all ride in
* `output.options`; the full frontend automation is stashed in
* `output.options.automation` for a lossless UI round-trip (while `steps`
* carries the endpoint-mapped pipeline the engine runs, pre-built by the caller).
*/
export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
return {
id: input.id ?? "",
name: input.name,
owner: "",
enabled: input.enabled,
trigger: null,
steps: input.pipelineSteps,
output: {
type: "inline",
options: {
mode: input.folder.outputMode,
name: input.folder.outputName,
position: input.folder.outputNamePosition,
maxRetries: input.folder.maxRetries,
retryDelayMinutes: input.folder.retryDelayMinutes,
automation: input.automation,
// Policy-level metadata (no trigger bag to hold it any more).
categoryId: input.categoryId,
sources: input.sources,
scopeTypes: input.scopeTypes,
reviewerEmail: input.reviewerEmail,
fieldValues: input.fieldValues,
},
},
editor: {
allowed: input.runsOnEditor,
runOn: input.folder.runOn,
},
};
}
/** Decode a stored backend policy back into the frontend settings. */
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
const output = policy.output.options;
@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import {
loadPolicies,
updatePolicy,
resetPolicy,
onPoliciesChange,
} from "@app/services/policyStorage";
@@ -37,18 +36,6 @@ describe("policyStorage", () => {
expect(p.security.reviewerEmail).toBe("x@y.com");
});
it("resetPolicy reverts a category to unconfigured default", () => {
updatePolicy("compliance", {
configured: true,
status: "active",
reviewerEmail: "a@b.com",
});
resetPolicy("compliance");
const p = loadPolicies();
expect(p.compliance.configured).toBe(false);
expect(p.compliance.status).toBe("default");
});
it("heals missing categories from corrupt/partial storage", () => {
localStorage.setItem(
"stirling-policies-state",
@@ -113,24 +113,6 @@ export function updatePolicy(
return next;
}
/**
* Persist a new execution order. Assigns `order` 0..n-1 to the given categories in
* the sequence provided, so after any reorder every listed policy has an explicit,
* contiguous order (no reliance on the catalog-index default). Categories omitted
* from the list keep their current order.
*/
export function reorderPolicies(
orderedCategoryIds: string[],
): PoliciesByCategory {
const current = loadPolicies();
const next: PoliciesByCategory = { ...current };
orderedCategoryIds.forEach((id, index) => {
if (next[id]) next[id] = { ...next[id], order: index };
});
persist(next);
return next;
}
/**
* Drop cached entries entirely (no default seeded back). For builder pipelines the backend has
* deleted: keyed by their own id, they have no built-in category to fall back to, so a left-behind
@@ -151,18 +133,6 @@ export function forgetPolicies(ids: string[]): PoliciesByCategory {
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
...defaultState(categoryId),
configured: false,
status: "default",
// Drop the backing-folder + backend links (the caller deletes those).
folderId: undefined,
backendId: undefined,
});
}
/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */
export function onPoliciesChange(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
@@ -9,10 +9,7 @@
*/
import type { ReactNode } from "react";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { AutomationOperation } from "@app/types/automation";
/** Lifecycle status of a policy category for the current user/org. */
export type PolicyStatus = "default" | "active" | "paused";
@@ -153,52 +150,3 @@ export interface PolicyFolderSettings {
maxRetries: number;
retryDelayMinutes: number;
}
/** Everything the shared policy wizard collects, handed back on submit. */
export interface PolicyWizardResult {
/** The saved workflow automation (created on setup, updated in place on edit). */
automation: AutomationConfig;
fieldValues: Record<string, boolean | string | string[]>;
sources: string[];
runsOnEditor: boolean;
scopeTypes: string[];
reviewerEmail: string;
/** Output + retry settings for the backing folder. */
folder: PolicyFolderSettings;
/**
* Backend pipeline steps (each `operation` is a tool ENDPOINT path), built
* from the workflow via the tool registry in the wizard's Workflow step — the
* hook persists these to the backend without needing the registry itself.
* Structurally matches policyPipeline's `BackendPipelineStep` (inlined here to
* avoid a types↔services import cycle).
*/
pipelineSteps: {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}[];
/** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
unresolvedOps: string[];
}
/**
* What the locked tool-config page hands back on save. Unlike the wizard's
* result it carries the tool `operations` directly (the page owns a fixed,
* configure-only chain — there's no separate saved automation), plus the
* endpoint-mapped pipeline steps. Used for both first-time configure and edits
* of a preset policy.
*/
export interface PolicyConfigResult {
/** The enabled tools (in order) as automation operations. */
operations: AutomationOperation[];
/** Endpoint-mapped backend steps built from `operations` via the registry. */
pipelineSteps: PolicyWizardResult["pipelineSteps"];
/** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
unresolvedOps: string[];
fieldValues: Record<string, boolean | string | string[]>;
sources: string[];
runsOnEditor: boolean;
scopeTypes: string[];
reviewerEmail: string;
folder: PolicyFolderSettings;
}