diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index 5a5a3018b1..3dac4e284e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -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. */
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java
new file mode 100644
index 0000000000..9b15adea2d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java
@@ -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.
+ *
+ *
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);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
index 14b1eb325c..00833bceb5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
@@ -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 steps,
OutputSpec output,
List 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 inputs,
+ List steps,
+ OutputSpec output,
+ List 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 editorRunOn() {
+ return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty();
+ }
+
public List 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 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);
}
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
index b2be7b668e..0d7845a209 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
@@ -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").
+ *
+ *
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 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) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
index 9b347366bc..7d35198633 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
@@ -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 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());
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
index 70d67bba0f..08bc253863 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
@@ -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()));
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
index 6edaa76c78..409555312f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
@@ -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);
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
index 373b596136..4d0830f9c5 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
@@ -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))
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
index f6e82bd011..6720ee2368 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
@@ -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");
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 76d18ec28d..3b61192800 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -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"
diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
index 988ecf789c..27f55ce060 100644
--- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
+++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
@@ -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 = ({
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
{currentFile.toolHistory
- .map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
+ .map((tool) => toolOperationLabel(tool, t))
.join(" → ")}
)}
diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
index f484f936ef..77d8808c17 100644
--- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
+++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
@@ -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 {t(`home.${toolId}.title`, toolId)};
+ return {toolOperationLabel(operation, t)};
}
export interface VersionTimelineProps {
@@ -242,7 +242,7 @@ export function VersionTimeline({
style={{ color: "var(--c-text)" }}
>
{delta ? (
-
+
) : (
t("filesPage.versionOrigin", "Original upload")
)}
diff --git a/frontend/editor/src/core/components/shared/ToolChain.tsx b/frontend/editor/src/core/components/shared/ToolChain.tsx
index 249e7802cc..7974614759 100644
--- a/frontend/editor/src/core/components/shared/ToolChain.tsx
+++ b/frontend/editor/src/core/components/shared/ToolChain.tsx
@@ -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 = ({
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 = ({
{toolChain.map((tool, index) => (
- {getToolName(tool.toolId)}
+ {getToolName(tool)}
{index < toolChain.length - 1 && (
@@ -53,18 +49,21 @@ const ToolChain: React.FC = ({
))}
) : (
- {toolIds.map(getToolName).join(" → ")}
+ {toolChain.map(getToolName).join(" → ")}
);
// 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 = ({
// 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 = (
= ({
{toolChain.slice(0, 3).map((tool, index) => (
- {getToolName(tool.toolId)}
+ {getToolName(tool)}
{index < Math.min(toolChain.length - 1, 2) && (
@@ -131,7 +130,7 @@ const ToolChain: React.FC = ({
...
- {getToolName(toolChain[toolChain.length - 1].toolId)}
+ {getToolName(toolChain[toolChain.length - 1])}
>
)}
@@ -140,7 +139,7 @@ const ToolChain: React.FC = ({
);
return isBadgesTruncated ? (
-
+
{badgesElement}
) : (
diff --git a/frontend/editor/src/core/services/fileStubHelpers.ts b/frontend/editor/src/core/services/fileStubHelpers.ts
index 60489c8c94..836d000af7 100644
--- a/frontend/editor/src/core/services/fileStubHelpers.ts
+++ b/frontend/editor/src/core/services/fileStubHelpers.ts
@@ -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,
diff --git a/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts
index 35a8456ae8..a71bc98f33 100644
--- a/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts
@@ -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) {
+function builderPipeline(editor: { allowed: boolean; runOn: string }) {
return {
id: "builder-pipeline-1",
name: "Flatten everything",
@@ -25,7 +21,8 @@ function builderPipeline(options: Record) {
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" });
diff --git a/frontend/editor/src/core/types/file.ts b/frontend/editor/src/core/types/file.ts
index 98a0094f43..c6ec1898cb 100644
--- a/frontend/editor/src/core/types/file.ts
+++ b/frontend/editor/src/core/types/file.ts
@@ -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;
}
/**
diff --git a/frontend/editor/src/core/utils/toolOperationLabel.test.ts b/frontend/editor/src/core/utils/toolOperationLabel.test.ts
new file mode 100644
index 0000000000..9b21f6e65f
--- /dev/null
+++ b/frontend/editor/src/core/utils/toolOperationLabel.test.ts
@@ -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 =>
+ ({ 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");
+ });
+});
diff --git a/frontend/editor/src/core/utils/toolOperationLabel.ts b/frontend/editor/src/core/utils/toolOperationLabel.ts
new file mode 100644
index 0000000000..1f29d02390
--- /dev/null
+++ b/frontend/editor/src/core/utils/toolOperationLabel.ts
@@ -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)
+ );
+}
diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts
index e441fcdac8..2be269c1ac 100644
--- a/frontend/editor/src/portal/api/pipelines.ts
+++ b/frontend/editor/src/portal/api/pipelines.ts
@@ -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;
}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx
index 0b6e8dac1b..eb5af9cd21 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx
@@ -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 (
+
+ {label}
+
+
+
+ }
>