diff --git a/src/models/componentSpec/__tests__/actions/createSubgraph.test.ts b/src/models/componentSpec/__tests__/actions/createSubgraph.test.ts index a0baa0e2d3..48bd118112 100644 --- a/src/models/componentSpec/__tests__/actions/createSubgraph.test.ts +++ b/src/models/componentSpec/__tests__/actions/createSubgraph.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + import { createSubgraph } from "../../actions/createSubgraph"; import { Binding } from "../../entities/binding"; import { ComponentSpec } from "../../entities/componentSpec"; @@ -88,6 +90,51 @@ describe("createSubgraph", () => { expect(result!.subgraphSpec.inputs.at(0)?.name).toBe("data"); }); + it("uses a regular subgraph input for an external condition", () => { + const spec = new ComponentSpec({ + $id: idGen.next("spec"), + name: "Main", + }); + const condition = new Input({ + $id: idGen.next("input"), + name: "condition", + }); + const task = new Task({ + $id: idGen.next("task"), + name: "InnerTask", + componentRef: {}, + isEnabled: "true", + }); + spec.addInput(condition); + spec.addTask(task); + spec.addBinding( + new Binding({ + $id: idGen.next("binding"), + sourceEntityId: condition.$id, + sourcePortName: "condition", + targetEntityId: task.$id, + targetPortName: IS_ENABLED_PORT_NAME, + }), + ); + + const result = createSubgraph({ + spec, + selectedTaskIds: [task.$id], + subgraphName: "Sub", + idGen, + }); + + expect(result?.subgraphSpec.inputs.at(0)?.name).toBe("run_when"); + expect( + spec.bindings.find( + (binding) => binding.targetEntityId === result?.replacementTask.$id, + )?.targetPortName, + ).toBe("run_when"); + expect(result?.subgraphSpec.bindings.at(0)?.targetPortName).toBe( + IS_ENABLED_PORT_NAME, + ); + }); + it("moves internal bindings to subgraph", () => { const spec = new ComponentSpec({ $id: idGen.next("spec"), @@ -189,7 +236,9 @@ describe("createSubgraph", () => { $id: idGen.next("task"), name: "ConfiguredTask", componentRef: { name: "MyComponent" }, - isEnabled: { "==": { op1: "a", op2: "b" } }, + isEnabled: { + taskOutput: { taskId: "condition", outputName: "result" }, + }, }); task.annotations.add({ key: "note", value: "test" }); spec.addTask(task); @@ -205,7 +254,9 @@ describe("createSubgraph", () => { const movedTask = result!.subgraphSpec.tasks.at(0); expect(movedTask?.name).toBe("ConfiguredTask"); expect(movedTask?.componentRef).toEqual({ name: "MyComponent" }); - expect(movedTask?.isEnabled).toEqual({ "==": { op1: "a", op2: "b" } }); + expect(movedTask?.isEnabled).toEqual({ + taskOutput: { taskId: "condition", outputName: "result" }, + }); expect(movedTask?.annotations.length).toBe(1); }); diff --git a/src/models/componentSpec/__tests__/actions/unpackSubgraph.test.ts b/src/models/componentSpec/__tests__/actions/unpackSubgraph.test.ts index 2350de8220..fe0dbd5679 100644 --- a/src/models/componentSpec/__tests__/actions/unpackSubgraph.test.ts +++ b/src/models/componentSpec/__tests__/actions/unpackSubgraph.test.ts @@ -314,14 +314,13 @@ describe("unpackSubgraph roundtrip", () => { }); }); - it("isEnabled predicate preserved after roundtrip", () => { + it("isEnabled value is preserved after roundtrip", () => { const spec = new ComponentSpec({ $id: idGen.next("spec"), name: "Main", }); - const predicate = { "==": { op1: "a", op2: "b" } }; const task = makeTask(idGen, "ConditionalTask", { - isEnabled: predicate, + isEnabled: "false", }); spec.addTask(task); @@ -329,7 +328,7 @@ describe("unpackSubgraph roundtrip", () => { const restored = spec.tasks.find((t) => t.name === "ConditionalTask"); expect(restored).toBeDefined(); - expect(restored?.isEnabled).toEqual(predicate); + expect(restored?.isEnabled).toBe("false"); }); it("static arguments preserved after roundtrip", () => { diff --git a/src/models/componentSpec/__tests__/entities/componentSpecProxy.test.ts b/src/models/componentSpec/__tests__/entities/componentSpecProxy.test.ts index 4a999de875..e870797eb2 100644 --- a/src/models/componentSpec/__tests__/entities/componentSpecProxy.test.ts +++ b/src/models/componentSpec/__tests__/entities/componentSpecProxy.test.ts @@ -296,14 +296,16 @@ describe("createComponentSpecProxy", () => { $id: "task_1", name: "T", componentRef: {}, - isEnabled: { "==": { op1: "a", op2: "b" } }, + isEnabled: { + taskOutput: { taskId: "condition", outputName: "result" }, + }, }), ); const graph = getGraph(createComponentSpecProxy(spec)); expect(graph.tasks["T"].isEnabled).toEqual({ - "==": { op1: "a", op2: "b" }, + taskOutput: { taskId: "condition", outputName: "result" }, }); }); diff --git a/src/models/componentSpec/__tests__/entities/task.test.ts b/src/models/componentSpec/__tests__/entities/task.test.ts index 183a5067d8..397ab93c87 100644 --- a/src/models/componentSpec/__tests__/entities/task.test.ts +++ b/src/models/componentSpec/__tests__/entities/task.test.ts @@ -53,15 +53,18 @@ describe("Task", () => { expect(task.arguments[0]).toEqual({ name: "input", value: "test" }); }); - it("can set isEnabled predicate", () => { + it("can set an isEnabled reference", () => { + const isEnabled = { + taskOutput: { taskId: "condition", outputName: "result" }, + }; const task = new Task({ $id: "task_1", name: "T", componentRef: {}, - isEnabled: { "==": { op1: "a", op2: "b" } }, + isEnabled, }); - expect(task.isEnabled).toEqual({ "==": { op1: "a", op2: "b" } }); + expect(task.isEnabled).toEqual(isEnabled); }); it("setName updates name", () => { diff --git a/src/models/componentSpec/__tests__/integration/roundtrip.test.ts b/src/models/componentSpec/__tests__/integration/roundtrip.test.ts index 3963917956..145e8a5bbe 100644 --- a/src/models/componentSpec/__tests__/integration/roundtrip.test.ts +++ b/src/models/componentSpec/__tests__/integration/roundtrip.test.ts @@ -183,15 +183,18 @@ describe("Serialization Roundtrip", () => { }); }); - it("preserves isEnabled predicate", () => { + it("preserves an isEnabled reference", () => { const yaml = { name: "ConditionalTest", implementation: { graph: { tasks: { + Condition: { componentRef: {} }, ConditionalTask: { componentRef: {}, - isEnabled: { "!=": { op1: "a", op2: "b" } }, + isEnabled: { + taskOutput: { taskId: "Condition", outputName: "result" }, + }, }, }, }, @@ -202,7 +205,7 @@ describe("Serialization Roundtrip", () => { const json = serializer.serialize(spec); expect(getGraph(json).tasks["ConditionalTask"].isEnabled).toEqual({ - "!=": { op1: "a", op2: "b" }, + taskOutput: { taskId: "Condition", outputName: "result" }, }); }); diff --git a/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts b/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts index 5c00a3a2b3..792814e124 100644 --- a/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts +++ b/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + import { Binding } from "../../entities/binding"; import { ComponentSpec } from "../../entities/componentSpec"; import { Input } from "../../entities/input"; @@ -82,15 +84,53 @@ describe("JsonSerializer", () => { $id: idGen.next("task"), name: "Process", componentRef: {}, - isEnabled: { "==": { op1: "a", op2: "b" } }, + isEnabled: { + taskOutput: { taskId: "condition", outputName: "result" }, + }, }); spec.addTask(task); const json = serializer.serialize(spec); expect(getGraph(json).tasks["Process"].isEnabled).toEqual({ - "==": { op1: "a", op2: "b" }, + taskOutput: { taskId: "condition", outputName: "result" }, + }); + }); + + it("serializes a conditional binding as isEnabled", () => { + const spec = new ComponentSpec({ + $id: idGen.next("spec"), + name: "Pipeline", + }); + const condition = new Task({ + $id: idGen.next("task"), + name: "Condition", + componentRef: {}, + }); + const conditionalTask = new Task({ + $id: idGen.next("task"), + name: "ConditionalTask", + componentRef: {}, + isEnabled: "true", + }); + spec.addTask(condition); + spec.addTask(conditionalTask); + spec.addBinding( + new Binding({ + $id: idGen.next("binding"), + sourceEntityId: condition.$id, + sourcePortName: "result", + targetEntityId: conditionalTask.$id, + targetPortName: IS_ENABLED_PORT_NAME, + }), + ); + + const taskSpec = getGraph(serializer.serialize(spec)).tasks.ConditionalTask; + + expect(taskSpec.isEnabled).toEqual({ + taskOutput: { taskId: "Condition", outputName: "result" }, }); + expect(taskSpec.arguments).toBeUndefined(); }); it("serializes metadata from annotations", () => { diff --git a/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts b/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts index 99d95d783c..f4d5e4a3f3 100644 --- a/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts +++ b/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + import { IncrementingIdGenerator } from "../../factories/idGenerator"; import { YamlDeserializer } from "../../serialization/yamlDeserializer"; @@ -102,15 +104,18 @@ describe("YamlDeserializer", () => { expect(spec.tasks.at(0)?.componentRef).toEqual({ name: "Processor" }); }); - it("deserializes task with isEnabled", () => { + it("deserializes an isEnabled reference as a conditional binding", () => { const yaml = { name: "Pipeline", implementation: { graph: { tasks: { + Condition: { componentRef: {} }, ConditionalTask: { componentRef: {}, - isEnabled: { "==": { op1: "a", op2: "b" } }, + isEnabled: { + taskOutput: { taskId: "Condition", outputName: "result" }, + }, }, }, }, @@ -118,10 +123,16 @@ describe("YamlDeserializer", () => { }; const spec = deserializer.deserialize(yaml); + const task = spec.tasks.find( + (candidate) => candidate.name === "ConditionalTask", + ); + const binding = spec.bindings.find( + (candidate) => candidate.targetEntityId === task?.$id, + ); - expect(spec.tasks.at(0)?.isEnabled).toEqual({ - "==": { op1: "a", op2: "b" }, - }); + expect(task?.isEnabled).toBe("true"); + expect(binding?.targetPortName).toBe(IS_ENABLED_PORT_NAME); + expect(binding?.sourcePortName).toBe("result"); }); it("deserializes task annotations", () => { diff --git a/src/models/componentSpec/actions/createSubgraph.ts b/src/models/componentSpec/actions/createSubgraph.ts index 656b42e030..80deae4db3 100644 --- a/src/models/componentSpec/actions/createSubgraph.ts +++ b/src/models/componentSpec/actions/createSubgraph.ts @@ -1,3 +1,4 @@ +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; import { deepClone } from "@/utils/deepClone"; import { Annotations } from "../annotations"; @@ -9,9 +10,9 @@ import { Task } from "../entities/task"; import type { Annotation, Argument, + ArgumentType, ComponentReference, ExecutionOptionsSpec, - PredicateType, } from "../entities/types"; import type { IdGenerator } from "../factories/idGenerator"; @@ -31,7 +32,7 @@ interface TaskSnapshot { $id: string; name: string; componentRef: ComponentReference; - isEnabled?: PredicateType; + isEnabled?: ArgumentType; annotations: Annotation[]; arguments: Argument[]; executionOptions?: ExecutionOptionsSpec; @@ -128,7 +129,11 @@ export function createSubgraph({ const usedInputNames = new Set(); for (const [, bindings] of Object.entries(incomingBySource)) { const first = bindings[0]; - const inputName = deduplicatePortName(first.targetPortName, usedInputNames); + const preferredInputName = + first.targetPortName === IS_ENABLED_PORT_NAME + ? "run_when" + : first.targetPortName; + const inputName = deduplicatePortName(preferredInputName, usedInputNames); const input = new Input({ $id: idGen.next("input"), name: inputName, diff --git a/src/models/componentSpec/entities/task.ts b/src/models/componentSpec/entities/task.ts index 694872f195..bf1137ce83 100644 --- a/src/models/componentSpec/entities/task.ts +++ b/src/models/componentSpec/entities/task.ts @@ -12,7 +12,6 @@ import type { ComponentReference, ComponentSpecJson, ExecutionOptionsSpec, - PredicateType, } from "./types"; import { isGraphImplementation } from "./types"; @@ -22,7 +21,7 @@ export class Task extends Model({ name: prop(), componentRef: prop(), subgraphSpec: prop(undefined), - isEnabled: prop(undefined), + isEnabled: prop(undefined), annotations: prop(() => new Annotations({})), arguments: prop(() => []), executionOptions: prop(undefined), @@ -78,8 +77,8 @@ export class Task extends Model({ } @modelAction - setIsEnabled(predicate: PredicateType | undefined) { - this.isEnabled = predicate; + setIsEnabled(condition: ArgumentType | undefined) { + this.isEnabled = condition; } @modelAction diff --git a/src/models/componentSpec/entities/types.ts b/src/models/componentSpec/entities/types.ts index 65b3b2b8d4..6b392c9fe9 100644 --- a/src/models/componentSpec/entities/types.ts +++ b/src/models/componentSpec/entities/types.ts @@ -11,7 +11,6 @@ export type { InputSpec, MetadataSpec, OutputSpec, - PredicateType, TaskOutputArgument, TaskSpec, TypeSpecType, diff --git a/src/models/componentSpec/serialization/jsonSerializer.ts b/src/models/componentSpec/serialization/jsonSerializer.ts index 1ed909e0a5..69f21ff6c1 100644 --- a/src/models/componentSpec/serialization/jsonSerializer.ts +++ b/src/models/componentSpec/serialization/jsonSerializer.ts @@ -1,3 +1,5 @@ +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + import type { Annotations } from "../annotations"; import { serializeAnnotationValue } from "../annotations"; import type { Binding } from "../entities/binding"; @@ -83,7 +85,17 @@ export class JsonSerializer { const taskBindings = spec.bindings.filter( (b) => b.targetEntityId === task.$id, ); - const args = this.serializeArguments(task.arguments, taskBindings, spec); + const conditionalBinding = taskBindings.find( + (binding) => binding.targetPortName === IS_ENABLED_PORT_NAME, + ); + const argumentBindings = taskBindings.filter( + (binding) => binding !== conditionalBinding, + ); + const args = this.serializeArguments( + task.arguments, + argumentBindings, + spec, + ); const componentRef = task.subgraphSpec ? { ...task.componentRef, spec: this.serialize(task.subgraphSpec) } @@ -97,7 +109,12 @@ export class JsonSerializer { result.arguments = args; } - if (task.isEnabled) { + const connectedCondition = conditionalBinding + ? this.bindingToArgument(conditionalBinding, spec) + : undefined; + if (connectedCondition !== undefined) { + result.isEnabled = connectedCondition; + } else if (task.isEnabled !== undefined) { result.isEnabled = task.isEnabled; } @@ -121,26 +138,9 @@ export class JsonSerializer { const result: Record = {}; for (const binding of bindings) { - const sourceTask = spec.tasks.find( - (t) => t.$id === binding.sourceEntityId, - ); - const sourceInput = spec.inputs.find( - (i) => i.$id === binding.sourceEntityId, - ); - - if (sourceTask) { - result[binding.targetPortName] = { - taskOutput: { - taskId: sourceTask.name, - outputName: binding.sourcePortName, - }, - }; - } else if (sourceInput) { - result[binding.targetPortName] = { - graphInput: { - inputName: sourceInput.name, - }, - }; + const argument = this.bindingToArgument(binding, spec); + if (argument !== undefined) { + result[binding.targetPortName] = argument; } } @@ -153,6 +153,36 @@ export class JsonSerializer { return result; } + private bindingToArgument( + binding: Binding, + spec: ComponentSpec, + ): ArgumentType | undefined { + const sourceTask = spec.tasks.find( + (task) => task.$id === binding.sourceEntityId, + ); + if (sourceTask) { + return { + taskOutput: { + taskId: sourceTask.name, + outputName: binding.sourcePortName, + }, + }; + } + + const sourceInput = spec.inputs.find( + (input) => input.$id === binding.sourceEntityId, + ); + if (sourceInput) { + return { + graphInput: { + inputName: sourceInput.name, + }, + }; + } + + return undefined; + } + private serializeInput(input: Input): InputSpec { const result: InputSpec = { name: input.name, diff --git a/src/models/componentSpec/serialization/yamlDeserializer.ts b/src/models/componentSpec/serialization/yamlDeserializer.ts index 3c4a4a1c71..5783ca0a7e 100644 --- a/src/models/componentSpec/serialization/yamlDeserializer.ts +++ b/src/models/componentSpec/serialization/yamlDeserializer.ts @@ -1,3 +1,5 @@ +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + import { Annotations, deserializeAnnotationValue } from "../annotations"; import { Binding } from "../entities/binding"; import { ComponentSpec } from "../entities/componentSpec"; @@ -143,13 +145,16 @@ export class YamlDeserializer { const componentRef = subgraphSpec ? { ...taskJson.componentRef, spec: undefined } : taskJson.componentRef; + const isEnabledBinding = + taskJson.isEnabled !== undefined && + this.isBindingArgument(taskJson.isEnabled); return new Task({ $id: this.idGen.next("task"), name: taskName, componentRef, subgraphSpec, - isEnabled: taskJson.isEnabled, + isEnabled: isEnabledBinding ? "true" : taskJson.isEnabled, executionOptions: taskJson.executionOptions, annotations: Annotations.from(annotationItems), arguments: args, @@ -177,9 +182,24 @@ export class YamlDeserializer { if (!graph?.tasks) return bindings; for (const [taskName, taskJson] of Object.entries(graph.tasks)) { - const targetTask = tasks.find((t) => t.name === taskName); - if (!targetTask || !taskJson.arguments) continue; + const targetTask = tasks.find((task) => task.name === taskName); + if (!targetTask) continue; + + if ( + taskJson.isEnabled !== undefined && + this.isBindingArgument(taskJson.isEnabled) + ) { + const binding = this.createBindingFromArgument( + inputs, + tasks, + targetTask.$id, + IS_ENABLED_PORT_NAME, + taskJson.isEnabled, + ); + if (binding) bindings.push(binding); + } + if (!taskJson.arguments) continue; for (const [argName, argValue] of Object.entries(taskJson.arguments)) { const binding = this.createBindingFromArgument( inputs, diff --git a/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts index f5c6a359d2..f55d8026f0 100644 --- a/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts +++ b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts @@ -5,8 +5,8 @@ import type { ArtifactNodeResponse, } from "@/api/types.gen"; import type { + ArgumentType, ComponentSpec, - PredicateType, TaskSpec, } from "@/utils/componentSpec"; @@ -31,7 +31,9 @@ const containerSpec = (): ComponentSpec => ({ const noStatus = new Map(); -const enabledWhen: PredicateType = { "==": { op1: "mode", op2: "train" } }; +const enabledWhen: ArgumentType = { + taskOutput: { taskId: "condition", outputName: "result" }, +}; const side = ( spec: ComponentSpec | undefined, diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ConfigurationSection.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ConfigurationSection.tsx index e3c8a9ecc9..b652c98f85 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ConfigurationSection.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ConfigurationSection.tsx @@ -9,12 +9,14 @@ import { parseSchemaToAnnotationConfig, } from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/AnnotationsEditor/utils"; import { ColorPicker } from "@/components/ui/color"; +import { Label } from "@/components/ui/label"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Heading, Paragraph } from "@/components/ui/typography"; import type { Task } from "@/models/componentSpec"; import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; import type { AnnotationConfig, Annotations } from "@/types/annotations"; import { EDITOR_COLLAPSED_ANNOTATION, @@ -32,12 +34,14 @@ export const ConfigurationSection = observer(function ConfigurationSection({ task, }: ConfigurationSectionProps) { const { track } = useAnalytics(); + const spec = useSpec(); const { toggleCacheDisable, saveAnnotation, setTaskColor, clearProviderAnnotations, setCollapsed, + setConditionalExecution, } = useTaskConfigActions(); const isSubgraph = task.subgraphSpec !== undefined; @@ -113,6 +117,14 @@ export const ConfigurationSection = observer(function ConfigurationSection({ } }, [selectedProvider, previousProvider, task.annotations]); + const handleConditionalExecutionChange = (checked: boolean) => { + if (!spec) return; + setConditionalExecution(spec, task, checked); + track("v2.pipeline_editor.task_details.conditional_execution.toggle", { + conditional_execution_enabled: checked, + }); + }; + const handleDisableCacheChange = (checked: boolean) => { toggleCacheDisable(task, checked); track("v2.pipeline_editor.task_details.disable_cache.toggle", { @@ -134,6 +146,7 @@ export const ConfigurationSection = observer(function ConfigurationSection({ }; const taskColor = task.annotations.get(TASK_COLOR_ANNOTATION); + const isConditional = task.isEnabled !== undefined; const isCollapsed = task.annotations.get(EDITOR_COLLAPSED_ANNOTATION) === "true"; @@ -157,12 +170,38 @@ export const ConfigurationSection = observer(function ConfigurationSection({ {!isSubgraph && ( <> + + + + + + + {isConditional && ( + + Connect a true or false value to Run when on the task. + + )} + + Disable cache diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.test.ts b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.test.ts new file mode 100644 index 0000000000..6bbafb5fc9 --- /dev/null +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { Binding, ComponentSpec, Task } from "@/models/componentSpec"; +import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; + +import { setConditionalExecution } from "./taskConfig.actions"; + +const undo: UndoGroupable = { + withGroup: (_label: string, action: () => T) => action(), +}; + +function createSpec() { + const task = new Task({ + $id: "task-1", + name: "Task", + componentRef: {}, + }); + const spec = new ComponentSpec({ + $id: "spec-1", + name: "Pipeline", + tasks: [task], + }); + return { spec, task }; +} + +describe("setConditionalExecution", () => { + it("sets an executable placeholder when enabled", () => { + const { spec, task } = createSpec(); + + setConditionalExecution(undo, spec, task, true); + + expect(task.isEnabled).toBe("true"); + }); + + it("clears the condition and its binding when disabled", () => { + const { spec, task } = createSpec(); + task.setIsEnabled("true"); + spec.addBinding( + new Binding({ + $id: "binding-1", + sourceEntityId: "source-1", + sourcePortName: "result", + targetEntityId: task.$id, + targetPortName: IS_ENABLED_PORT_NAME, + }), + ); + + setConditionalExecution(undo, spec, task, false); + + expect(task.isEnabled).toBeUndefined(); + expect(spec.bindings).toHaveLength(0); + }); +}); diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.ts b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.ts index 3957b47f8a..a7b31852d6 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.ts +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/taskConfig.actions.ts @@ -1,10 +1,11 @@ -import type { Task } from "@/models/componentSpec"; +import type { ComponentSpec, Task } from "@/models/componentSpec"; import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; import type { AnnotationConfig } from "@/types/annotations"; import { EDITOR_COLLAPSED_ANNOTATION, TASK_COLOR_ANNOTATION, } from "@/utils/annotations"; +import { IS_ENABLED_PORT_NAME } from "@/utils/conditionalExecution"; import { ISO8601_DURATION_ZERO_DAYS } from "@/utils/constants"; export function toggleCacheDisable( @@ -56,6 +57,27 @@ export function setCollapsed( }); } +export function setConditionalExecution( + undo: UndoGroupable, + spec: ComponentSpec, + task: Task, + enabled: boolean, +) { + undo.withGroup("Toggle conditional execution", () => { + if (enabled) { + task.setIsEnabled("true"); + return; + } + + spec.removeAllBindingsBy( + (binding) => + binding.targetEntityId === task.$id && + binding.targetPortName === IS_ENABLED_PORT_NAME, + ); + task.setIsEnabled(undefined); + }); +} + export function clearProviderAnnotations( undo: UndoGroupable, task: Task, diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/useTaskConfigActions.ts b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/useTaskConfigActions.ts index 225365b591..959b3587db 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/useTaskConfigActions.ts +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/useTaskConfigActions.ts @@ -4,6 +4,7 @@ import { clearProviderAnnotations, saveAnnotation, setCollapsed, + setConditionalExecution, setTaskColor, toggleCacheDisable, } from "./taskConfig.actions"; @@ -16,6 +17,7 @@ export function useTaskConfigActions() { saveAnnotation: saveAnnotation.bind(null, undo), setTaskColor: setTaskColor.bind(null, undo), setCollapsed: setCollapsed.bind(null, undo), + setConditionalExecution: setConditionalExecution.bind(null, undo), clearProviderAnnotations: clearProviderAnnotations.bind(null, undo), }; } diff --git a/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx b/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx index 689a3b89ea..db51321c2a 100644 --- a/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx +++ b/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx @@ -27,6 +27,10 @@ import { TASK_COLOR_ANNOTATION, } from "@/utils/annotations"; import { isSecretArgument } from "@/utils/componentSpec"; +import { + IS_ENABLED_PORT_NAME, + isFalseCondition, +} from "@/utils/conditionalExecution"; import { ISO8601_DURATION_ZERO_DAYS } from "@/utils/constants"; import type { ExecutionStatusStats } from "@/utils/executionStatus"; @@ -67,6 +71,9 @@ export interface TaskNodeViewProps { annotations: { key: string }[]; taskColor?: string; cacheDisabled: boolean; + isConditional?: boolean; + conditionalConnected?: boolean; + conditionalDisplayValue?: string; componentRef?: ComponentReference; digest?: string; publishedComponentBadgeReadOnly?: boolean; @@ -295,6 +302,13 @@ export const TaskNode = observer(function TaskNode({ const connectedPorts = resolveConnectedPortNames(entityId, spec); const inputDisplayData = resolveInputDisplayData(task, entityId, spec); + const isConditional = task.isEnabled !== undefined; + const conditionalConnected = connectedPorts.inputs.has(IS_ENABLED_PORT_NAME); + const conditionalDisplayValue = conditionalConnected + ? inputDisplayData.values[IS_ENABLED_PORT_NAME] + : isFalseCondition(task.isEnabled) + ? "Never" + : undefined; const isSelected = isEditorVisualNodeSelected(editor, id, !!selected); @@ -320,6 +334,9 @@ export const TaskNode = observer(function TaskNode({ cacheDisabled: task.executionOptions?.cachingStrategy?.maxCacheStaleness === ISO8601_DURATION_ZERO_DAYS, + isConditional, + conditionalConnected, + conditionalDisplayValue, componentRef: publishedComponentBadgeEnabled ? task.resolvedComponentRef : undefined, diff --git a/src/routes/v2/shared/nodes/TaskNode/TaskNodeCard.test.tsx b/src/routes/v2/shared/nodes/TaskNode/TaskNodeCard.test.tsx index 19ae8ff64f..691c69cf36 100644 --- a/src/routes/v2/shared/nodes/TaskNode/TaskNodeCard.test.tsx +++ b/src/routes/v2/shared/nodes/TaskNode/TaskNodeCard.test.tsx @@ -99,6 +99,41 @@ describe("TaskNodeCard", () => { ); }); + it("shows a highlighted run condition when conditional execution is enabled", () => { + render( + + + , + ); + + expect(screen.getByText("Run when")).toBeInTheDocument(); + expect(screen.getByText("Connect condition")).toBeInTheDocument(); + expect(screen.getByLabelText("Connect run condition")).toBeInTheDocument(); + }); + + it("shows the connected condition value", () => { + render( + + + , + ); + + expect(screen.getByText("→ Check.result")).toBeInTheDocument(); + }); + it("shows child execution progress for a subgraph", () => { render( void; +} + +function ConditionalExecutionHandle({ + connected, + displayValue, + onHandleClick, +}: ConditionalExecutionHandleProps) { + const handleId = `input_${IS_ENABLED_PORT_NAME}`; + + return ( + +
+
+ onHandleClick(handleId, event)} + /> +
+ + + {IS_ENABLED_INPUT_LABEL} + + + {displayValue ?? "Connect condition"} + + +
+
+ ); +} + export const TaskNodeCard = observer(function TaskNodeCard({ entityId, taskName, @@ -228,6 +283,9 @@ export const TaskNodeCard = observer(function TaskNodeCard({ onHandleClick, taskColor, cacheDisabled, + isConditional = false, + conditionalConnected = false, + conditionalDisplayValue, componentRef, digest, publishedComponentBadgeReadOnly, @@ -334,6 +392,22 @@ export const TaskNodeCard = observer(function TaskNodeCard({ Subgraph )} + {isConditional && ( + + + + + + + + Conditional execution + + + )} {cacheDisabled && ( @@ -376,6 +450,13 @@ export const TaskNodeCard = observer(function TaskNodeCard({ {isSubgraph && subgraphExecutionStats && ( )} + {isConditional && ( + + )} {showInputsSection && (
+ {isConditional && ( + <> + + + + + + + + Conditional execution + + + )} {visibleInputs.map((input) => ( ": TwoArgumentOperands; - } - | { - ">=": TwoArgumentOperands; - } - | { - "<": TwoArgumentOperands; - } - | { - "<=": TwoArgumentOperands; - } - | { - and: TwoLogicalOperands; - } - | { - or: TwoLogicalOperands; - } - | { - not: PredicateType; - }; - interface RetryStrategySpec { maxRetries?: number; } @@ -430,7 +393,7 @@ export interface TaskSpec { arguments?: { [k: string]: ArgumentType; }; - isEnabled?: PredicateType; + isEnabled?: ArgumentType; executionOptions?: ExecutionOptionsSpec; annotations?: { [k: string]: unknown; diff --git a/src/utils/conditionalExecution.test.ts b/src/utils/conditionalExecution.test.ts new file mode 100644 index 0000000000..1399b5dfe4 --- /dev/null +++ b/src/utils/conditionalExecution.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { isFalseCondition } from "./conditionalExecution"; + +describe("isFalseCondition", () => { + it.each(["false", " FALSE\n", false])("recognizes %p as false", (value) => { + expect(isFalseCondition(value)).toBe(true); + }); + + it.each(["true", true, undefined])("does not treat %p as false", (value) => { + expect(isFalseCondition(value)).toBe(false); + }); +}); diff --git a/src/utils/conditionalExecution.ts b/src/utils/conditionalExecution.ts new file mode 100644 index 0000000000..d880046d47 --- /dev/null +++ b/src/utils/conditionalExecution.ts @@ -0,0 +1,6 @@ +export const IS_ENABLED_PORT_NAME = "__is_enabled__"; +export const IS_ENABLED_INPUT_LABEL = "Run when"; + +export function isFalseCondition(value: unknown): boolean { + return String(value).trim().toLowerCase() === "false"; +}