This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 51f1b5b16d8 TS SDK: Reject triggerDagRun tasks in dag.if and 
dag.switch (#74492)
51f1b5b16d8 is described below

commit 51f1b5b16d8551d5fd9380798d63d456234c90e3
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Oct 14 14:52:37 2026 +0800

    TS SDK: Reject triggerDagRun tasks in dag.if and dag.switch (#74492)
    
    * TS SDK: Reject triggerDagRun tasks in dag.if and dag.switch
    
    * TS SDK: Guard the decider handler in #wrapDecider
    
    Check the missing handler where it is used, so any operator task given to
    dag.if or dag.switch is rejected, and name the operator and the call in the 
error.
    
    ---------
    
    Co-authored-by: ZHE YOU LIU <[email protected]>
---
 ts-sdk/src/sdk/dag.ts                    | 18 +++++++++++++-----
 ts-sdk/tests/hitl/operator.test.ts       | 11 +++++++++++
 ts-sdk/tests/sdk/trigger-dag-run.test.ts | 13 +++++++++++++
 3 files changed, 37 insertions(+), 5 deletions(-)

diff --git a/ts-sdk/src/sdk/dag.ts b/ts-sdk/src/sdk/dag.ts
index 0efc5f8672a..d31b5f2fe8d 100644
--- a/ts-sdk/src/sdk/dag.ts
+++ b/ts-sdk/src/sdk/dag.ts
@@ -667,7 +667,7 @@ export class Dag {
     const taskId = condition.taskId;
     const branches: ConditionRecord = {};
     this.#conditions.set(taskId, branches);
-    this.#wrapDecider(taskId, async (held: unknown) => {
+    this.#wrapDecider("dag.if", taskId, async (held: unknown) => {
       if (typeof held !== "boolean") {
         throw new Error(
           `Condition "${taskId}" of Dag "${this.dagId}" returned 
${describeValue(held)} ` +
@@ -730,12 +730,20 @@ export class Dag {
   }
 
   #wrapDecider(
+    verb: "dag.if" | "dag.switch",
     taskId: string,
     decide: (returned: unknown) => Promise<{ skip: string[]; result: unknown 
}>,
   ): void {
-    const { task, spec, fn } = this.#tasks.get(taskId)!;
-    // A decider is always declared from a handler, never from an operator.
-    const inner = fn!;
+    const record = this.#tasks.get(taskId)!;
+    // Only an operator task has no handler.
+    if (record.fn === undefined) {
+      const { label, operatorName } = record.operator;
+      throw new Error(
+        `A ${label ?? operatorName} task of Dag "${this.dagId}" was given to 
${verb}, ` +
+          "which needs a handler to decide the branch with",
+      );
+    }
+    const { task, spec, fn: inner } = record;
     const wrapped: TaskFunction = async (args) => {
       const { skip, result } = await decide(await inner(args as never));
       if (skip.length > 0) await skipTasks(getClient() as CoordinatorClient, 
skip);
@@ -775,7 +783,7 @@ export class Dag {
     const taskId = decider.taskId;
     const candidates: TaskRef[] = [];
     this.#branches.set(taskId, candidates);
-    this.#wrapDecider(taskId, async (chosen: unknown) => {
+    this.#wrapDecider("dag.switch", taskId, async (chosen: unknown) => {
       const known = candidates.map((ref) => ref.taskId);
       const picked = isTaskRef(chosen) && chosen.dagId === this.dagId ? 
chosen.taskId : undefined;
       if (picked === undefined || !known.includes(picked)) {
diff --git a/ts-sdk/tests/hitl/operator.test.ts 
b/ts-sdk/tests/hitl/operator.test.ts
index f6afb9761d7..7dbb8809aa6 100644
--- a/ts-sdk/tests/hitl/operator.test.ts
+++ b/ts-sdk/tests/hitl/operator.test.ts
@@ -58,6 +58,17 @@ describe("a HITL task in a Dag", () => {
     expect(dag.taskIds).toEqual(["release.sign_off"]);
   });
 
+  it.each(["if", "switch"] as const)("is rejected by dag.%s, which needs a 
handler", (decider) => {
+    const dag = new Dag("d");
+    const declare = dag[decider].bind(dag) as (...args: unknown[]) => unknown;
+
+    expect(() =>
+      declare(hitl({ subject: "s", options: ["a"] }), undefined, { taskId: 
"choose" }),
+    ).toThrowError(
+      `A human-in-the-loop task of Dag "d" was given to dag.${decider}, which 
needs a handler`,
+    );
+  });
+
   it("serializes as the operator it mirrors, with its argument bindings", () 
=> {
     const dag = new Dag("d");
     const build = dag.task("build", async () => 1);
diff --git a/ts-sdk/tests/sdk/trigger-dag-run.test.ts 
b/ts-sdk/tests/sdk/trigger-dag-run.test.ts
index 7fc3c42d5c9..ae3c3712cf1 100644
--- a/ts-sdk/tests/sdk/trigger-dag-run.test.ts
+++ b/ts-sdk/tests/sdk/trigger-dag-run.test.ts
@@ -237,6 +237,19 @@ describe("triggerDagRun", () => {
       );
     });
 
+    it.each(["if", "switch"] as const)("a trigger task given to dag.%s", 
(decider) => {
+      const dag = new Dag("d");
+      const declare = dag[decider].bind(dag) as (...args: unknown[]) => 
unknown;
+
+      expect(() =>
+        declare(triggerDagRun({ dagId: "downstream_etl" }), undefined, {
+          taskId: "trigger_downstream",
+        }),
+      ).toThrowError(
+        `A triggerDagRun task of Dag "d" was given to dag.${decider}, which 
needs a handler`,
+      );
+    });
+
     it("a trigger task left uncalled, when the Dag is read", () => {
       const dag = new Dag("d");
       dag.task(triggerDagRun({ dagId: "downstream_etl" }), { taskId: 
"trigger_downstream" });

Reply via email to