Policies tidying (#6587)

# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
This commit is contained in:
James Brunton
2026-06-11 13:20:01 +01:00
committed by GitHub
parent c722b9f6ad
commit 68e031ac55
47 changed files with 582 additions and 612 deletions
@@ -3077,14 +3077,6 @@ addTo = "Add to"
hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy."
title = "Connect your AI assistant"
[config.mcp.tools]
ai = "AI"
convert = "Convert"
misc = "Misc"
pages = "Pages"
security = "Security"
title = "What your assistant can do"
[config.overview]
description = "Current application settings and configuration details."
error = "Error"
@@ -8193,9 +8185,6 @@ confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
[policies]
sidebarTitle = "Policies"
[watchedFolders]
alreadyInFolder = "Already in this folder"
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
@@ -74,7 +74,11 @@ export async function runPolicyPipeline(
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
form.append("json", JSON.stringify(definition));
// 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" }),
);
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form, {
headers: { "Content-Type": "multipart/form-data" },
});
@@ -133,9 +133,10 @@ describe("buildBackendPolicy", () => {
expect(policy.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
]);
// Extras ride in options.
expect(policy.trigger.options.categoryId).toBe("security");
expect(policy.trigger.options.reviewerEmail).toBe("[email protected]");
// Trigger is null (browser-driven); extras ride in output.options.
expect(policy.trigger).toBeNull();
expect(policy.output.options.categoryId).toBe("security");
expect(policy.output.options.reviewerEmail).toBe("[email protected]");
expect(policy.output.options.maxRetries).toBe(2);
});
@@ -35,7 +35,7 @@ export interface BackendPipelineDefinition {
output: BackendOutputSpec;
}
/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
/** A policy's automatic trigger ("schedule" | "folder-watch"); null means manual (run on demand). */
export interface BackendTriggerConfig {
type: string;
options: Record<string, unknown>;
@@ -49,7 +49,8 @@ export interface BackendPolicy {
owner: string;
/** Gates automatic triggering; an explicit run ignores it. */
enabled: boolean;
trigger: BackendTriggerConfig;
/** Null = manual; these policies are run from the browser on uploaded files. */
trigger: BackendTriggerConfig | null;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
}
@@ -188,7 +189,7 @@ export interface PolicyToStore {
/** The decoded policy read back from the backend. */
export interface DecodedPolicy {
id: string;
/** The catalog category this policy maps to (from trigger.options.categoryId). */
/** The catalog category this policy maps to (from output.options.categoryId). */
categoryId: string;
name: string;
enabled: boolean;
@@ -210,13 +211,11 @@ const DEFAULT_FOLDER: PolicyFolderSettings = {
};
/**
* Map a frontend policy to the backend {@link BackendPolicy} for persistence.
* The backend models only name/enabled/trigger/steps/output, so the policy-level
* extras (categoryId, sources, scope, reviewer, fields) ride in `trigger.options`
* and the output + retry settings 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).
* Map a frontend policy to the backend {@link BackendPolicy} for persistence. Trigger is null:
* these policies are run from the browser on uploaded files, not fired by a backend trigger. The
* backend models only name/enabled/trigger/steps/output, so all policy-level extras (categoryId,
* sources, scope, reviewer, fields, output/retry settings, and the full automation for a lossless
* UI round-trip) ride in `output.options`; `steps` carries the endpoint-mapped pipeline.
*/
export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
return {
@@ -224,16 +223,7 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
name: input.name,
owner: "",
enabled: input.enabled,
trigger: {
type: "folder",
options: {
categoryId: input.categoryId,
sources: input.sources,
scopeTypes: input.scopeTypes,
reviewerEmail: input.reviewerEmail,
fieldValues: input.fieldValues,
},
},
trigger: null,
steps: input.pipelineSteps,
output: {
type: "inline",
@@ -244,6 +234,11 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
maxRetries: input.folder.maxRetries,
retryDelayMinutes: input.folder.retryDelayMinutes,
automation: input.automation,
categoryId: input.categoryId,
sources: input.sources,
scopeTypes: input.scopeTypes,
reviewerEmail: input.reviewerEmail,
fieldValues: input.fieldValues,
},
},
};
@@ -251,7 +246,6 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
/** Decode a stored backend policy back into the frontend settings. */
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
const trigger = policy.trigger.options;
const output = policy.output.options;
const str = (v: unknown, fallback = "") =>
typeof v === "string" ? v : fallback;
@@ -259,19 +253,17 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
typeof v === "number" ? v : fallback;
return {
id: policy.id,
categoryId: str(trigger.categoryId),
categoryId: str(output.categoryId),
name: policy.name,
enabled: policy.enabled,
automation: (output.automation as AutomationConfig | undefined) ?? null,
sources: Array.isArray(trigger.sources)
? (trigger.sources as string[])
sources: Array.isArray(output.sources) ? (output.sources as string[]) : [],
scopeTypes: Array.isArray(output.scopeTypes)
? (output.scopeTypes as string[])
: [],
scopeTypes: Array.isArray(trigger.scopeTypes)
? (trigger.scopeTypes as string[])
: [],
reviewerEmail: str(trigger.reviewerEmail),
reviewerEmail: str(output.reviewerEmail),
fieldValues:
(trigger.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
(output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
folder: {
outputMode: output.mode === "new_version" ? "new_version" : "new_file",
outputName: str(output.name),