mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Move editor participation onto the policy and address review feedback
This commit is contained in:
committed by
James Brunton
parent
64eb44e627
commit
37c8621426
+11
-1
@@ -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. */
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-1
@@ -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) {
|
||||
|
||||
+6
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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()));
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+39
@@ -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))
|
||||
|
||||
+3
-2
@@ -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" });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user