Move editor participation onto the policy and address review feedback

This commit is contained in:
Anthony Stirling
2026-08-28 13:40:00 +01:00
committed by James Brunton
parent 64eb44e627
commit 37c8621426
34 changed files with 495 additions and 181 deletions
@@ -336,6 +336,15 @@ public class PolicyController {
* nothing to check.
*/
private void requireAccessibleOutput(Policy policy) {
// An editor policy hands its results back to the workspace the file came from. A stored
// destination would send the run to a folder or bucket instead, leaving the editor's copy
// untouched - and the editor's import would then have nothing to collect.
if (policy.editor().allowed() && !policy.outputIds().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"An editor policy delivers back to the editor and can't also have a"
+ " destination");
}
for (String outputId : policy.outputIds()) {
Source destination =
sourceStore
@@ -393,7 +402,8 @@ public class PolicyController {
policy.steps(),
policy.output(),
policy.outputIds(),
teamId);
teamId,
policy.editor());
}
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
@@ -0,0 +1,34 @@
package stirling.software.proprietary.policy.model;
/**
* How a policy participates in the editor: it fires in the browser as each file passes through,
* rather than being swept from a stored {@code Source} on a trigger.
*
* <p>An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the
* decision, and so later editor-only settings have somewhere to live.
*
* @param allowed whether the editor may run this policy at all
* @param runOn which moment it fires on: {@code "upload"} or {@code "export"}
*/
public record EditorConfig(boolean allowed, String runOn) {
public static final String UPLOAD = "upload";
public static final String EXPORT = "export";
public EditorConfig {
runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD;
}
/** Not an editor policy: swept server-side, or run only on demand. */
public static EditorConfig disabled() {
return new EditorConfig(false, UPLOAD);
}
public static EditorConfig onUpload() {
return new EditorConfig(true, UPLOAD);
}
public static EditorConfig onExport() {
return new EditorConfig(true, EXPORT);
}
}
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
import java.util.Optional;
/**
* A stored automation: ordered tool steps, input bindings, and output destinations.
@@ -24,13 +25,29 @@ public record Policy(
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId) {
Long teamId,
EditorConfig editor) {
public Policy {
inputs = inputs == null ? List.of() : List.copyOf(inputs);
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
outputIds = outputIds == null ? List.of() : List.copyOf(outputIds);
editor = editor == null ? EditorConfig.disabled() : editor;
}
/** Without editor participation: a swept or on-demand policy. */
public Policy(
String id,
String name,
String owner,
boolean enabled,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId) {
this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null);
}
/**
@@ -71,6 +88,14 @@ public record Policy(
}
/** The distinct trigger types configured across this policy's inputs (manual inputs aside). */
/**
* The moment this policy fires in the editor ("upload" / "export"), or empty when the editor
* does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read.
*/
public Optional<String> editorRunOn() {
return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty();
}
public List<String> triggerTypes() {
return inputs.stream()
.map(PipelineInput::trigger)
@@ -82,17 +107,20 @@ public record Policy(
/** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */
public Policy withOutput(OutputSpec resolved) {
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
return new Policy(
id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor);
}
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
public Policy withOwner(String newOwner) {
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
return new Policy(
id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor);
}
/** A copy referencing the given saved output destinations. */
public Policy withOutputIds(List<String> newOutputIds) {
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
return new Policy(
id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor);
}
/**
@@ -114,10 +114,14 @@ public class PolicyOverviewService {
/**
* Summarise a policy's triggers for the overview row: "manual" when no input is triggered,
* otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule").
*
* <p>An editor policy has no wire input to trigger, but it is not manual either - it fires in
* the editor on every upload or export, so it reports that rather than reading as on-demand.
*/
private static String triggerSummary(Policy policy) {
List<String> types = policy.triggerTypes();
return types.isEmpty() ? "manual" : String.join(", ", types);
if (!types.isEmpty()) return String.join(", ", types);
return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual");
}
private static String outputSummary(OutputSpec output) {
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.TeamCreatedEvent;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder {
static Policy defaultPolicy(Long teamId) {
Map<String, Object> options = new HashMap<>();
options.put("categoryId", CATEGORY);
options.put("runOn", "upload");
options.put("mode", "new_version");
options.put("sources", List.of("editor"));
options.put("sources", List.of());
options.put("scopeTypes", List.of());
options.put("reviewerEmail", "");
return new Policy(
@@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder {
List.of(),
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
new OutputSpec("inline", options),
teamId);
List.of(),
teamId,
// Classification runs in the editor on every upload.
EditorConfig.onUpload());
}
}
@@ -38,7 +38,8 @@ public class InProcessPolicyStore implements PolicyStore {
policy.steps(),
policy.output(),
policy.outputIds(),
policy.teamId());
policy.teamId(),
policy.editor());
policies.put(id, stored);
// Existing policy keeps its position; a new one appends to the end of its team's queue.
sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId()));
@@ -48,7 +48,8 @@ public class JpaPolicyStore implements PolicyStore {
policy.steps(),
policy.output(),
policy.outputIds(),
policy.teamId());
policy.teamId(),
policy.editor());
PolicyEntity entity = new PolicyEntity();
entity.setId(id);
@@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineInput;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -223,6 +224,44 @@ class PolicyOverviewServiceTest {
teamId));
}
@Test
void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() {
policyStore.save(
new Policy(
null,
"Editor flatten",
"owner",
true,
List.of(),
List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())),
OutputSpec.inline(),
List.of(),
1L,
EditorConfig.onUpload()));
PolicyView view = find(service.overview(), "Editor flatten");
assertEquals("editor-upload", view.trigger());
}
@Test
void sweptPolicyWithNoTriggeredInputIsStillManual() {
policyStore.save(
new Policy(
null,
"Swept compress",
"owner",
true,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
1L));
PolicyView view = find(service.overview(), "Swept compress");
assertEquals("manual", view.trigger());
}
private static PolicyView find(PoliciesOverviewResponse response, String name) {
return response.pipelines().stream()
.filter(view -> view.name().equals(name))
@@ -64,9 +64,10 @@ class DefaultClassificationPolicySeederTest {
assertThat(policy.teamId()).isEqualTo(7L);
assertThat(policy.output().type()).isEqualTo("inline");
assertThat(policy.output().options().get("categoryId")).isEqualTo("classification");
assertThat(policy.output().options().get("runOn")).isEqualTo("upload");
assertThat(policy.output().options().get("mode")).isEqualTo("new_version");
assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor"));
// Editor participation is the policy's own flag, not a marker in the output options.
assertThat(policy.editor().allowed()).isTrue();
assertThat(policy.editor().runOn()).isEqualTo("upload");
assertThat(policy.steps()).hasSize(1);
assertThat(policy.steps().get(0).operation())
.isEqualTo("/api/v1/ai/tools/classify-and-label");
@@ -8205,6 +8205,9 @@ chooseDestination = "Choose a destination"
chooseOperation = "Choose what this step does"
chooseSource = "Choose a source"
discard = "Discard changes"
editorDestination = "Editor"
editorDestinationDetail = "Replaces the file you ran it on"
editorDestinationHelp = "This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them."
inputs = "Input"
inputSource = "Input source"
inputTrigger = "Trigger"
@@ -8218,7 +8221,7 @@ pause = "Pause"
rename = "Rename pipeline"
runOn = "Runs on"
runOnExport = "Every export"
runOnHelper = "Editor pipelines run in the browser as each file passes through - there is no server-side sweep to schedule."
runOnTooltip = "Choose when this pipeline runs on your files: when you add them, or when you export them."
runOnUpload = "Every upload"
searchTools = "Search tools"
sendToSystem = "Send to another system"
@@ -8360,6 +8363,8 @@ steps = "Steps"
trigger = "Trigger"
[portal.pipelines.trigger]
editor-export = "Every export"
editor-upload = "Every upload"
folder-watch = "Folder watch"
manual = "Manual"
schedule = "Scheduled"
@@ -7,6 +7,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { useTranslation } from "react-i18next";
import { getFileSize } from "@app/utils/fileUtils";
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
@@ -115,7 +116,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.toolHistory
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
.map((tool) => toolOperationLabel(tool, t))
.join(" → ")}
</Text>
)}
@@ -10,7 +10,7 @@ import HistoryIcon from "@mui/icons-material/History";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { FileId, ToolOperation } from "@app/types/file";
import { ToolId } from "@app/types/toolId";
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
@@ -64,10 +64,10 @@ function deltaToolFor(
return curr[priorLen] ?? null;
}
/** Translated tool name via `home.{toolId}.title`. */
function ToolLabel({ toolId }: { toolId: ToolId }) {
/** The operation's own label when it has one, else its translated tool name. */
function ToolLabel({ operation }: { operation: ToolOperation }) {
const { t } = useTranslation();
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
return <span>{toolOperationLabel(operation, t)}</span>;
}
export interface VersionTimelineProps {
@@ -242,7 +242,7 @@ export function VersionTimeline({
style={{ color: "var(--c-text)" }}
>
{delta ? (
<ToolLabel toolId={delta.toolId} />
<ToolLabel operation={delta} />
) : (
t("filesPage.versionOrigin", "Original upload")
)}
@@ -6,8 +6,8 @@
import React from "react";
import { Text, Tooltip, Badge, Group } from "@mantine/core";
import { ToolOperation } from "@app/types/file";
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
import { useTranslation } from "react-i18next";
import { ToolId } from "@app/types/toolId";
interface ToolChainProps {
toolChain: ToolOperation[];
@@ -29,11 +29,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
const { t } = useTranslation();
if (!toolChain || toolChain.length === 0) return null;
const toolIds = toolChain.map((tool) => tool.toolId);
const getToolName = (toolId: ToolId) => {
return t(`home.${toolId}.title`, toolId);
};
const getToolName = (tool: ToolOperation) => toolOperationLabel(tool, t);
// Create full tool chain for tooltip
const fullChainDisplay =
@@ -42,7 +38,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
{toolChain.map((tool, index) => (
<React.Fragment key={`${tool.toolId}-${index}`}>
<Badge size="sm" variant="light" color="blue">
{getToolName(tool.toolId)}
{getToolName(tool)}
</Badge>
{index < toolChain.length - 1 && (
<Text size="sm" c="dimmed">
@@ -53,18 +49,21 @@ const ToolChain: React.FC<ToolChainProps> = ({
))}
</Group>
) : (
<Text size="sm">{toolIds.map(getToolName).join(" → ")}</Text>
<Text size="sm">{toolChain.map(getToolName).join(" → ")}</Text>
);
// Create truncated display based on available space
const getTruncatedDisplay = () => {
if (toolIds.length <= 2) {
if (toolChain.length <= 2) {
// Show all tools if 2 or fewer
return { text: toolIds.map(getToolName).join(" → "), isTruncated: false };
return {
text: toolChain.map(getToolName).join(" → "),
isTruncated: false,
};
} else {
// Show first tool ... last tool for longer chains
return {
text: `${getToolName(toolIds[0])} → +${toolIds.length - 2}${getToolName(toolIds[toolIds.length - 1])}`,
text: `${getToolName(toolChain[0])} → +${toolChain.length - 2}${getToolName(toolChain[toolChain.length - 1])}`,
isTruncated: true,
};
}
@@ -75,10 +74,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
// Compact style for very small spaces
if (displayStyle === "compact") {
const compactText =
toolIds.length === 1
? getToolName(toolIds[0])
: `${toolIds.length} tools`;
const isCompactTruncated = toolIds.length > 1;
toolChain.length === 1
? getToolName(toolChain[0])
: `${toolChain.length} tools`;
const isCompactTruncated = toolChain.length > 1;
const compactElement = (
<Text
@@ -116,7 +115,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
{toolChain.slice(0, 3).map((tool, index) => (
<React.Fragment key={`${tool.toolId}-${index}`}>
<Badge size={size} variant="light" color="blue">
{getToolName(tool.toolId)}
{getToolName(tool)}
</Badge>
{index < Math.min(toolChain.length - 1, 2) && (
<Text size="xs" c="dimmed">
@@ -131,7 +130,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
...
</Text>
<Badge size={size} variant="light" color="blue">
{getToolName(toolChain[toolChain.length - 1].toolId)}
{getToolName(toolChain[toolChain.length - 1])}
</Badge>
</>
)}
@@ -140,7 +139,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
);
return isBadgesTruncated ? (
<Tooltip label={`${toolIds.map(getToolName).join(" → ")}`} withinPortal>
<Tooltip label={`${toolChain.map(getToolName).join(" → ")}`} withinPortal>
{badgesElement}
</Tooltip>
) : (
@@ -14,6 +14,8 @@ export async function createStirlingFilesAndStubs(
files: File[],
parentStub: StirlingFileStub,
toolId: ToolId,
/** Shown instead of the tool's name in version history (a policy passes its pipeline name). */
label?: string,
): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> {
const stirlingFiles: StirlingFile[] = [];
const stubs: StirlingFileStub[] = [];
@@ -22,7 +24,7 @@ export async function createStirlingFilesAndStubs(
const processedFileMetadata = await generateProcessedFileMetadata(file);
const childStub = createChildStub(
parentStub,
{ toolId, timestamp: Date.now() },
{ toolId, timestamp: Date.now(), ...(label ? { label } : {}) },
file,
processedFileMetadata?.thumbnailUrl,
processedFileMetadata,
@@ -2,11 +2,7 @@ import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
/**
* PR #7581: a pipeline built on the Pipelines page can be set to run on the editor.
* It has no catalogue category, so it must reach the auto-run by naming "editor" in
* its sources - and a swept pipeline (blank sources) must NOT be mistaken for one.
*/
// A pipeline reaches the editor auto-run through its own editor flag; a swept one must not.
test.use({ autoGoto: false });
@@ -16,7 +12,7 @@ const SAMPLE = path.join(
);
/** A builder-made pipeline: no categoryId, one harmless step. */
function builderPipeline(options: Record<string, unknown>) {
function builderPipeline(editor: { allowed: boolean; runOn: string }) {
return {
id: "builder-pipeline-1",
name: "Flatten everything",
@@ -25,7 +21,8 @@ function builderPipeline(options: Record<string, unknown>) {
trigger: null,
sourceIds: [],
steps: [{ operation: "/api/v1/misc/flatten", parameters: {} }],
output: { type: "inline", options: { mode: "new_version", ...options } },
output: { type: "inline", options: { mode: "new_version" } },
editor,
teamId: 1,
};
}
@@ -48,7 +45,7 @@ test("an editor pipeline set to run on upload dispatches when a file is added",
}) => {
const dispatched = await armed(
page,
builderPipeline({ sources: ["editor"], runOn: "upload" }),
builderPipeline({ allowed: true, runOn: "upload" }),
);
await page.goto("/editor", { waitUntil: "domcontentloaded" });
@@ -59,12 +56,10 @@ test("an editor pipeline set to run on upload dispatches when a file is added",
.toContain("/api/v1/policies/builder-pipeline-1/run");
});
test("a swept pipeline (blank sources) never runs on editor upload", async ({
page,
}) => {
test("a swept pipeline never runs on editor upload", async ({ page }) => {
const dispatched = await armed(
page,
builderPipeline({ sources: [], runOn: "upload" }),
builderPipeline({ allowed: false, runOn: "upload" }),
);
await page.goto("/editor", { waitUntil: "domcontentloaded" });
@@ -79,7 +74,7 @@ test("an editor pipeline set to run on export does not fire on upload", async ({
}) => {
const dispatched = await armed(
page,
builderPipeline({ sources: ["editor"], runOn: "export" }),
builderPipeline({ allowed: true, runOn: "export" }),
);
await page.goto("/editor", { waitUntil: "domcontentloaded" });
+3
View File
@@ -16,6 +16,9 @@ export type FileId = string & { readonly [tag]: "FileId" };
export interface ToolOperation {
toolId: ToolId;
timestamp: number;
/** Overrides the tool's own name in history. Set by a policy run to its pipeline's name, since
* every policy records the same "automate" toolId. */
label?: string;
}
/**
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import type { TFunction } from "i18next";
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
import type { ToolOperation } from "@app/types/file";
// Stands in for i18next: echoes the key so the assertions show which lookup ran.
const t = ((key: string, fallback?: string) =>
key === "home.automate.title" ? "Automate" : (fallback ?? key)) as TFunction;
const op = (over: Partial<ToolOperation>): ToolOperation =>
({ toolId: "automate", timestamp: 0, ...over }) as ToolOperation;
describe("toolOperationLabel", () => {
it("prefers the operation's own label", () => {
expect(toolOperationLabel(op({ label: "add-page-numbers" }), t)).toBe(
"add-page-numbers",
);
});
// Every policy records the same "automate" toolId, so without a label each automated version
// reads identically no matter which pipeline produced it.
it("falls back to the tool's name when unlabelled", () => {
expect(toolOperationLabel(op({}), t)).toBe("Automate");
});
it("keeps the fallback for an empty label rather than rendering a blank", () => {
expect(toolOperationLabel(op({ label: "" }), t)).toBe("Automate");
});
});
@@ -0,0 +1,17 @@
import type { TFunction } from "i18next";
import type { ToolOperation } from "@app/types/file";
/**
* What produced a version, for the history surfaces. A policy run carries its own label (the
* pipeline's name) because every policy records the same "automate" toolId, which would otherwise
* render every automated version identically.
*/
export function toolOperationLabel(
operation: ToolOperation,
t: TFunction,
): string {
// Truthiness, not nullish: a blank label would otherwise render as an empty history entry.
return (
operation.label || t(`home.${operation.toolId}.title`, operation.toolId)
);
}
@@ -70,6 +70,8 @@ export interface Policy {
* output} is used.
*/
outputIds: string[];
/** Whether the editor runs this policy per file, and on which moment. */
editor?: { allowed: boolean; runOn: "upload" | "export" };
teamId?: number | null;
}
@@ -1,9 +1,9 @@
/**
* When a pipeline's input fires. A swept source is scheduled or triggered server-side; the editor
* is client-driven and instead runs as each file passes through, so the two get different controls.
*/
// Swept sources are scheduled or triggered server-side; the editor runs client-side as each file
// passes through, so the two get different controls.
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { FormField, Input, Select } from "@app/ui";
export type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
@@ -13,10 +13,7 @@ const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
/** Empty trigger type = manual-only (no automatic trigger). */
export const MANUAL = "";
/**
* Sentinel for the manual choice: Mantine's Select reads an empty string as "no selection", so the
* option needs a real value. Mapped to/from {@link MANUAL} at this component's edges.
*/
/** Sentinel for manual: Mantine's Select reads "" as no selection. Maps to {@link MANUAL}. */
export const MANUAL_OPTION = "manual";
/** One input row in the builder: a source paired with its own trigger config. */
@@ -52,11 +49,23 @@ export function PipelineInputTrigger({
const label = t("portal.pipelines.builder.runOn", "Runs on");
return (
<FormField
label={label}
helperText={t(
"portal.pipelines.builder.runOnHelper",
"Editor pipelines run in the browser as each file passes through - there is no server-side sweep to schedule.",
)}
label={
<Tooltip
label={t(
"portal.pipelines.builder.runOnTooltip",
"Choose when this pipeline runs on your files: when you add them, or when you export them.",
)}
position="right"
withinPortal
multiline
w={260}
>
<span className="portal-builder__label-hint">
{label}
<InfoOutlinedIcon style={{ fontSize: "0.875rem" }} />
</span>
</Tooltip>
}
>
<Select
inputSize="sm"
@@ -51,6 +51,8 @@ export interface GraphNodeContent {
warning?: string;
/** Why the input will not be much use. */
inputWarning?: ChainWarning;
/** An end the pipeline decides for itself, so it carries no remove control. */
fixed?: boolean;
}
export interface GraphStepContent extends GraphNodeContent {
@@ -294,7 +296,9 @@ export function PipelineGraph({
warning={content.warning}
selected={selected === kind}
onSelect={() => onSelect(kind)}
onRemove={() => onRemoveEnd(kind)}
onRemove={
content.fixed ? undefined : () => onRemoveEnd(kind)
}
/>
)}
</div>
@@ -194,6 +194,18 @@
color: var(--c-text-subtle);
}
/* A field label that carries an info icon explaining the choice. */
.portal-builder__label-hint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: help;
}
.portal-builder__label-hint svg {
color: var(--c-text-subtle);
}
.portal-builder__input-row .portal-builder__source-edit:hover:not(:disabled) {
color: var(--c-accent-fg, var(--c-primary));
}
@@ -813,7 +813,7 @@ describe("PipelineBuilder", () => {
expect(screen.getByText("source-modal:src-1")).toBeInTheDocument();
});
it("saves an editor pipeline as run-on-upload metadata, not as a wire input", async () => {
it("saves an editor pipeline as its own flag, not as a wire input", async () => {
fetchSources.mockResolvedValue({
kpis: [],
sources: [SOURCE, EDITOR_SOURCE],
@@ -830,11 +830,10 @@ describe("PipelineBuilder", () => {
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
const body = savePipeline.mock.calls[0][0];
// The editor is virtual: nothing sweeps it server-side, so it is recorded in the options the
// editor reads rather than as an input the backend would try to pull from.
// The editor is virtual: nothing sweeps it server-side, so it is recorded as the policy's own
// editor flag rather than as an input the backend would try to pull from.
expect(body.inputs).toEqual([]);
expect(body.output.options.sources).toEqual(["editor"]);
expect(body.output.options.runOn).toBe("upload");
expect(body.editor).toEqual({ allowed: true, runOn: "upload" });
// And it needs no destination - results land back in the workspace the file came from.
expect(body.outputIds).toEqual([]);
});
@@ -224,10 +224,8 @@ export function PipelineBuilder() {
async () => await fetchTriggers(),
[],
);
// The editor is a built-in, client-driven source: picking it means the pipeline runs in the
// browser as each file is uploaded or exported, rather than being swept server-side. It is a
// legitimate input, so it is offered - but it never becomes a wire input (see save), and it is
// not writable, so isWritableSource keeps it out of the destinations below.
// Includes the virtual editor source: a valid input, but never a wire input (see save) and not
// writable, so isWritableSource keeps it out of the destinations below.
const availableSources = useMemo<SourceView[]>(
() => sourcesState.data?.sources ?? [],
[sourcesState.data],
@@ -348,16 +346,13 @@ export function PipelineBuilder() {
// without inputs), the stored input for an edit. A legacy multi-input policy shows only its
// first input; saving persists just that one (the backend rejects more anyway).
// An editor pipeline has no wire input; it is recognised by its recorded sources.
const storedOptions = (policy?.output?.options ?? {}) as {
sources?: string[];
runOn?: string;
};
const editorSourceId = (sourcesState.data?.sources ?? []).find(
(source) => source.type === EDITOR_SOURCE_TYPE,
)?.id;
setRunOn(storedOptions.runOn === "export" ? "export" : "upload");
setRunOn(policy?.editor?.runOn === "export" ? "export" : "upload");
const stored = policy?.inputs[0];
if (storedOptions.sources?.includes("editor") && editorSourceId) {
const seedsEditor = Boolean(policy?.editor?.allowed && editorSourceId);
if (seedsEditor && editorSourceId) {
setInput({ ...blankInput(), sourceId: editorSourceId });
} else if (stored) {
const trigger = parseTrigger(stored.trigger);
@@ -373,7 +368,7 @@ export function PipelineBuilder() {
setSteps(
(policy?.steps ?? []).map((step) => deserializeToolStep(step, allTools)),
);
setOutputIds(policy?.outputIds ?? []);
setOutputIds(seedsEditor ? [] : (policy?.outputIds ?? []));
setSeeded(true);
}, [isEdit, policyState.data, allTools, seeded, sourcesState.data]);
@@ -421,8 +416,8 @@ export function PipelineBuilder() {
// Changing the source may make the current trigger incompatible (folder-watch on a non-folder);
// drop it back to manual when that happens so the row can't hold an invalid pairing.
function changeInputSource(sourceId: string) {
const type = sourceType(sourceId);
setInput((current) => {
const type = sourceType(sourceId);
const trigger = triggers.find((tr) => tr.type === current.triggerType);
const keepTrigger =
current.triggerType === MANUAL ||
@@ -433,6 +428,11 @@ export function PipelineBuilder() {
triggerType: keepTrigger ? current.triggerType : MANUAL,
};
});
// The editor hands results back to the workspace, so it has no destination to choose.
if (type === EDITOR_SOURCE_TYPE) {
setOutputIds([]);
setOutputAsked(false);
}
}
/** Put an end on the chain and open it, so the click that asks for it also offers the choice. */
@@ -681,8 +681,7 @@ export function PipelineBuilder() {
input.triggerType !== "schedule" ||
Number(input.scheduleCount) > 0;
const inputValid = sourceChosen && scheduleValid;
// Nor does it need a destination: its results land back in the workspace the file came from,
// which is the whole point of running there rather than sweeping a folder.
// Nor a destination: an editor pipeline's results land back in the workspace the file came from.
const outputValid = isEditorInput || outputIds.length === 1;
// The single source of truth for "can this be committed": every reason it can't be, in the order
@@ -800,20 +799,13 @@ export function PipelineBuilder() {
? []
: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: await serializeStepsForSave(),
// Destinations are the referenced saved sources; the inline output field is
// preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline.
output: {
...(policyState.data?.output ?? { type: "inline", options: {} }),
options: {
...(policyState.data?.output?.options ?? {}),
// Written only for an editor pipeline: the auto-run holds a pipeline to explicit
// metadata, so a swept one must not claim the editor by leaving these behind.
...(isEditorInput
? { sources: ["editor"], runOn }
: { sources: [], runOn: undefined }),
},
},
outputIds,
// Destinations are the referenced saved sources; the inline output is preserved as-is
// or defaults to inline.
output: policyState.data?.output ?? { type: "inline", options: {} },
editor: { allowed: isEditorInput, runOn },
// An editor pipeline delivers back into the workspace. A stored destination would send the
// run to a folder or bucket instead, leaving the editor's copy untouched.
outputIds: isEditorInput ? [] : outputIds,
};
await savePipeline(policy);
await invalidatePipelines();
@@ -1072,6 +1064,11 @@ export function PipelineBuilder() {
/** How this input fires, in a few words, for the input node's summary line. */
function triggerSummary(): string {
// The editor has no trigger to schedule; it fires as each file passes through.
if (isEditorInput)
return runOn === "export"
? t("portal.pipelines.builder.runOnExport", "Every export")
: t("portal.pipelines.builder.runOnUpload", "Every upload");
if (input.triggerType === MANUAL)
return t("portal.pipelines.composer.triggerManual");
if (input.triggerType === "schedule")
@@ -1184,7 +1181,7 @@ export function PipelineBuilder() {
variant="tertiary"
className="portal-builder__source-edit"
aria-label={t("portal.pipelines.composer.editSource")}
disabled={input.sourceId === ""}
disabled={input.sourceId === "" || isEditorInput}
onClick={() =>
setSourceModal({ open: true, sourceId: input.sourceId })
}
@@ -1217,6 +1214,17 @@ export function PipelineBuilder() {
);
}
if (selected === "output" && isEditorInput) {
return (
<p className="portal-builder__muted">
{t(
"portal.pipelines.builder.editorDestinationHelp",
"This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them.",
)}
</p>
);
}
if (selected === "output") {
return (
<DestinationPicker
@@ -1326,16 +1334,28 @@ export function PipelineBuilder() {
: null
}
output={
outputAsked || outputValid
isEditorInput
? {
label:
chosenDestination?.name ??
t("portal.pipelines.builder.chooseDestination"),
warning: outputValid
? undefined
: t("portal.pipelines.builder.needsDestination"),
label: t(
"portal.pipelines.builder.editorDestination",
"Editor",
),
detail: t(
"portal.pipelines.builder.editorDestinationDetail",
"Replaces the file you ran it on",
),
fixed: true,
}
: null
: outputAsked || outputValid
? {
label:
chosenDestination?.name ??
t("portal.pipelines.builder.chooseDestination"),
warning: outputValid
? undefined
: t("portal.pipelines.builder.needsDestination"),
}
: null
}
steps={graphSteps}
selected={selected}
@@ -165,8 +165,6 @@ export function usePolicyAutoRun(): void {
s.configured &&
s.status === "active" &&
s.backendId &&
// Resolved at decode: blank sources mean different things for a catalogue tile
// and for a builder pipeline (see runsOnEditor in policyBackend).
s.runsOnEditor &&
(s.runOn ?? "upload") === "upload" &&
// An escalation-only policy has nothing to do with no engine to escalate to.
@@ -377,6 +375,7 @@ export function usePolicyAutoRun(): void {
void importOutputs(run, {
addFiles,
consumeFiles,
policyName: policies[run.categoryId]?.name,
updateStirlingFileStub,
bumpRevision,
outputMode,
@@ -427,6 +426,8 @@ interface ImportContext {
bumpRevision: () => void;
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
outputMode: "new_file" | "new_version";
/** The policy's name, shown in version history instead of the generic "automate" tool. */
policyName?: string;
/** Rename rule. Empty → keep the input's filename. */
outputName: string;
/** Rename position around the base filename; defaults to "suffix" when absent. */
@@ -744,6 +745,7 @@ async function importOutputs(
files,
parentStub,
"automate",
ctx.policyName,
);
// Transitive provenance for the PERSISTED record, mirroring what the
// CONSUME_FILES reducer computes for workspace state: the output derives
@@ -154,8 +154,8 @@ describe("usePolicies", () => {
expect(result.current.policies.ingestion.folderId).toBeTruthy();
});
// A pipeline built on the Pipelines page has no category tile. The reconcile used to walk only
// the catalogue, so one set to run on the editor never reached the map the auto-run iterates.
// A builder pipeline has no category tile, so the reconcile must key it by id to reach the map
// the auto-run iterates.
it("reconciles a builder pipeline that has no category", async () => {
api.store.set("be-pipeline", {
id: "be-pipeline",
@@ -163,8 +163,9 @@ describe("usePolicies", () => {
enabled: true,
inputs: [],
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: { type: "inline", options: { sources: ["editor"] } },
output: { type: "inline", options: {} },
outputIds: [],
editor: { allowed: true, runOn: "upload" },
} as unknown as { id: string });
const { result } = renderHook(() => usePolicies());
@@ -178,7 +179,7 @@ describe("usePolicies", () => {
expect(pipeline.isDefault).toBe(false);
});
it("does not put a builder pipeline on the editor unless it names it", async () => {
it("does not put a builder pipeline on the editor unless it opts in", async () => {
api.store.set("be-s3", {
id: "be-s3",
name: "S3 sweep",
@@ -196,4 +197,32 @@ describe("usePolicies", () => {
);
expect(result.current.policies["be-s3"].runsOnEditor).toBe(false);
});
// Deleting a pipeline on the Pipelines page leaves its cached entry behind. It still satisfies
// every auto-run condition but its backendId is dead, so the dispatch fails, the run never
// completes, and every policy behind it in the chain is skipped on every upload.
it("forgets a builder pipeline the backend no longer has", async () => {
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
"be-deleted": {
configured: true,
status: "active",
backendId: "be-deleted",
sources: ["editor"],
runsOnEditor: true,
runOn: "upload",
isDefault: false,
},
}),
);
const { result } = renderHook(() => usePolicies());
await waitFor(() =>
expect(result.current.policies["be-deleted"]).toBeUndefined(),
);
// A catalogue tile is never forgotten: it reseeds from the catalogue.
expect(result.current.policies.security).toBeDefined();
});
});
@@ -14,6 +14,7 @@ import {
onPoliciesChange,
updatePolicy,
resetPolicy,
forgetPolicies,
reorderPolicies as persistPolicyOrder,
} from "@app/services/policyStorage";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
@@ -114,12 +115,20 @@ export function usePolicies() {
backendId: undefined,
};
}
// Builder-made pipelines have no category tile, so the catalogue loop above skips them.
// They are still policies: one set to run on the editor has to reach the auto-run.
// Builder-made pipelines have no category, so the built-in loop above skips them. They are
// still policies: one set to run on the editor has to reach the auto-run.
for (const [key, decoded] of byCategory) {
if (reconciled[key]) continue;
reconciled[key] = decodedToState(decoded, local[key]?.folderId);
}
// A builder pipeline the backend no longer has was deleted on the Pipelines page. Its cached
// entry keeps a dead backendId that still satisfies the auto-run filter, so the dispatch
// fails, the run never completes, and the chain behind it never advances.
forgetPolicies(
Object.keys(local).filter(
(id) => !reconciled[id] && !byCategory.has(id),
),
);
for (const [id, state] of Object.entries(reconciled)) {
updatePolicy(id, state);
}
@@ -10,7 +10,11 @@ vi.mock("@app/services/policyApi", () => ({
}));
/** A stored policy in the shape the backend returns. */
const policy = (id: string, categoryId?: string, sources?: string[]) => ({
const policy = (
id: string,
categoryId?: string,
editor?: { allowed: boolean; runOn?: "upload" | "export" },
) => ({
id,
name: id,
enabled: true,
@@ -18,12 +22,13 @@ const policy = (id: string, categoryId?: string, sources?: string[]) => ({
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: {
type: "inline",
options: {
...(categoryId ? { categoryId } : {}),
...(sources ? { sources } : {}),
},
options: { ...(categoryId ? { categoryId } : {}) },
},
outputIds: [],
editor: {
allowed: editor?.allowed ?? false,
runOn: editor?.runOn ?? ("upload" as const),
},
});
/** Decode one stored policy and project it onto the state the editor reads. */
@@ -39,7 +44,7 @@ describe("fetchPoliciesByCategory", () => {
it("keys a catalogue policy by its category", async () => {
listPolicies.mockResolvedValue([
policy("pol-1", "classification", ["editor"]),
policy("pol-1", "classification", { allowed: true }),
]);
const map = await fetchPoliciesByCategory();
@@ -60,7 +65,7 @@ describe("fetchPoliciesByCategory", () => {
it("carries both kinds at once without either displacing the other", async () => {
listPolicies.mockResolvedValue([
policy("pol-1", "classification", ["editor"]),
policy("pol-1", "classification", { allowed: true }),
policy("pol-adhoc"),
]);
@@ -71,7 +76,7 @@ describe("fetchPoliciesByCategory", () => {
it("records run order from the list, which is the team's order", async () => {
listPolicies.mockResolvedValue([
policy("pol-1", "security", ["editor"]),
policy("pol-1", "security", { allowed: true }),
policy("pol-adhoc"),
]);
@@ -85,18 +90,19 @@ describe("fetchPoliciesByCategory", () => {
describe("decodedToState — runsOnEditor", () => {
beforeEach(() => listPolicies.mockReset());
it("runs a catalogue tile that nobody has narrowed yet", async () => {
// Blank on a tile means "not yet scoped", which has always meant every source.
const state = await stateOf(policy("pol-1", "security"), "security");
it("runs a catalogue tile that opted into the editor", async () => {
const state = await stateOf(
policy("pol-1", "security", { allowed: true }),
"security",
);
expect(state.runsOnEditor).toBe(true);
});
it("does not run a catalogue tile narrowed to another source", async () => {
const state = await stateOf(
policy("pol-1", "security", ["s3-archive"]),
"security",
);
// Participation is the policy's own flag now, so a tile that never opted in does not run in the
// editor just because nobody narrowed its scope.
it("does not run a catalogue tile that never opted in", async () => {
const state = await stateOf(policy("pol-1", "security"), "security");
expect(state.runsOnEditor).toBe(false);
});
@@ -111,7 +117,7 @@ describe("decodedToState — runsOnEditor", () => {
it("runs a builder pipeline that names the editor outright", async () => {
const state = await stateOf(
policy("pol-adhoc", undefined, ["editor"]),
policy("pol-adhoc", undefined, { allowed: true }),
"pol-adhoc",
);
@@ -18,9 +18,6 @@ import {
} from "@app/services/policyPipeline";
import type { PolicyState } from "@app/types/policies";
/** The editor's id in a policy's source list; it is client-driven, not a swept source. */
const EDITOR_SOURCE_ID = "editor";
/**
* Fetch every stored policy and decode it, keyed by its catalog category. If two
* stored policies share a category (shouldn't happen — one per category), the
@@ -55,8 +52,9 @@ export function decodedToState(
return {
configured: true,
status: decoded.enabled ? "active" : "paused",
name: decoded.name,
sources: decoded.sources,
runsOnEditor: runsOnEditor(decoded),
runsOnEditor: decoded.runsOnEditor,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
fieldValues: decoded.fieldValues,
@@ -73,20 +71,6 @@ export function decodedToState(
};
}
/**
* Whether the policy runs in the editor as each file passes through.
*
* Blank sources reads differently either side of that line, so it is resolved here once rather
* than at each call site: a catalogue tile is blank because nobody has narrowed it yet and still
* runs everywhere, while a builder pipeline is blank because nothing stamped it - it has to name
* the editor outright, or an S3 or folder pipeline would fire on every upload.
*/
function runsOnEditor(decoded: DecodedPolicy): boolean {
const sources = decoded.sources ?? [];
if (sources.includes(EDITOR_SOURCE_ID)) return true;
return Boolean(decoded.categoryId) && sources.length === 0;
}
/**
* 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
@@ -1,11 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
/**
* Which policies export-time enforcement picks up. The interesting case is a builder-made
* pipeline: blank sources mean "everywhere" for a catalogue tile but "not the editor" for a
* pipeline, so the choice has to come from runsOnEditor rather than from the raw source list.
*/
// Which policies export-time enforcement picks up: the policy's own editor flag, not its scope.
const loadPolicies = vi.fn<() => PoliciesByCategory>();
vi.mock("@app/services/policyStorage", () => ({
@@ -15,7 +11,12 @@ vi.mock("@app/services/policyStorage", () => ({
const runStoredPolicy = vi.fn(async (_id: string) => "run-1");
vi.mock("@app/services/policyApi", () => ({
runStoredPolicy: (id: string) => runStoredPolicy(id),
getPolicyRun: async () => ({ status: "COMPLETED", outputs: [] }),
// One output, so a run completes rather than throwing "produced no output" - which would abort
// the per-file policy loop after the first policy and hide the order under test.
getPolicyRun: async () => ({
status: "COMPLETED",
outputs: [{ fileId: "out-1", fileName: "doc.pdf" }],
}),
downloadPolicyOutput: async () => new Blob(),
resolvePolicyRunTarget: () => "local",
}));
@@ -103,4 +104,26 @@ describe("export-time policy selection", () => {
expect(runStoredPolicy).toHaveBeenCalledWith("backend-security");
});
it("enforces in the team's run order, not object order", async () => {
loadPolicies.mockReturnValue({
second: exportPolicy({
runsOnEditor: true,
backendId: "backend-second",
order: 1,
}),
first: exportPolicy({
runsOnEditor: true,
backendId: "backend-first",
order: 0,
}),
} as unknown as PoliciesByCategory);
await enforceExportPolicies([pdf()], ["file-1"]);
expect(runStoredPolicy.mock.calls.map(([id]) => id)).toEqual([
"backend-first",
"backend-second",
]);
});
});
@@ -62,24 +62,28 @@ function activeExportPolicies(): ExportPolicy[] {
const labels = new Map(
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
);
return Object.entries(loadPolicies())
.filter(
([, s]) =>
s.configured &&
s.status === "active" &&
s.backendId &&
// Same decode-time answer the upload path uses: blank sources mean "everywhere" for a
// catalogue tile but "not the editor" for a builder pipeline (see runsOnEditor).
s.runsOnEditor &&
s.runOn === "export",
)
.map(([id, s]) => ({
categoryId: id,
backendId: s.backendId as string,
label: labels.get(id) ?? "Policy",
outputMode: s.outputMode === "new_file" ? "new_file" : "new_version",
accent: `var(--color-${ROW_ACCENT[id] ?? "blue"})`,
}));
return (
Object.entries(loadPolicies())
.filter(
([, s]) =>
s.configured &&
s.status === "active" &&
s.backendId &&
s.runsOnEditor &&
s.runOn === "export",
)
// Same team-wide run order the upload path uses: enforcement is not commutative (a watermark
// then a flatten is not a flatten then a watermark), so both paths must agree on the sequence.
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
.map(([id, s]) => ({
categoryId: id,
backendId: s.backendId as string,
// A builder pipeline has no built-in category, so it labels by its own name.
label: labels.get(id) ?? s.name ?? "Policy",
outputMode: s.outputMode === "new_file" ? "new_file" : "new_version",
accent: `var(--color-${ROW_ACCENT[id] ?? "blue"})`,
}))
);
}
/** Run one policy on a file and resolve the enforced bytes + run info (throws on
@@ -11,7 +11,10 @@
* using that registry.
*/
import { resolveRunOn } from "@app/policies/runOn";
import { resolveRunOn, type PolicyRunOn } from "@app/policies/runOn";
/** The editor's id in a policy's scope list; picking it makes the policy editor-run. */
export const EDITOR_SOURCE_ID = "editor";
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { PolicyFolderSettings } from "@app/types/policies";
@@ -56,6 +59,14 @@ export interface BackendPolicy {
trigger: BackendTriggerConfig | null;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
/** Whether the editor runs this policy per file, and on which moment. */
editor?: BackendEditorConfig;
}
/** Mirrors the backend `EditorConfig`. */
export interface BackendEditorConfig {
allowed: boolean;
runOn: PolicyRunOn;
}
/**
@@ -233,6 +244,8 @@ export interface DecodedPolicy {
/** Null if the stored policy carried no automation blob. */
automation: AutomationConfig | null;
sources: string[];
/** Whether the editor runs this policy per file, straight from the policy's own flag. */
runsOnEditor: boolean;
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
@@ -280,7 +293,6 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
maxRetries: input.folder.maxRetries,
retryDelayMinutes: input.folder.retryDelayMinutes,
automation: input.automation,
runOn: input.folder.runOn,
// Policy-level metadata (no trigger bag to hold it any more).
categoryId: input.categoryId,
sources: input.sources,
@@ -289,6 +301,10 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
fieldValues: input.fieldValues,
},
},
editor: {
allowed: input.sources.includes(EDITOR_SOURCE_ID),
runOn: input.folder.runOn,
},
};
}
@@ -298,6 +314,7 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
// Metadata lives in output.options; legacy records kept it in trigger.options,
// so merge both (output wins) to decode either shape.
const meta = { ...(policy.trigger?.options ?? {}), ...output };
const editor = policy.editor;
const str = (v: unknown, fallback = "") =>
typeof v === "string" ? v : fallback;
const num = (v: unknown, fallback: number) =>
@@ -316,8 +333,9 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
reviewerEmail: str(meta.reviewerEmail),
fieldValues:
(meta.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
runsOnEditor: editor?.allowed === true,
folder: {
runOn: resolveRunOn(meta.runOn, categoryId),
runOn: resolveRunOn(editor?.runOn, categoryId),
// Legacy/missing output.mode defaults to new_version, not new_file.
outputMode: output.mode === "new_file" ? "new_file" : "new_version",
outputName: str(output.name),
@@ -65,9 +65,8 @@ export function loadPolicies(): PoliciesByCategory {
if (merged.order == null) merged.order = index;
out[cat.id] = merged;
});
// Builder-made pipelines key by their own id, so the catalogue walk above misses them. They only
// ever arrive from the backend reconcile, so they are carried through as stored - seeding them
// with a tile's defaults would mark them built-in and put them on the editor uninvited.
// Builder pipelines key by their own id, so the walk above misses them. Carried through as
// stored: a tile's defaults would mark them built-in and put them on the editor uninvited.
for (const [key, state] of Object.entries(parsed)) {
if (!out[key] && state) out[key] = state as PolicyState;
}
@@ -125,6 +124,26 @@ export function reorderPolicies(
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
* entry keeps a dead backendId that the auto-run still tries to dispatch. Built-in categories are
* never forgotten - they reseed on the next read anyway.
*/
export function forgetPolicies(ids: string[]): PoliciesByCategory {
const current = loadPolicies();
const catalogIds = new Set(loadPolicyCatalog().categories.map((c) => c.id));
const next: PoliciesByCategory = { ...current };
let removed = false;
for (const id of ids) {
if (catalogIds.has(id) || !(id in next)) continue;
delete next[id];
removed = true;
}
if (removed) persist(next);
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
@@ -120,6 +120,8 @@ export interface PolicyState {
status: PolicyStatus;
/** Selected sources (ids from POLICY_SOURCES). */
sources: string[];
/** The policy's own name. Set for builder pipelines, which have no built-in category label. */
name?: string;
/** Whether the policy runs in the editor as each file passes through (resolved at decode). */
runsOnEditor?: boolean;
/** When non-empty, narrows the policy to these document types. */