diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index b42831a97a..08dfa35798 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7713,6 +7713,10 @@ needsUpload = "Needs an uploaded file" noToolMatches = "No tools match your search." 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." +runOnUpload = "Every upload" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index 52fb5d516c..612742e9b5 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -228,6 +228,20 @@ const POLICY: Policy = { outputIds: [], }; +/** The built-in editor source, offered as an input so a pipeline can run in the browser. */ +const EDITOR_SOURCE: SourceView = { + id: "src-editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, +}; + const SOURCE: SourceView = { id: "src-in", name: "Claims intake", @@ -747,6 +761,32 @@ 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 () => { + fetchSources.mockResolvedValue({ + kpis: [], + sources: [SOURCE, EDITOR_SOURCE], + }); + renderBuilder("/processor/pipelines/new"); + fireEvent.change( + await screen.findByLabelText("portal.pipelines.composer.name"), + { target: { value: "Label on upload" } }, + ); + await addTool("Compress"); + await pickInputSource("Editor"); + + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); + + 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. + expect(body.inputs).toEqual([]); + expect(body.output.options.sources).toEqual(["editor"]); + expect(body.output.options.runOn).toBe("upload"); + // And it needs no destination - results land back in the workspace the file came from. + expect(body.outputIds).toEqual([]); + }); + it("runs an existing pipeline and reports success", async () => { renderBuilder("/processor/pipelines/plc-1"); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d5092f5c88..9e68e3f0e2 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -216,13 +216,12 @@ export function PipelineBuilder() { async () => await fetchTriggers(), [], ); - // The editor is a built-in, client-driven source (it runs on editor upload, - // not as a pipeline input), so it's excluded from a pipeline's inputs. + // 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. const availableSources = useMemo( - () => - (sourcesState.data?.sources ?? []).filter( - (source) => source.type !== EDITOR_SOURCE_TYPE, - ), + () => sourcesState.data?.sources ?? [], [sourcesState.data], ); // A destination is a source used as a write target: only writable types (folder/S3, filtered per @@ -241,6 +240,17 @@ export function PipelineBuilder() { // Exactly one input: the row is always present, so the working state is a single object; the // wire shape stays a list (see save()). const [input, setInput] = useState(blankInput); + // When the editor is the source, the pipeline fires client-side on each file: on upload as it + // arrives, or on export as it leaves. Meaningless for a swept source, which has no such moment. + const [runOn, setRunOn] = useState<"upload" | "export">("upload"); + const isEditorInput = useMemo( + () => + availableSources.some( + (source) => + source.id === input.sourceId && source.type === EDITOR_SOURCE_TYPE, + ), + [availableSources, input.sourceId], + ); const [steps, setSteps] = useState([]); /** Which node the inspector is editing: an end of the chain, a step, or nothing. */ const [selected, setSelected] = useState(null); @@ -329,8 +339,19 @@ export function PipelineBuilder() { // The one input row is always present: blank for a new pipeline (or a legacy policy saved // 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"); const stored = policy?.inputs[0]; - if (stored) { + if (storedOptions.sources?.includes("editor") && editorSourceId) { + setInput({ ...blankInput(), sourceId: editorSourceId }); + } else if (stored) { const trigger = parseTrigger(stored.trigger); setInput({ sourceId: stored.sourceId, @@ -346,7 +367,7 @@ export function PipelineBuilder() { ); setOutputIds(policy?.outputIds ?? []); setSeeded(true); - }, [isEdit, policyState.data, allTools, seeded]); + }, [isEdit, policyState.data, allTools, seeded, sourcesState.data]); const sourceType = (sourceId: string) => availableSources.find((s) => s.id === sourceId)?.type; @@ -617,10 +638,15 @@ export function PipelineBuilder() { // Each validity condition is defined exactly once here, then consumed both by the graph (which // flags each end) and by the blocker list below. const sourceChosen = input.sourceId !== ""; + // An editor pipeline has no trigger to schedule: it fires as each file passes through. const scheduleValid = - input.triggerType !== "schedule" || Number(input.scheduleCount) > 0; + isEditorInput || + input.triggerType !== "schedule" || + Number(input.scheduleCount) > 0; const inputValid = sourceChosen && scheduleValid; - const outputValid = outputIds.length === 1; + // 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. + 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 // they appear down the form, so a disabled Create / Save button can say exactly what is still owed. @@ -675,12 +701,25 @@ export function PipelineBuilder() { id: policyState.data?.id ?? undefined, name: name.trim(), enabled: enabledOverride ?? enabled, - // The wire shape stays a list; canSave guarantees the one input has a source. - inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + // The editor is virtual - there is no stored Source to pull from, and nothing server-side + // sweeps it - so it is recorded in the output options the editor reads, not as a wire input. + inputs: isEditorInput + ? [] + : [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], steps: steps.map((step) => serializeToolStep(step, allTools)), // 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: {} }, + 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, }; try { @@ -1035,25 +1074,60 @@ export function PipelineBuilder() { - - + setRunOn(value === "export" ? "export" : "upload") + } + options={[ + { + value: "upload", + label: t( + "portal.pipelines.builder.runOnUpload", + "Every upload", + ), + }, + { + value: "export", + label: t( + "portal.pipelines.builder.runOnExport", + "Every export", + ), + }, + ]} + /> + + ) : ( + +