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 959a7408362 TS SDK: register mixed-language task handlers with the 
TaskHandler class (#73188)
959a7408362 is described below

commit 959a74083628532112101acdc5d7e0e15f219fdc
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Sep 17 13:19:12 2026 +0800

    TS SDK: register mixed-language task handlers with the TaskHandler class 
(#73188)
    
    A mixed-language Dag is declared in Python: the `@task.stub` per task, the
    queue that routes it to the Node coordinator, and the order between them all
    live there. The SDK said otherwise. The only way to attach a handler was
    `new Dag(dagId)` plus `dag.task(taskId, fn)`, so every mixed-language bundle
    constructed a Dag object for a Dag it does not own.
    
    `new TaskHandler(dagId, taskId, handler)` binds a function to the task it
    implements and carries nothing else: no schedule, no task order, no dag_id 
of
    its own. A handler is a value with no call
    signature, so wiring one the way a natively declared task is wired is a 
compile
    error rather than a runtime throw.
    
    `Dag` becomes exclusively the native case, and a dag_id is one or the 
other: a
    native Dag attaches its own tasks, so registering a handler for one is 
rejected
    rather than silently becoming a second, disagreeing source for its task 
list.
    
    `Registerable` gains its second arm rather than the bundle gaining a second
    verb, so one `register` call still lists everything a bundle provides in any
    mixture. What that changes underneath is the key: a bundle now holds one 
entry
    per dag_id and dispatches on the `(dag_id, task_id)` pair, so one bundle can
    provide for several Dags and the same task_id under two of them is two
    different handlers. The example and the end-to-end test both exercise that,
    with `typescript_example` and the new `typescript_taskflow_example` each
    declaring a `build_message` served from one bundle.mjs.
---
 .../language-sdks/typescript.rst                   |  35 ++--
 .../tests/airflow_e2e_tests/conftest.py            |   9 +-
 .../ts_sdk_tests/test_ts_sdk_dag.py                | 100 ++++++++---
 ts-sdk/README.md                                   |  61 ++++---
 ts-sdk/api-docs/dag-authoring-api.ts               |   2 +-
 ts-sdk/docs/index.md                               |  13 +-
 ts-sdk/example/README.md                           |  10 +-
 ts-sdk/example/dags/typescript_taskflow_example.py |  57 ++++++
 ts-sdk/example/src/main.ts                         |  22 ++-
 ts-sdk/example/src/taskflow.ts                     |  75 ++++++++
 ts-sdk/scripts/verify-package.mjs                  |   2 +-
 ts-sdk/src/cli/pack.ts                             |   4 +-
 ts-sdk/src/coordinator/runtime.ts                  |   8 +-
 ts-sdk/src/index.ts                                |   1 +
 ts-sdk/src/sdk/bundle.ts                           | 144 +++++++++++----
 ts-sdk/src/sdk/task-handler.ts                     |  97 +++++++++++
 ts-sdk/tests/cli/fixtures/entry.ts                 |  13 +-
 ts-sdk/tests/cli/pack.test.ts                      |   6 +-
 ts-sdk/tests/coordinator/integration.test.ts       |  49 +++++-
 ts-sdk/tests/public-api.test.ts                    |  34 +++-
 ts-sdk/tests/sdk/bundle.test.ts                    |   8 +-
 ts-sdk/tests/sdk/task-handler.test.ts              | 193 +++++++++++++++++++++
 22 files changed, 798 insertions(+), 145 deletions(-)

diff --git 
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index 9bfc50e0f67..7aaf6408ac8 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -22,7 +22,7 @@ TypeScript SDK
 
 |experimental|
 
-The TypeScript SDK lets you group task handlers in a ``Dag`` and implement 
their logic in TypeScript (or
+The TypeScript SDK lets you register task handlers on a ``Bundle`` and 
implement their logic in TypeScript (or
 plain JavaScript), running on Node.js. A matching Python stub Dag still 
declares the scheduling shape and
 dependencies; individual tasks delegate to a Node.js subprocess that is 
spawned by
 :class:`~airflow.sdk.coordinators.node.NodeCoordinator` for each task instance.
@@ -37,7 +37,7 @@ The SDK is the ``apache-airflow-ts-sdk`` package (ESM-only). 
It is currently in
 
 .. seealso::
 
-  For the full TypeScript API reference (``Dag``, ``Bundle``, the task handler 
getters,
+  For the full TypeScript API reference (``Bundle``, ``TaskHandler``, ``Dag``, 
the task handler getters,
   ``TaskClient``, supporting types, and exceptions),
   see the `TypeScript SDK API reference 
<https://airflow.apache.org/docs/ts-sdk/stable/>`__.
 
@@ -96,13 +96,13 @@ TypeScript implementation
 A task is an ordinary (usually ``async``) function taking no arguments:
 ``getContext()`` and ``getClient()`` reach the runtime from inside the call, 
so nothing the SDK supplies is a parameter.
 
-Create a ``Dag`` with the ``dag_id`` it implements, attach each handler with 
``dag.task``,
-register it on a ``Bundle``, then serve it to Airflow with ``bundle.serve()``.
+Create a ``TaskHandler`` per task, binding the function to the ``dag_id`` and 
``task_id`` it implements,
+register them on a ``Bundle``, then serve it to Airflow with 
``bundle.serve()``.
 That top-level ``await`` makes the module a runnable bundle entry point.
 
 .. code-block:: typescript
 
-    import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
+    import { Bundle, getClient, TaskHandler } from "apache-airflow-ts-sdk";
 
     export async function buildMessage() {
       const client = getClient();
@@ -114,25 +114,22 @@ That top-level ``await`` makes the module a runnable 
bundle entry point.
       return `${greeting ?? "hello from TypeScript"}; upstream=${upstream ?? 
"missing"}`;
     }
 
-    const dag = new Dag("typescript_example");
-    dag.task("build_message", buildMessage);
-
     const bundle = new Bundle();
-    bundle.register(dag);
+    bundle.register(new TaskHandler("typescript_example", "build_message", 
buildMessage));
     await bundle.serve();
 
-The ``dagId`` passed to ``new Dag(...)`` must match the ``dag_id`` of the 
Python Dag, and each ``taskId``
-passed to ``dag.task`` must match a ``@task.stub`` function in that Dag. What 
the bundle holds is its
-complete set of Dags; a second ``bundle.serve()`` call is rejected. A Dag left 
unregistered is not part of
-the packed bundle, and its tasks are marked removed at runtime.
+The ``dagId`` a handler binds must match the ``dag_id`` of the Python Dag, and 
the ``taskId`` a
+``@task.stub`` function in that Dag, including any TaskGroup prefix.
 
-``register`` is the bundle's one registration verb, and takes any number of 
items, so a bundle that
-collects what it provides across several modules can call it repeatedly 
instead of passing everything to
-the constructor. Registering holds no sockets and starts nothing, so a unit 
test can build a bundle and
-dispatch a handler through ``bundle.getTaskHandler(dagId, taskId)`` without a 
coordinator runtime.
+``register`` takes any number of task handlers and ``bundle.serve()`` serves 
exactly what is registered,
+so a task left out is not part of the packed bundle and is marked removed at 
runtime.
+A second ``bundle.serve()`` call is rejected.
+Registering holds no sockets and starts nothing, so a unit test can build a 
bundle and dispatch a handler
+through ``bundle.getTaskHandler(dagId, taskId)`` without a coordinator runtime.
 
-``new Dag`` and ``dag.task`` take a trailing options object: ``spec`` on both, 
plus ``inputs`` on a task.
-These are not used yet; do not set them. Any other key is rejected.
+``Dag`` is another interface, for a Dag declared in TypeScript rather than in 
Python, and is still a work
+in progress. ``new Dag`` and ``dag.task`` take a trailing options object 
(``spec`` on both, plus
+``inputs`` on a task) that is not used yet; do not set them.
 
 .. note::
 
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 2877a4dc7bb..961f367b907 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -724,9 +724,10 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
     ts_bundles_dir.mkdir()
     copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.mjs", ts_bundles_dir / 
"bundle.mjs")
 
-    copyfile(
-        TS_SDK_EXAMPLE_PATH / "dags" / "typescript_example.py", tmp_dir / 
"dags" / "typescript_example.py"
-    )
+    # Both of the example bundle's Dags: one bundle.mjs provides for two 
dag_ids,
+    # and the tests check that dispatch tells their same-named tasks apart.
+    for dag_file in ("typescript_example.py", 
"typescript_taskflow_example.py"):
+        copyfile(TS_SDK_EXAMPLE_PATH / "dags" / dag_file, tmp_dir / "dags" / 
dag_file)
 
     coordinator_config = json.dumps(
         {
@@ -770,7 +771,7 @@ def spin_up_airflow_environment(tmp_path_factory: 
pytest.TempPathFactory):
     _E2ETestState.airflow_dags_path = tmp_dir / "dags"
 
     # openlineage sources its dags from the provider system tests (via 
_setup_openlineage_integration),
-    # so it must not also load the stock e2e dags — the harness triggers every 
dag it finds.
+    # so it must not also load the stock e2e dags, since the harness triggers 
every dag it finds.
     if E2E_TEST_MODE != "openlineage":
         console.print(f"[yellow]Copying dags to:[/ {tmp_dir / 'dags'}")
         copytree(E2E_DAGS_FOLDER, tmp_dir / "dags", dirs_exist_ok=True)
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
index 3c308100654..5e25ef5801c 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
@@ -21,14 +21,17 @@ Run with::
     E2E_TEST_MODE=ts_sdk uv run --project airflow-e2e-tests pytest \\
         tests/airflow_e2e_tests/ts_sdk_tests/ -xvs
 
-The ``typescript_example`` Dag mixes a Python task with ``@task.stub``
-TypeScript tasks whose handlers live in the ``airflow-ts-pack`` bundle built
-by ``conftest._setup_ts_sdk_integration``. Triggered once via the
-module-scoped ``completed_run`` fixture, the run confirms end-to-end that
-``NodeCoordinator`` launches the bundle on the volume-provided Node runtime,
-Variable/Connection reads and Python <-> TypeScript XCom round-trips work
-through the Task Execution API, and coordinator-channel logs reach the
-task-log store.
+Two Dags mix Python tasks with ``@task.stub`` TypeScript tasks whose handlers 
live in the single
+``airflow-ts-pack`` bundle built by ``conftest._setup_ts_sdk_integration``.
+Each is triggered once via a module-scoped fixture.
+
+``typescript_example`` confirms that ``NodeCoordinator`` launches the bundle 
on the volume-provided
+Node runtime, that Variable/Connection reads and Python <-> TypeScript XCom 
round-trips work through
+the Task Execution API, and that coordinator-channel logs reach the task-log 
store.
+
+``typescript_taskflow_example`` confirms that one bundle provides for two 
``dag_id``s: its
+``build_message`` shares a ``task_id`` with a task in ``typescript_example``, 
so a bundle that keyed
+dispatch on the task ID alone would run the wrong handler for one of them.
 """
 
 from __future__ import annotations
@@ -49,26 +52,28 @@ _TS_TASK_TIMEOUT = 600
 _LOG_FETCH_TIMEOUT = 120
 
 _DAG_ID = "typescript_example"
+_TASKFLOW_DAG_ID = "typescript_taskflow_example"
 
 
 @dataclass
 class _CompletedRun:
     client: AirflowClient
+    dag_id: str
     run_id: str
     state: str
     ti_states: dict[str, str]
 
     def xcom(self, task_id: str, key: str = "return_value"):
-        return self.client.get_xcom_value(dag_id=_DAG_ID, task_id=task_id, 
run_id=self.run_id, key=key).get(
-            "value"
-        )
+        return self.client.get_xcom_value(
+            dag_id=self.dag_id, task_id=task_id, run_id=self.run_id, key=key
+        ).get("value")
 
     def logs(self, task_id: str, try_number: int = 1) -> str:
         """Fetch task logs, retrying until present (log upload is async)."""
         deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
         while True:
             resp = self.client.get_task_logs(
-                dag_id=_DAG_ID, run_id=self.run_id, task_id=task_id, 
try_number=try_number
+                dag_id=self.dag_id, run_id=self.run_id, task_id=task_id, 
try_number=try_number
             )
             text = "\n".join(str(entry) for entry in resp.get("content", []))
             if text.strip() or time.monotonic() > deadline:
@@ -76,16 +81,26 @@ class _CompletedRun:
             time.sleep(3)
 
 
[email protected](scope="module")
-def completed_run() -> _CompletedRun:
-    """Trigger ``typescript_example`` once; every test inspects the same 
run."""
+def _trigger_and_wait(dag_id: str) -> _CompletedRun:
     client = AirflowClient()
-    resp = client.trigger_dag(_DAG_ID, json={"logical_date": 
datetime.now(timezone.utc).isoformat()})
+    resp = client.trigger_dag(dag_id, json={"logical_date": 
datetime.now(timezone.utc).isoformat()})
     run_id = resp["dag_run_id"]
-    state = client.wait_for_dag_run(dag_id=_DAG_ID, run_id=run_id, 
timeout=_TS_TASK_TIMEOUT)
-    ti_resp = client.get_task_instances(dag_id=_DAG_ID, run_id=run_id)
+    state = client.wait_for_dag_run(dag_id=dag_id, run_id=run_id, 
timeout=_TS_TASK_TIMEOUT)
+    ti_resp = client.get_task_instances(dag_id=dag_id, run_id=run_id)
     ti_states = {ti["task_id"]: ti.get("state") for ti in 
ti_resp.get("task_instances", [])}
-    return _CompletedRun(client=client, run_id=run_id, state=state, 
ti_states=ti_states)
+    return _CompletedRun(client=client, dag_id=dag_id, run_id=run_id, 
state=state, ti_states=ti_states)
+
+
[email protected](scope="module")
+def completed_run() -> _CompletedRun:
+    """Trigger ``typescript_example`` once; every test inspects the same 
run."""
+    return _trigger_and_wait(_DAG_ID)
+
+
[email protected](scope="module")
+def completed_taskflow_run() -> _CompletedRun:
+    """Trigger ``typescript_taskflow_example`` once, from the same bundle."""
+    return _trigger_and_wait(_TASKFLOW_DAG_ID)
 
 
 def test_dag_run_succeeded(completed_run: _CompletedRun):
@@ -107,8 +122,8 @@ def test_task_states(completed_run: _CompletedRun):
 
 
 def test_build_message_xcom_round_trip(completed_run: _CompletedRun):
-    """``build_message`` combines ``python_start``'s XCom with the Variable,
-    pushes it under ``typescript_message``, and returns it."""
+    """``build_message`` combines ``python_start``'s XCom with the Variable, 
pushes it under
+    ``typescript_message``, and returns it."""
     assert completed_run.xcom("python_start") == "hello from Python"
 
     message = "greetings from e2e; upstream=hello from Python"
@@ -132,3 +147,46 @@ def test_read_connection_xcom(completed_run: 
_CompletedRun):
 
 def test_coordinator_logs_reach_task_log_store(completed_run: _CompletedRun):
     assert "[ts-sdk.runtime] Coordinator runtime started" in 
completed_run.logs("build_message")
+
+
+def test_second_dag_from_the_same_bundle_succeeded(completed_taskflow_run: 
_CompletedRun):
+    """One packed bundle provides for both Dags, so the second one also 
runs."""
+    assert completed_taskflow_run.state == "success", (
+        f"expected the run to succeed; got {completed_taskflow_run.state!r}. "
+        f"task states: {completed_taskflow_run.ti_states}"
+    )
+    expected = {"make_totals": "success", "summarize": "success", 
"build_message": "success"}
+    for task_id, want in expected.items():
+        assert completed_taskflow_run.ti_states.get(task_id) == want, (
+            f"{task_id!r} expected {want!r}. all task states: 
{completed_taskflow_run.ti_states}"
+        )
+
+
+def test_summarize_xcom(completed_taskflow_run: _CompletedRun):
+    """``summarize`` reads ``make_totals``'s output and averages it."""
+    assert completed_taskflow_run.xcom("make_totals") == {"orders": 12, 
"revenue": 3402.0}
+    value = completed_taskflow_run.xcom("summarize")
+    assert value == {"orders": 12, "averageOrder": 283.5, "currency": "GBP"}, (
+        f"unexpected 'summarize' return_value: {value!r}"
+    )
+
+
+def test_same_task_id_under_two_dags_runs_its_own_handler(
+    completed_run: _CompletedRun, completed_taskflow_run: _CompletedRun
+):
+    """Both Dags have a ``build_message``; each must reach its own handler.
+
+    A bundle that keyed dispatch on the task ID alone would answer both from
+    whichever handler was registered last, and both assertions below would
+    report the same shape.
+    """
+    example_value = completed_run.xcom("build_message")
+    taskflow_value = completed_taskflow_run.xcom("build_message")
+
+    assert set(example_value) == {"message", "upstream"}, (
+        f"unexpected 'typescript_example.build_message' return_value: 
{example_value!r}"
+    )
+    assert taskflow_value == {
+        "dagId": _TASKFLOW_DAG_ID,
+        "message": "12 orders averaging 283.5 GBP",
+    }, f"unexpected 'typescript_taskflow_example.build_message' return_value: 
{taskflow_value!r}"
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 4cc5c9b4e53..c5f60210337 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -35,24 +35,24 @@ npm install [email protected]
 ## Task Handlers
 
 ```ts
-import { Bundle, Dag, getClient, getContext } from "apache-airflow-ts-sdk";
+import { Bundle, getClient, getContext, TaskHandler } from 
"apache-airflow-ts-sdk";
 
 export async function sayHello() {
   const greeting = await getClient().getVariable("greeting");
   return { message: `Hello from ${getContext().taskId}: ${greeting}` };
 }
 
-const dag = new Dag("example_dag");
-dag.task("say_hello", sayHello);
-
 const bundle = new Bundle();
-bundle.register(dag);
+bundle.register(new TaskHandler("example_dag", "say_hello", sayHello));
 await bundle.serve();
 ```
 
 A handler is a plain function. `getContext()` and `getClient()` reach the 
runtime from inside the call,
 so a handler takes no SDK-supplied parameter.
 
+`new TaskHandler(dagId, taskId, handler)` binds the function to the 
Python-owned task it implements.
+The Dag is declared in Python with `@task.stub`, so the TypeScript side only 
supplies the task bodies.
+
 Non-`undefined` return values are pushed to XCom under the `"return_value"`
 key by the active runtime, matching Python `@task` behavior.
 
@@ -105,7 +105,7 @@ Airflow metadata in the bundle itself.
 TypeScript entrypoint:
 
 ```ts
-import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
+import { Bundle, getClient, TaskHandler } from "apache-airflow-ts-sdk";
 
 export async function extract() {
   const client = getClient();
@@ -129,44 +129,41 @@ export async function transform() {
   };
 }
 
-const salesPipeline = new Dag("sales_pipeline");
-salesPipeline.task("extract", extract);
-salesPipeline.task("transform", transform);
-
 const bundle = new Bundle();
-bundle.register(salesPipeline);
+bundle.register(
+  new TaskHandler("sales_pipeline", "extract", extract),
+  new TaskHandler("sales_pipeline", "transform", transform),
+);
 await bundle.serve();
 ```
 
 The Python stub defines the Dag dependency graph. The TypeScript handler does
-the work and uses `TaskClient` for task-time Airflow data access. Create a
-`Dag` with the Python Dag's `dag_id` and attach each handler with the stub
-task's `task_id`. The handler function is the reusable task implementation;
-`dag.task` binds that handler to a Python stub task identity, a `Bundle` holds
-what this bundle process provides, and `bundle.serve()` serves it to Airflow.
-
-`bundle.serve()` is the entrypoint: a Dag left unregistered is not part of the 
bundle,
-and its tasks are marked removed at runtime.
+the work and uses `TaskClient` for task-time Airflow data access. The handler
+function is the reusable task implementation; a `TaskHandler` binds it to a
+Python stub task identity, a `Bundle` holds what this bundle process provides,
+and `bundle.serve()` serves it to Airflow.
+
+`bundle.serve()` is the entrypoint: a task left unregistered is not part of 
the bundle,
+and one bundle can provide for several `TaskHandler`s.
 Registering holds no sockets and starts nothing, so a unit test can build a 
bundle
 and dispatch through `bundle.getTaskHandler(dagId, taskId)` without any 
runtime involved.
 
-`new Dag` and `dag.task` take a trailing options object: `spec` on both, plus 
`inputs` on a task.
-These are not used yet; do not set them.
-
-For larger projects, declare each Dag in its own module and keep one Airflow
-entrypoint that serves them all:
+Dispatch keys on the `(dagId, taskId)` pair, so the same `taskId` under two 
Dags is two different handlers:
 
 ```ts
-import { salesDag } from "./sales/dag";
-import { billingDag } from "./billing/dag";
-import { Bundle } from "apache-airflow-ts-sdk";
-
-await new Bundle(salesDag, billingDag).serve();
+import { Bundle, TaskHandler } from "apache-airflow-ts-sdk";
+import { chargeCustomer } from "./billing/tasks";
+import { extract } from "./sales/tasks";
+
+await new Bundle(
+  new TaskHandler("sales_pipeline", "extract", extract),
+  new TaskHandler("billing_pipeline", "extract", chargeCustomer),
+).serve();
 ```
 
-`register` is the bundle's one registration verb: a bundle that collects what 
it
-provides across several modules can call it repeatedly instead of passing
-everything to the constructor.
+Register `TaskHandler` and `Dag` values with the `register` method, or pass 
them to the `Bundle` constructor.
+
+`Dag` is another interface, for a Dag declared natively in TypeScript, and is 
still a work in progress.
 
 Airflow launches the bundled entrypoint with `--comm=host:port` and
 `--logs=host:port`. `bundle.serve()` connects to those sockets, receives the
diff --git a/ts-sdk/api-docs/dag-authoring-api.ts 
b/ts-sdk/api-docs/dag-authoring-api.ts
index b1e92900f56..da90ccb81d4 100644
--- a/ts-sdk/api-docs/dag-authoring-api.ts
+++ b/ts-sdk/api-docs/dag-authoring-api.ts
@@ -19,7 +19,7 @@
 
 /** @module Authoring */
 
-export { Bundle, Dag, getClient, getContext } from "../src/index.js";
+export { Bundle, Dag, getClient, getContext, TaskHandler } from 
"../src/index.js";
 export type {
   DagSpec,
   Registerable,
diff --git a/ts-sdk/docs/index.md b/ts-sdk/docs/index.md
index e53f5b8dc86..689a8299e32 100644
--- a/ts-sdk/docs/index.md
+++ b/ts-sdk/docs/index.md
@@ -35,28 +35,29 @@ Install the beta package from npm:
 npm install [email protected]
 ```
 
-Define a Dag, register it on a `Bundle`, and serve it.
+Bind a handler to the Python-owned task it implements, register it on a 
`Bundle`, and serve it.
 A handler is a plain function: `getContext()` returns the `TaskContext` and 
`getClient()` the `TaskClient`
 for as long as it runs, so neither is a parameter.
 Any non-`undefined` return value is pushed to XCom under the `"return_value"` 
key by the active runtime,
 matching Python `@task` behavior:
 
 ```ts
-import { Bundle, Dag, getClient, getContext } from "apache-airflow-ts-sdk";
+import { Bundle, getClient, getContext, TaskHandler } from 
"apache-airflow-ts-sdk";
 
 export async function sayHello() {
   const greeting = await getClient().getVariable("greeting");
   return { message: `Hello from ${getContext().taskId}: ${greeting}` };
 }
 
-const dag = new Dag("example_dag");
-dag.task("say_hello", sayHello);
-
 const bundle = new Bundle();
-bundle.register(dag);
+bundle.register(new TaskHandler("example_dag", "say_hello", sayHello));
 await bundle.serve();
 ```
 
+`register` takes any number of items, so one bundle can provide for several 
`TaskHandler`s.
+
+`Dag` is another interface, for a Dag declared in TypeScript rather than in 
Python, and is still a work in progress.
+
 ## Coordinators
 
 Airflow runs TypeScript task bundles through the Python-side `NodeCoordinator`
diff --git a/ts-sdk/example/README.md b/ts-sdk/example/README.md
index d8c690eec90..e9a1e43967b 100644
--- a/ts-sdk/example/README.md
+++ b/ts-sdk/example/README.md
@@ -21,9 +21,10 @@
 
 This example shows the coordinator-mode shape for TypeScript task handlers:
 
-- `dags/typescript_example.py` declares the Airflow Dag and stub tasks.
-- `src/main.ts` registers TypeScript handlers for the same Dag/task IDs and
-  starts the coordinator runtime.
+- `dags/typescript_example.py` and `dags/typescript_taskflow_example.py` 
declare two Airflow Dags and their stub tasks.
+- `src/main.ts` and `src/taskflow.ts` register a `TaskHandler` per stub task 
and start the coordinator runtime.
+  One bundle provides for both Dags, and both declare a task called 
`build_message`.
+  A handler binds the `(dag_id, task_id)` pair, so the two are different tasks 
with different bodies.
 - `dist/bundle.mjs` is the generated Node.js bundle that Airflow launches.
 
 The build uses the SDK's `airflow-ts-pack` tool, which bundles the entrypoint
@@ -70,7 +71,7 @@ export AIRFLOW__SDK__COORDINATORS='{
 export AIRFLOW__SDK__QUEUE_TO_COORDINATOR='{"typescript": "ts"}'
 ```
 
-Copy `dags/typescript_example.py` into your Airflow Dags folder.
+Copy both files in `dags/` into your Airflow Dags folder.
 
 The example also uses one Variable and one Connection:
 
@@ -87,4 +88,5 @@ Then start Airflow and trigger the Dag:
 
 ```bash
 airflow dags trigger typescript_example
+airflow dags trigger typescript_taskflow_example
 ```
diff --git a/ts-sdk/example/dags/typescript_taskflow_example.py 
b/ts-sdk/example/dags/typescript_taskflow_example.py
new file mode 100644
index 00000000000..57b0d08ef9b
--- /dev/null
+++ b/ts-sdk/example/dags/typescript_taskflow_example.py
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+"""
+A second Python-owned Dag served by the same TypeScript bundle.
+
+``typescript_example`` shows the basics; this Dag exists so one bundle 
provides for two ``dag_id``s at once.
+Its ``build_message`` stub deliberately shares a ``task_id`` with a task in 
``typescript_example``:
+a handler binds the ``(dag_id, task_id)`` pair, so the two are different tasks 
with different bodies.
+See ``src/taskflow.ts``.
+"""
+
+from __future__ import annotations
+
+from airflow.sdk import dag, task
+
+
+@task
+def make_totals():
+    return {"orders": 12, "revenue": 3402.0}
+
+
[email protected](queue="typescript")
+def summarize(): ...
+
+
+# Same task_id as `typescript_example.build_message`, on purpose.
[email protected](queue="typescript")
+def build_message(): ...
+
+
+@dag(
+    dag_id="typescript_taskflow_example",
+    schedule=None,
+    catchup=False,
+    tags=["typescript", "example", "taskflow"],
+)
+def typescript_taskflow_example():
+    make_totals() >> summarize() >> build_message()
+
+
+typescript_taskflow_example()
diff --git a/ts-sdk/example/src/main.ts b/ts-sdk/example/src/main.ts
index 3914148937a..d1644ade4e2 100644
--- a/ts-sdk/example/src/main.ts
+++ b/ts-sdk/example/src/main.ts
@@ -17,9 +17,14 @@
  * under the License.
  */
 
-import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
+// The bundle entry point: one bundle, two Python-owned Dags.
+//
+// Both Dags in `dags/` are declared in Python with `@task.stub` tasks routed 
to the Node
+// coordinator, so this side only supplies the task bodies.
 
-const dag = new Dag("typescript_example");
+import { Bundle, getClient, TaskHandler } from "apache-airflow-ts-sdk";
+
+import { buildSummaryMessage, summarize } from "./taskflow.js";
 
 export async function buildMessage() {
   const client = getClient();
@@ -50,9 +55,14 @@ export async function readConnection() {
   };
 }
 
-dag.task("build_message", buildMessage);
-dag.task("read_connection", readConnection);
-
+// One register call lists everything this bundle provides.
+// `build_message` appears under both Dags: two different handlers, told apart 
by the dag_id each
+// is bound to and never by the task_id alone.
 const bundle = new Bundle();
-bundle.register(dag);
+bundle.register(
+  new TaskHandler("typescript_example", "build_message", buildMessage),
+  new TaskHandler("typescript_example", "read_connection", readConnection),
+  new TaskHandler("typescript_taskflow_example", "summarize", summarize),
+  new TaskHandler("typescript_taskflow_example", "build_message", 
buildSummaryMessage),
+);
 await bundle.serve();
diff --git a/ts-sdk/example/src/taskflow.ts b/ts-sdk/example/src/taskflow.ts
new file mode 100644
index 00000000000..54629cab87d
--- /dev/null
+++ b/ts-sdk/example/src/taskflow.ts
@@ -0,0 +1,75 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Handlers for the `typescript_taskflow_example` Dag.
+//
+// A second Python-owned Dag served by the same bundle, so the pair of ids a 
handler binds is what
+// tells its tasks apart from `typescript_example`'s.
+// `build_summary_message` implements a task named `build_message`, exactly as 
the other Dag has,
+// and the two share nothing else.
+
+import { getClient, getContext } from "apache-airflow-ts-sdk";
+
+/** What `make_totals` returns on the Python side. */
+export interface Totals {
+  orders: number;
+  revenue: number;
+}
+
+/** What {@link summarize} returns, and what {@link buildSummaryMessage} 
reads. */
+export interface Summary {
+  orders: number;
+  averageOrder: number;
+  currency: string;
+}
+
+export async function summarize(): Promise<Summary> {
+  const totals = await getClient().getXCom<Totals>({
+    key: "return_value",
+    taskId: "make_totals",
+  });
+  if (totals === null) {
+    throw new Error(`task ${getContext().taskId} has no totals to summarize`);
+  }
+  const average = totals.orders === 0 ? 0 : totals.revenue / totals.orders;
+
+  return {
+    orders: totals.orders,
+    averageOrder: Number(average.toFixed(2)),
+    currency: "GBP",
+  };
+}
+
+export async function buildSummaryMessage() {
+  const ctx = getContext();
+  const summary = await getClient().getXCom<Summary>({
+    key: "return_value",
+    taskId: "summarize",
+  });
+  if (summary === null) {
+    throw new Error(`task ${ctx.taskId} has no summary to report`);
+  }
+
+  return {
+    // The dag_id is in the return value on purpose: it is how the end-to-end 
test tells this task
+    // apart from `typescript_example`'s `build_message`.
+    dagId: ctx.dagId,
+    message: `${summary.orders} orders averaging ${summary.averageOrder} 
${summary.currency}`,
+  };
+}
diff --git a/ts-sdk/scripts/verify-package.mjs 
b/ts-sdk/scripts/verify-package.mjs
index e8064682da4..463a21e679e 100644
--- a/ts-sdk/scripts/verify-package.mjs
+++ b/ts-sdk/scripts/verify-package.mjs
@@ -34,7 +34,7 @@ export const REQUIRED_ROOT_FILES = ["LICENSE", "NOTICE", 
"README.md", "package.j
 // The Dag-authoring entrypoints a consumer must be able to reach from the 
package root.
 // The two getters are here because a handler cannot reach the runtime without 
them: a
 // published build that dropped them would still import, and fail at the first 
task.
-export const REQUIRED_ROOT_EXPORTS = ["Bundle", "Dag", "getClient", 
"getContext"];
+export const REQUIRED_ROOT_EXPORTS = ["Bundle", "Dag", "TaskHandler", 
"getClient", "getContext"];
 
 function run(command, args, options = {}) {
   const result = spawnSync(command, args, {
diff --git a/ts-sdk/src/cli/pack.ts b/ts-sdk/src/cli/pack.ts
index 5acea1b9626..08a3aff008b 100644
--- a/ts-sdk/src/cli/pack.ts
+++ b/ts-sdk/src/cli/pack.ts
@@ -200,7 +200,9 @@ export async function runPack(argv: readonly string[]): 
Promise<void> {
     const manifest = readBundleManifest(stagingPath);
     const dagEntries = Object.entries(manifest.dags);
     if (dagEntries.length === 0) {
-      throw new Error(`${args.entry} served no Dags; register them with 
bundle.register(...)`);
+      throw new Error(
+        `${args.entry} served nothing; register Dags or task handlers with 
bundle.register(...)`,
+      );
     }
     // Warn rather than fail, as airflow-go-pack does: the shared schema 
allows a
     // Dag with no tasks.
diff --git a/ts-sdk/src/coordinator/runtime.ts 
b/ts-sdk/src/coordinator/runtime.ts
index 0e05ec24b58..e34e37c1303 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -23,10 +23,10 @@
 //
 //     node my-bundle.mjs --comm=host:port --logs=host:port
 //
-// where `my-bundle.mjs` is a user-bundled Node script that imports
-// the SDK, creates `Dag` objects, attaches a handler per task with
-// `dag.task(...)`, registers them on a `Bundle`, then awaits `bundle.serve()`.
-// Each handler runs inside a task scope, which is what `getContext()` and
+// where `my-bundle.mjs` is a user-bundled Node script that imports the SDK,
+// registers what it provides on a `Bundle` (a `TaskHandler` per Python-owned
+// task, a `Dag` per natively declared one), then awaits `bundle.serve()`. Each
+// handler runs inside a task scope, which is what `getContext()` and
 // `getClient()` read.
 //
 // Lifecycle:
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index edf2ceaafc9..935d58f95fd 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -19,6 +19,7 @@
 
 export { Dag } from "./sdk/dag.js";
 export { Bundle } from "./sdk/bundle.js";
+export { TaskHandler } from "./sdk/task-handler.js";
 export { getClient, getContext } from "./sdk/task.js";
 export { ConnectionNotFoundError, VariableNotFoundError } from 
"./sdk/client.js";
 export { SUPERVISOR_API_VERSION } from "./coordinator/index.js";
diff --git a/ts-sdk/src/sdk/bundle.ts b/ts-sdk/src/sdk/bundle.ts
index 615fe971ee9..612adbac8b7 100644
--- a/ts-sdk/src/sdk/bundle.ts
+++ b/ts-sdk/src/sdk/bundle.ts
@@ -21,27 +21,38 @@
 
 import { brand, DUPLICATE_COPY_HINT, hasBrand } from "./brand.js";
 import { Dag, getDagTaskRecords, isDag, type TaskRef } from "./dag.js";
+import { getTaskHandlerFunction, isTaskHandler, TaskHandler } from 
"./task-handler.js";
 import type { TaskFunction } from "./task.js";
 
 // Assigned inside Bundle's static block, as Dag does for its tasks.
-let dagsOf: (bundle: Bundle) => ReadonlyMap<string, Dag>;
+let entriesOf: (bundle: Bundle) => ReadonlyMap<string, BundleEntry>;
 
 /**
- * Anything {@link Bundle.register} accepts.
- *
- * A union rather than a base class or an interface: TypeScript's equivalent of
- * the sealed interface the Go SDK uses for the same purpose. Registering gains
- * a kind by gaining an arm here, never a second verb.
+ * What {@link Bundle.register} and the {@link Bundle} constructor accept: a
+ * {@link TaskHandler} for a task that a Python Dag declares, or a {@link Dag}
+ * declared in TypeScript.
  */
-export type Registerable = Dag;
+export type Registerable = Dag | TaskHandler;
+
+// What a bundle holds per dag_id. The two arms are exclusive by construction:
+// a Dag is the native case and owns its own tasks, while task handlers supply
+// bodies for a Dag that Python declares, so one dag_id is never both.
+type BundleEntry =
+  | { readonly kind: "dag"; readonly dag: Dag }
+  | { readonly kind: "handlers"; readonly handlers: Map<string, TaskFunction> 
};
+
+function entryTaskIds(entry: BundleEntry): string[] {
+  return entry.kind === "dag" ? [...entry.dag.taskIds] : 
[...entry.handlers.keys()];
+}
 
 /** Internal: whether `value` is a Bundle built by any copy of this package. */
 export function isBundle(value: unknown): value is Bundle {
   return hasBrand(value, "Bundle");
 }
 
-/** Internal: a registered Dag with its task IDs, as {@link listBundleDags} 
reports it.
- *  A task-less Dag is included, so the bundle manifest keeps it visible. */
+/** Internal: a Dag this bundle provides for, with its task IDs, as
+ *  {@link listBundleDags} reports it. A task-less native Dag is included, so
+ *  the bundle manifest keeps it visible. */
 export interface RegisteredDag {
   /** Identifier of the registered Dag. */
   readonly dagId: string;
@@ -73,10 +84,12 @@ export interface RegisteredDag {
  * a snapshot of its tasks.
  */
 export class Bundle {
-  readonly #dags = new Map<string, Dag>();
+  // Keyed by dag_id and insertion-ordered, so the bundle manifest lists what
+  // this process provides in the order the entry point registered it.
+  readonly #entries = new Map<string, BundleEntry>();
 
   static {
-    dagsOf = (bundle) => bundle.#dags;
+    entriesOf = (bundle) => bundle.#entries;
   }
 
   /** Registers `items`, on the same terms as {@link register}. */
@@ -91,28 +104,87 @@ export class Bundle {
    *  The constructor covers the common case; this is for a bundle that
    *  collects what it provides across several modules. */
   register(...items: Registerable[]): void {
-    const incoming = new Set<string>();
+    // Validated against what is already held *and* against this call, in full,
+    // before anything is written: a call that throws registers none of its
+    // items, so a bundle never half-provides what its author listed once.
+    const incomingDags = new Set<string>();
+    const incomingTaskHandlers = new Set<string>();
     for (const item of items) {
       // Typed as Registerable, so narrowing it would collapse to never; these
       // guard callers reaching this from plain JavaScript.
       const candidate: unknown = item;
-      // Another copy's Dag cannot be registered, since lookups read a private
-      // task map keyed to this copy's class, so it is rejected by its cause.
-      if (!(candidate instanceof Dag)) {
+      // Another copy's value cannot be registered, since both kinds read
+      // private state keyed to this copy's class, so it is rejected by its 
cause.
+      if (candidate instanceof Dag) {
+        this.#checkDag(candidate, incomingDags);
+      } else if (candidate instanceof TaskHandler) {
+        this.#checkTaskHandler(candidate, incomingDags, incomingTaskHandlers);
+      } else if (isDag(candidate)) {
+        throw new Error(`Dag "${(candidate as Dag).dagId}" 
${DUPLICATE_COPY_HINT}`);
+      } else if (isTaskHandler(candidate)) {
+        const foreign = candidate as TaskHandler;
         throw new Error(
-          isDag(candidate)
-            ? `Dag "${candidate.dagId}" ${DUPLICATE_COPY_HINT}`
-            : "only Dag instances can be registered",
+          `Task handler for Dag "${foreign.dagId}" task "${foreign.taskId}" 
${DUPLICATE_COPY_HINT}`,
         );
+      } else {
+        throw new Error("only Dag and TaskHandler instances can be 
registered");
       }
-      if (this.#dags.has(item.dagId) || incoming.has(item.dagId)) {
-        throw new Error(`Dag "${item.dagId}" is already registered`);
-      }
-      incoming.add(item.dagId);
     }
     for (const item of items) {
-      this.#dags.set(item.dagId, item);
+      if (item instanceof Dag) {
+        this.#entries.set(item.dagId, { kind: "dag", dag: item });
+      } else {
+        const existing = this.#entries.get(item.dagId);
+        const handlers =
+          existing?.kind === "handlers" ? existing.handlers : new Map<string, 
TaskFunction>();
+        handlers.set(item.taskId, getTaskHandlerFunction(item));
+        if (existing === undefined) {
+          this.#entries.set(item.dagId, { kind: "handlers", handlers });
+        }
+      }
+    }
+  }
+
+  #checkDag(dag: Dag, incomingDags: Set<string>): void {
+    if (this.#entries.get(dag.dagId)?.kind === "handlers") {
+      throw new Error(
+        `Dag "${dag.dagId}" already has registered task handlers; a Dag 
declared in ` +
+          "TypeScript owns its own tasks, so one Dag ID cannot have both",
+      );
+    }
+    if (this.#entries.has(dag.dagId) || incomingDags.has(dag.dagId)) {
+      throw new Error(`Dag "${dag.dagId}" is already registered`);
+    }
+    incomingDags.add(dag.dagId);
+  }
+
+  #checkTaskHandler(
+    handler: TaskHandler,
+    incomingDags: Set<string>,
+    incomingTaskHandlers: Set<string>,
+  ): void {
+    // A native Dag attaches its tasks with dag.task(...), so a task handler 
for
+    // the same Dag ID would be a second, disagreeing source for its task list.
+    if (this.#entries.get(handler.dagId)?.kind === "dag" || 
incomingDags.has(handler.dagId)) {
+      throw new Error(
+        `Dag "${handler.dagId}" is declared in TypeScript; attach its tasks 
with ` +
+          "dag.task(...) rather than registering task handlers for them",
+      );
+    }
+    const entry = this.#entries.get(handler.dagId);
+    // Keyed on the pair, not the task ID: one bundle serves several Dags, and
+    // the same task_id under two of them is two different handlers.
+    const pair = `${handler.dagId}\u0000${handler.taskId}`;
+    if (
+      entry?.kind === "handlers"
+        ? entry.handlers.has(handler.taskId)
+        : incomingTaskHandlers.has(pair)
+    ) {
+      throw new Error(
+        `A handler for Dag "${handler.dagId}" task "${handler.taskId}" is 
already registered`,
+      );
     }
+    incomingTaskHandlers.add(pair);
   }
 
   /**
@@ -143,8 +215,11 @@ export class Bundle {
   /** Look up a registered handler, the way the runtime dispatches a task.
    *  Returns `undefined` when no handler exists. */
   getTaskHandler(dagId: string, taskId: string): TaskFunction | undefined {
-    const dag = this.#dags.get(dagId);
-    return dag ? getDagTaskRecords(dag).get(taskId)?.fn : undefined;
+    const entry = this.#entries.get(dagId);
+    if (entry === undefined) return undefined;
+    return entry.kind === "dag"
+      ? getDagTaskRecords(entry.dag).get(taskId)?.fn
+      : entry.handlers.get(taskId);
   }
 }
 
@@ -159,18 +234,21 @@ export function validateOwnBundle(value: unknown, 
accessor: string): asserts val
   );
 }
 
-/** Internal: the task handles across a bundle's Dags. Not re-exported from the
- *  package root: enumerating what the runtime dispatches is the runtime's 
job. */
+/** Internal: every task handle this bundle can dispatch, across both kinds. 
Not
+ *  re-exported from the package root: enumerating what the runtime dispatches
+ *  is the runtime's job. */
 export function listBundleTasks(bundle: Bundle): TaskRef[] {
-  return [...dagsOf(bundle).values()].flatMap((dag) =>
-    [...getDagTaskRecords(dag).values()].map((record) => record.task),
+  return [...entriesOf(bundle)].flatMap(([dagId, entry]) =>
+    entryTaskIds(entry).map((taskId) => ({ dagId, taskId })),
   );
 }
 
-/** Internal: every registered Dag with its task IDs, empty Dags included. */
+/** Internal: every Dag this bundle provides for, with its task IDs. A native
+ *  Dag with no tasks is included; a Dag known only through task handlers 
always
+ *  has at least one, since a handler is what put it here. */
 export function listBundleDags(bundle: Bundle): RegisteredDag[] {
-  return [...dagsOf(bundle).values()].map((dag) => ({
-    dagId: dag.dagId,
-    tasks: [...dag.taskIds],
+  return [...entriesOf(bundle)].map(([dagId, entry]) => ({
+    dagId,
+    tasks: entryTaskIds(entry),
   }));
 }
diff --git a/ts-sdk/src/sdk/task-handler.ts b/ts-sdk/src/sdk/task-handler.ts
new file mode 100644
index 00000000000..a682b586d1b
--- /dev/null
+++ b/ts-sdk/src/sdk/task-handler.ts
@@ -0,0 +1,97 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The mixed-language authoring surface: a TypeScript function bound to the
+// Python-owned task it implements.
+
+import { brand, hasBrand } from "./brand.js";
+import type { TaskFunction } from "./task.js";
+
+// Assigned inside TaskHandler's static block, as Dag does for its tasks.
+let functionOf: (handler: TaskHandler) => TaskFunction;
+
+/** Internal: whether `value` is a TaskHandler built by any copy of this 
package. */
+export function isTaskHandler(value: unknown): value is TaskHandler {
+  return hasBrand(value, "TaskHandler");
+}
+
+function requireId(label: string, value: string): string {
+  // Typed as string, so plain JavaScript is what these catch, along with the
+  // empty string, which types cannot rule out and which no Airflow id can be.
+  const candidate: unknown = value;
+  if (typeof candidate !== "string" || candidate.length === 0) {
+    throw new Error(`${label} for a task handler must be a non-empty string`);
+  }
+  return value;
+}
+
+/**
+ * A TypeScript function bound to the Python-owned task it implements.
+ *
+ * A mixed-language Dag declares its structure in Python, with a `@task.stub`
+ * per task routed to the Node coordinator. TypeScript supplies the task bodies
+ * and nothing else: no dag_id of its own, no schedule, no task order.
+ *
+ * ```ts
+ * const bundle = new Bundle();
+ * bundle.register(new TaskHandler("etl", "transform", transform));
+ * await bundle.serve();
+ * ```
+ *
+ * `dagId` must match the Python Dag's `dag_id` and `taskId` a `@task.stub` in
+ * it, including any TaskGroup prefix. Both are written out: nothing is derived
+ * from the handler's function name, which the build step is free to rename.
+ *
+ * Identity and a body, and no more. A handler has no factory to call, so 
wiring
+ * one the way a natively declared task is wired is a compile error rather than
+ * a runtime throw. For a native Dag, use {@link Dag} instead.
+ */
+export class TaskHandler<TReturn = unknown> {
+  /** Identifier of the Python Dag this task belongs to. */
+  readonly dagId: string;
+  /** Airflow task ID this handler implements, including any TaskGroup prefix. 
*/
+  readonly taskId: string;
+  readonly #handler: TaskFunction<TReturn>;
+
+  static {
+    functionOf = (handler) => handler.#handler as TaskFunction;
+  }
+
+  constructor(dagId: string, taskId: string, handler: TaskFunction<TReturn>) {
+    this.dagId = requireId("dagId", dagId);
+    this.taskId = requireId("taskId", taskId);
+    if (typeof handler !== "function") {
+      throw new Error(`handler for Dag "${dagId}" task "${taskId}" must be a 
function`);
+    }
+    brand(this, "TaskHandler");
+    this.#handler = handler;
+  }
+}
+
+/**
+ * Internal: the function a TaskHandler carries, for bundle dispatch.
+ *
+ * Not re-exported from the package root, and the package `"exports"` map 
blocks
+ * deep imports, so this is unreachable from outside the SDK. The field is
+ * private for the same reason `TaskRef` does not expose its handler: what a
+ * handler binds is identity, and reaching the body is the runtime's business.
+ */
+export function getTaskHandlerFunction(handler: TaskHandler): TaskFunction {
+  return functionOf(handler);
+}
diff --git a/ts-sdk/tests/cli/fixtures/entry.ts 
b/ts-sdk/tests/cli/fixtures/entry.ts
index bd388974a2e..607c62cdf0b 100644
--- a/ts-sdk/tests/cli/fixtures/entry.ts
+++ b/ts-sdk/tests/cli/fixtures/entry.ts
@@ -17,12 +17,15 @@
  * under the License.
  */
 
-import { Bundle, Dag } from "../../../src/index.js";
+import { Bundle, Dag, TaskHandler } from "../../../src/index.js";
 
-const fixtureDag = new Dag("fixture_dag");
-fixtureDag.task("extract", async () => "extracted");
-fixtureDag.task("transform", async () => "transformed");
+// A mixed bundle: task handlers for a Python-owned Dag, plus a natively
+// declared one, so packing covers both paths into the manifest.
 const otherDag = new Dag("other_dag");
 otherDag.task("solo", async () => undefined);
 
-await new Bundle(fixtureDag, otherDag).serve();
+await new Bundle(
+  new TaskHandler("fixture_dag", "extract", async () => "extracted"),
+  new TaskHandler("fixture_dag", "transform", async () => "transformed"),
+  otherDag,
+).serve();
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
index cd5dc1ca0b6..34d4b3a3d86 100644
--- a/ts-sdk/tests/cli/pack.test.ts
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -286,10 +286,12 @@ describe("runPack", () => {
     expect(existsSync(path.join(outdir, 
"bundle.pack-staging.mjs"))).toBe(false);
   });
 
-  it("leaves no bundle behind when the entry serves no Dags", async () => {
+  it("leaves no bundle behind when the entry serves nothing", async () => {
     outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
 
-    await expect(runPack([EMPTY_ENTRY, "--outdir", 
outdir])).rejects.toThrow("served no Dags");
+    await expect(runPack([EMPTY_ENTRY, "--outdir", outdir])).rejects.toThrow(
+      "served nothing; register Dags or task handlers",
+    );
     expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
     expect(existsSync(path.join(outdir, 
"bundle.pack-staging.mjs"))).toBe(false);
   });
diff --git a/ts-sdk/tests/coordinator/integration.test.ts 
b/ts-sdk/tests/coordinator/integration.test.ts
index e6294154187..fabd39a089c 100644
--- a/ts-sdk/tests/coordinator/integration.test.ts
+++ b/ts-sdk/tests/coordinator/integration.test.ts
@@ -37,13 +37,15 @@ import {
 } from "../../src/coordinator/runtime.js";
 import { Dag } from "../../src/sdk/dag.js";
 import { Bundle } from "../../src/sdk/bundle.js";
+import { TaskHandler } from "../../src/sdk/task-handler.js";
 import { getClient, getContext } from "../../src/sdk/task.js";
 
 const testDag = new Dag("test_dag");
 const otherDag = new Dag("other_dag");
 // The bundle the runtime dispatches through. startCoordinator() is driven
 // directly rather than through bundle.serve(), so these tests can supply mock
-// socket addresses.
+// socket addresses. Both authoring kinds are registered in one call, since the
+// runtime dispatches through one lookup regardless of which put a task there.
 const bundle = new Bundle(testDag, otherDag);
 
 interface MockResult {
@@ -356,6 +358,51 @@ describe("coordinator runtime integration", () => {
     });
   });
 
+  it("dispatches to a task handler registered for a Python-owned Dag", async 
() => {
+    // The mixed-language path: no Dag object exists for `py_dag` on this side,
+    // only a handler bound to its dag_id and task_id.
+    let observedCtx: unknown = null;
+    bundle.register(
+      new TaskHandler("py_dag", "transform", async () => {
+        observedCtx = getContext();
+        return "transformed";
+      }),
+    );
+
+    const result = await driveSupervisor(makeStartupDetails("transform", 
"py_dag"));
+
+    expect(result.firstResponse!.body).toMatchObject({ type: "SucceedTask" });
+    expect(observedCtx).toMatchObject({ dagId: "py_dag", taskId: "transform" 
});
+    const setXComReqs = result.runtimeRequests.filter((r) => r.type === 
"SetXCom");
+    expect(setXComReqs[0]!.body).toMatchObject({
+      key: "return_value",
+      value: "transformed",
+      dag_id: "py_dag",
+      task_id: "transform",
+    });
+  });
+
+  it("keeps two Dags' same-named tasks apart when dispatching", async () => {
+    // Registration keys on the pair, and so must dispatch: the runtime is
+    // handed dag_id and task_id together and must not answer from the wrong 
one.
+    bundle.register(
+      new TaskHandler("pair_dag_a", "shared", async () => "from a"),
+      new TaskHandler("pair_dag_b", "shared", async () => "from b"),
+    );
+
+    for (const [dagId, expected] of [
+      ["pair_dag_a", "from a"],
+      ["pair_dag_b", "from b"],
+    ]) {
+      const result = await driveSupervisor(makeStartupDetails("shared", 
dagId));
+      expect(result.firstResponse!.body).toMatchObject({ type: "SucceedTask" 
});
+      expect(result.runtimeRequests.find((r) => r.type === 
"SetXCom")!.body).toMatchObject({
+        value: expected,
+        dag_id: dagId,
+      });
+    }
+  });
+
   it("aborts the context signal on SIGTERM and reports a thrown task error", 
async () => {
     let sawAbort = false;
     testDag.task("aborted_then_failed", async () => {
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index ad94cd389a0..dae26146e1a 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -41,6 +41,7 @@ import {
   getClient,
   getContext,
   SUPERVISOR_API_VERSION,
+  TaskHandler,
   VariableNotFoundError,
 } from "../src/index.js";
 
@@ -155,6 +156,37 @@ describe("public API", () => {
     expectTypeOf<typeof sdk>().not.toHaveProperty("startCoordinator");
   });
 
+  it("exports TaskHandler as the mixed-language authoring surface", () => {
+    const transform = async () => "transformed";
+    const bundle = new Bundle(new TaskHandler("py_etl", "transform", 
transform));
+
+    expect(bundle.getTaskHandler("py_etl", "transform")).toBe(transform);
+    // Identity and a body, and no more: no schedule, no task order, no dag_id
+    // of its own to declare.
+    expectTypeOf<keyof TaskHandler>().toEqualTypeOf<"dagId" | "taskId">();
+    expectTypeOf<ConstructorParameters<typeof TaskHandler>>().toEqualTypeOf<
+      [string, string, TaskFunction<unknown>]
+    >();
+  });
+
+  it("does not let a task handler be wired the way a native task is", () => {
+    // The guarantee an earlier draft's separate MixedLangDag class existed to
+    // provide: a handler has no factory to call, so calling one is a compile
+    // error rather than a runtime throw.
+    const rejectsFactoryMisuse = () => {
+      const handler = new TaskHandler("py_etl", "transform", async () => 
undefined);
+      // @ts-expect-error a task handler is a value, not a callable task 
factory.
+      handler();
+      // @ts-expect-error dagId and taskId are positional, not an options 
object.
+      new TaskHandler({ dagId: "py_etl", taskId: "transform" }, async () => 
undefined);
+      // @ts-expect-error the task_id is always written out, never derived.
+      new TaskHandler("py_etl", async () => undefined);
+      // @ts-expect-error a handler does not expose the function it carries.
+      void handler.handler;
+    };
+    void rejectsFactoryMisuse;
+  });
+
   describe("the task-handler getters", () => {
     it("throw outside a handler, naming the accessor", () => {
       // The full scope behaviour is covered in tests/sdk/task-scope.test.ts;
@@ -194,7 +226,7 @@ describe("public API", () => {
     expectTypeOf<Bundle["serve"]>().toEqualTypeOf<() => Promise<void>>();
     expectTypeOf<Bundle["register"]>().toEqualTypeOf<(...items: 
Registerable[]) => void>();
     expectTypeOf<ConstructorParameters<typeof 
Bundle>>().toEqualTypeOf<Registerable[]>();
-    expectTypeOf<Registerable>().toEqualTypeOf<Dag>();
+    expectTypeOf<Registerable>().toEqualTypeOf<Dag | TaskHandler>();
     for (const name of ["serveDags", "DagRegistry"]) {
       expect(name in sdk).toBe(false);
     }
diff --git a/ts-sdk/tests/sdk/bundle.test.ts b/ts-sdk/tests/sdk/bundle.test.ts
index e5725cb7810..37f6f68b663 100644
--- a/ts-sdk/tests/sdk/bundle.test.ts
+++ b/ts-sdk/tests/sdk/bundle.test.ts
@@ -49,9 +49,9 @@ describe("Bundle", () => {
     );
   });
 
-  it("rejects constructor values that are not Dag instances", () => {
+  it("rejects constructor values that are neither a Dag nor a task handler", 
() => {
     expect(() => new Bundle({ dagId: "example_dag" } as unknown as 
Dag)).toThrowError(
-      /only Dag instances can be registered/,
+      /only Dag and TaskHandler instances can be registered/,
     );
   });
 
@@ -111,10 +111,10 @@ describe("Bundle", () => {
     expect(listBundleTasks(bundle)).toEqual([]);
   });
 
-  it("rejects values that are not Dag instances", () => {
+  it("rejects values that are neither a Dag nor a task handler", () => {
     const bundle = new Bundle();
     expect(() => bundle.register({ dagId: "example_dag" } as unknown as 
Dag)).toThrowError(
-      /only Dag instances can be registered/,
+      /only Dag and TaskHandler instances can be registered/,
     );
   });
 
diff --git a/ts-sdk/tests/sdk/task-handler.test.ts 
b/ts-sdk/tests/sdk/task-handler.test.ts
new file mode 100644
index 00000000000..78bb1541802
--- /dev/null
+++ b/ts-sdk/tests/sdk/task-handler.test.ts
@@ -0,0 +1,193 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { Bundle, listBundleDags, listBundleTasks } from 
"../../src/sdk/bundle.js";
+import { Dag } from "../../src/sdk/dag.js";
+import { getTaskHandlerFunction, TaskHandler } from 
"../../src/sdk/task-handler.js";
+
+describe("TaskHandler", () => {
+  it("binds a function to the Python-owned task it implements", () => {
+    const transform = async () => "transformed";
+    const handler = new TaskHandler("etl", "transform", transform);
+
+    expect(handler.dagId).toBe("etl");
+    expect(handler.taskId).toBe("transform");
+    expect(getTaskHandlerFunction(handler)).toBe(transform);
+  });
+
+  it("derives nothing from the function's name", () => {
+    // The build step is free to rename or inline a function, so the task_id is
+    // always written out and an anonymous handler is perfectly ordinary.
+    const handler = new TaskHandler("etl", "transform", async () => undefined);
+    expect(handler.taskId).toBe("transform");
+  });
+
+  it.each([
+    ["an empty dagId", "", "transform", /dagId for a task handler must be a 
non-empty string/],
+    ["an empty taskId", "etl", "", /taskId for a task handler must be a 
non-empty string/],
+  ])("rejects %s", (_label, dagId, taskId, message) => {
+    expect(() => new TaskHandler(dagId, taskId, async () => 
undefined)).toThrowError(message);
+  });
+
+  it("rejects a handler that is not a function", () => {
+    expect(
+      () => new TaskHandler("etl", "transform", "not a function" as unknown as 
() => void),
+    ).toThrowError(/handler for Dag "etl" task "transform" must be a 
function/);
+  });
+
+  it("does not expose the function it carries", () => {
+    // As with TaskRef: what a handler binds is identity. Reaching the body is
+    // the runtime's business, through an accessor the package root never 
ships.
+    const handler = new TaskHandler("etl", "transform", async () => undefined);
+    for (const name of ["handler", "fn", "run", "call"]) {
+      expect(name in handler).toBe(false);
+    }
+  });
+});
+
+describe("a bundle of task handlers", () => {
+  it("registers Dags and task handlers in one call", () => {
+    const nativeDag = new Dag("native_etl");
+    nativeDag.task("extract", async () => undefined);
+    const transform = async () => "transformed";
+
+    const bundle = new Bundle();
+    bundle.register(nativeDag, new TaskHandler("py_etl", "transform", 
transform));
+
+    expect(bundle.getTaskHandler("py_etl", "transform")).toBe(transform);
+    expect(bundle.getTaskHandler("native_etl", "extract")).toBeDefined();
+  });
+
+  it("dispatches on the Dag/task pair, not the task ID alone", () => {
+    // The property the flattened map can get wrong and the old per-Dag map
+    // could not: one bundle serves several Dags, and the same task_id under
+    // two of them is two different handlers.
+    const first = async () => "from etl";
+    const second = async () => "from reporting";
+
+    const bundle = new Bundle(
+      new TaskHandler("etl", "build_message", first),
+      new TaskHandler("reporting", "build_message", second),
+    );
+
+    expect(bundle.getTaskHandler("etl", "build_message")).toBe(first);
+    expect(bundle.getTaskHandler("reporting", "build_message")).toBe(second);
+    expect(bundle.getTaskHandler("unknown", "build_message")).toBeUndefined();
+    expect(bundle.getTaskHandler("etl", "unknown")).toBeUndefined();
+  });
+
+  it("lists every Dag it provides for, in registration order", () => {
+    const nativeDag = new Dag("native_etl");
+    nativeDag.task("extract", async () => undefined);
+
+    const bundle = new Bundle(
+      new TaskHandler("py_etl", "transform", async () => undefined),
+      nativeDag,
+      new TaskHandler("py_etl", "report", async () => undefined),
+    );
+
+    // A Dag registered through handlers keeps its place from the first handler
+    // that named it, so a later one does not reorder the manifest.
+    expect(listBundleDags(bundle)).toEqual([
+      { dagId: "py_etl", tasks: ["transform", "report"] },
+      { dagId: "native_etl", tasks: ["extract"] },
+    ]);
+    expect(listBundleTasks(bundle)).toEqual([
+      { dagId: "py_etl", taskId: "transform" },
+      { dagId: "py_etl", taskId: "report" },
+      { dagId: "native_etl", taskId: "extract" },
+    ]);
+  });
+
+  it("accumulates handlers for one Dag across several calls", () => {
+    const bundle = new Bundle();
+    bundle.register(new TaskHandler("py_etl", "transform", async () => 
undefined));
+    bundle.register(new TaskHandler("py_etl", "report", async () => 
undefined));
+
+    expect(listBundleDags(bundle)).toEqual([{ dagId: "py_etl", tasks: 
["transform", "report"] }]);
+  });
+
+  it("rejects a second handler for the same Dag and task", () => {
+    const bundle = new Bundle(new TaskHandler("etl", "transform", async () => 
undefined));
+    expect(() =>
+      bundle.register(new TaskHandler("etl", "transform", async () => 
undefined)),
+    ).toThrowError(/A handler for Dag "etl" task "transform" is already 
registered/);
+  });
+
+  it("rejects a duplicate Dag and task within a single call", () => {
+    expect(
+      () =>
+        new Bundle(
+          new TaskHandler("etl", "transform", async () => undefined),
+          new TaskHandler("etl", "transform", async () => undefined),
+        ),
+    ).toThrowError(/A handler for Dag "etl" task "transform" is already 
registered/);
+  });
+
+  it("registers none of its items when a call throws", () => {
+    const bundle = new Bundle();
+    expect(() =>
+      bundle.register(
+        new TaskHandler("etl", "transform", async () => undefined),
+        new TaskHandler("etl", "transform", async () => undefined),
+      ),
+    ).toThrowError(/already registered/);
+    expect(listBundleDags(bundle)).toEqual([]);
+    expect(bundle.getTaskHandler("etl", "transform")).toBeUndefined();
+  });
+
+  it("rejects a task handler for a Dag declared in TypeScript", () => {
+    // A native Dag attaches its tasks with dag.task(...), so a handler for the
+    // same Dag ID would be a second, disagreeing source for its task list.
+    const bundle = new Bundle(new Dag("native_etl"));
+    expect(() =>
+      bundle.register(new TaskHandler("native_etl", "transform", async () => 
undefined)),
+    ).toThrowError(/is declared in TypeScript; attach its tasks with 
dag\.task/);
+  });
+
+  it("rejects a task handler for a Dag declared in TypeScript in the same 
call", () => {
+    expect(
+      () =>
+        new Bundle(
+          new Dag("native_etl"),
+          new TaskHandler("native_etl", "transform", async () => undefined),
+        ),
+    ).toThrowError(/is declared in TypeScript; attach its tasks with 
dag\.task/);
+  });
+
+  it("rejects a Dag whose ID already has task handlers", () => {
+    const bundle = new Bundle(new TaskHandler("py_etl", "transform", async () 
=> undefined));
+    expect(() => bundle.register(new Dag("py_etl"))).toThrowError(
+      /already has registered task handlers/,
+    );
+  });
+
+  it("names the duplicate-copy cause for a handler carrying the brand but not 
this class", () => {
+    // Stands in for a handler from a second resolved copy: same brand, other
+    // class. Its private function field is unreadable here, so the point is
+    // only that it says why.
+    const foreign = { dagId: "etl", taskId: "transform" };
+    Object.defineProperty(foreign, Symbol.for("airflow.ts-sdk.TaskHandler"), { 
value: true });
+    expect(() => new Bundle(foreign as unknown as TaskHandler)).toThrowError(
+      /Task handler for Dag "etl" task "transform" comes from a different 
copy/,
+    );
+  });
+});

Reply via email to