Let a pipeline run on the editor, on upload or export

This commit is contained in:
Anthony Stirling
2026-08-19 21:02:42 +01:00
parent 782e006697
commit e650ba0943
3 changed files with 150 additions and 32 deletions
@@ -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}}."
@@ -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");
@@ -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<SourceView[]>(
() =>
(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<WorkingInput>(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<WorkingToolStep[]>([]);
/** Which node the inspector is editing: an end of the chain, a step, or nothing. */
const [selected, setSelected] = useState<GraphSelection>(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() {
</div>
</FormField>
<FormField label={t("portal.pipelines.builder.inputTrigger")}>
<Select
inputSize="sm"
aria-label={t("portal.pipelines.builder.inputTrigger")}
value={
input.triggerType === MANUAL
? MANUAL_OPTION
: input.triggerType
}
disabled={input.sourceId === ""}
onChange={(value) =>
updateInput({
triggerType:
value && value !== MANUAL_OPTION ? value : MANUAL,
})
}
options={triggerOptionsFor(input.sourceId)}
/>
</FormField>
{isEditorInput ? (
<FormField
label={t("portal.pipelines.builder.runOn", "Runs on")}
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.",
)}
>
<Select
inputSize="sm"
aria-label={t("portal.pipelines.builder.runOn", "Runs on")}
value={runOn}
onChange={(value) =>
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",
),
},
]}
/>
</FormField>
) : (
<FormField label={t("portal.pipelines.builder.inputTrigger")}>
<Select
inputSize="sm"
aria-label={t("portal.pipelines.builder.inputTrigger")}
value={
input.triggerType === MANUAL
? MANUAL_OPTION
: input.triggerType
}
disabled={input.sourceId === ""}
onChange={(value) =>
updateInput({
triggerType:
value && value !== MANUAL_OPTION ? value : MANUAL,
})
}
options={triggerOptionsFor(input.sourceId)}
/>
</FormField>
)}
{input.triggerType === "schedule" && (
<div className="portal-builder__schedule">