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 8f1838cc025 Add airflow-ts-pack bundle build tool for TypeScript SDK 
(#69295)
8f1838cc025 is described below

commit 8f1838cc025132f0f43ad23599b3966a06b18c8a
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Thu Jul 16 16:57:23 2026 +0900

    Add airflow-ts-pack bundle build tool for TypeScript SDK (#69295)
    
    * Add airflow-ts-pack bundle build tool for TypeScript SDK
    
    * Embed TypeScript bundle metadata as leading comment
    
    * Harden airflow-ts-pack manifest reading and packaging
    
    Address review: strip entry shebangs so the metadata line stays first,
    read the manifest via a sentinel line immune to import-time logging,
    validate before writing bundle.mjs, make esbuild an optional peer
    dependency, and reject null dags manifests.
    
    * Validate embedded metadata size and split manifest out of runtime
---
 .../airflow/sdk/coordinators/node/coordinator.py   |  48 +++-
 .../task_sdk/coordinators/node/test_coordinator.py |  86 ++++--
 ts-sdk/README.md                                   |  34 ++-
 ts-sdk/example/README.md                           |  19 +-
 ts-sdk/example/package.json                        |   2 +-
 ts-sdk/package.json                                |  12 +
 ts-sdk/pnpm-lock.yaml                              | 289 ++++++++++++++++++++-
 ts-sdk/src/cli/main.ts                             |  28 ++
 ts-sdk/src/cli/pack.ts                             | 225 ++++++++++++++++
 ts-sdk/src/coordinator/manifest.ts                 |  44 ++++
 ts-sdk/src/coordinator/runtime.ts                  |   9 +
 ts-sdk/tests/cli/fixtures/empty-entry.ts           |  22 ++
 ts-sdk/tests/cli/fixtures/entry.ts                 |  26 ++
 ts-sdk/tests/cli/fixtures/noisy-entry.ts           |  27 ++
 ts-sdk/tests/cli/pack.test.ts                      | 190 ++++++++++++++
 ts-sdk/tests/coordinator/runtime-manifest.test.ts  |  61 +++++
 16 files changed, 1070 insertions(+), 52 deletions(-)

diff --git a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py 
b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
index 41b07710f8f..0d0d6b9151e 100644
--- a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
@@ -19,6 +19,7 @@
 
 from __future__ import annotations
 
+import base64
 import os
 import pathlib
 from typing import TYPE_CHECKING, Any
@@ -45,6 +46,38 @@ log: FilteringBoundLogger = 
structlog.get_logger(logger_name="coordinators.node"
 
 BUNDLE_FILENAME = "bundle.mjs"
 METADATA_FILENAME = "airflow-metadata.yaml"
+EMBEDDED_METADATA_MARKER = b"//# airflowMetadata="
+EMBEDDED_METADATA_MAX_BYTES = 1024 * 1024
+
+
+def _read_embedded_metadata(bundle_path: pathlib.Path) -> dict[str, Any] | 
None:
+    """
+    Read the manifest ``airflow-ts-pack`` embeds in the bundle itself.
+
+    The packer prepends the ``airflow-metadata.yaml`` content as a leading
+    ``//# airflowMetadata=<base64>`` line comment, keeping bundle and metadata
+    a single artifact. Returns ``None`` when the bundle has no such marker.
+    """
+    try:
+        with bundle_path.open("rb") as bundle_file:
+            line = bundle_file.readline(EMBEDDED_METADATA_MAX_BYTES + 1)
+    except OSError as exc:
+        raise ValueError(f"cannot read {bundle_path.name}: {exc}") from exc
+
+    if not line.startswith(EMBEDDED_METADATA_MARKER):
+        return None
+    if len(line) > EMBEDDED_METADATA_MAX_BYTES:
+        raise ValueError(
+            f"embedded airflow metadata exceeds {EMBEDDED_METADATA_MAX_BYTES} 
bytes; "
+            f"rebuild {bundle_path.name} with airflow-ts-pack"
+        )
+
+    payload = line[len(EMBEDDED_METADATA_MARKER) :].strip()
+    try:
+        decoded = base64.b64decode(payload, validate=True)
+    except ValueError as exc:
+        raise ValueError(f"cannot parse embedded airflow metadata: {exc}") 
from exc
+    return parse_metadata_mapping(decoded, source="embedded airflow metadata")
 
 
 def _read_bundle_metadata(metadata_path: pathlib.Path) -> dict[str, Any]:
@@ -63,8 +96,9 @@ def _find_bundle(bundles_root: Sequence[pathlib.Path]) -> 
ResolvedBundle:
     """
     Locate the ``.mjs`` entry point in *bundles_root*.
 
-    Scans each configured directory for ``bundle.mjs`` and reads the sibling
-    ``airflow-metadata.yaml`` for the bundle's supervisor schema version.
+    Scans each configured directory for ``bundle.mjs`` and reads the bundle's
+    supervisor schema version from the metadata embedded in the bundle,
+    falling back to a sibling ``airflow-metadata.yaml`` sidecar.
 
     This is an ordered fallback search, not Dag/task-aware multi-bundle
     routing. The first bundle found wins. A future version can use the
@@ -77,7 +111,9 @@ def _find_bundle(bundles_root: Sequence[pathlib.Path]) -> 
ResolvedBundle:
         if not candidate.is_file():
             continue
         try:
-            metadata = _read_bundle_metadata(root / METADATA_FILENAME)
+            metadata = _read_embedded_metadata(candidate)
+            if metadata is None:
+                metadata = _read_bundle_metadata(root / METADATA_FILENAME)
             log.debug("Selected TypeScript bundle", path=candidate, root=root)
             return ResolvedBundle(
                 path=candidate,
@@ -123,8 +159,10 @@ class NodeCoordinator(SubprocessCoordinator):
         ``"node"``, which relies on ``$PATH``).
     :param bundles_root: Ordered list of directories scanned for a usable
         TypeScript bundle. Each bundle directory must contain ``bundle.mjs``
-        and ``airflow-metadata.yaml``. This is a fallback search path; it does
-        not yet route different Dag/task pairs to different bundles.
+        with embedded metadata (as produced by ``airflow-ts-pack``), or
+        ``bundle.mjs`` plus an ``airflow-metadata.yaml`` sidecar. This is a
+        fallback search path; it does not yet route different Dag/task pairs
+        to different bundles.
     :param task_startup_timeout: Maximum time the coordinator waits for a task
         process to start, in seconds. The default is 10 seconds.
     """
diff --git a/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py 
b/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
index 3538c389a19..5222723953d 100644
--- a/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
+++ b/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
@@ -18,6 +18,7 @@
 
 from __future__ import annotations
 
+import base64
 import pathlib
 
 import pytest
@@ -25,6 +26,7 @@ from uuid6 import uuid7
 
 from airflow.sdk.api.datamodels._generated import TaskInstance
 from airflow.sdk.coordinators.node.coordinator import (
+    EMBEDDED_METADATA_MAX_BYTES,
     NodeCoordinator,
     _find_bundle,
 )
@@ -45,27 +47,33 @@ def _make_ti(dag_id: str = "test_dag", queue: str = "ts") 
-> TaskInstance:
     )
 
 
+def _metadata_yaml(schema_version: str) -> str:
+    return f"""\
+airflow_bundle_metadata_version: "1.0"
+sdk:
+  language: typescript
+  version: "0.1.0"
+  supervisor_schema_version: "{schema_version}"
+source: src/airflow.ts
+dags:
+  test_dag:
+    tasks:
+      - test_task
+"""
+
+
 def write_bundle(root: pathlib.Path, schema_version: str = SCHEMA_VERSION) -> 
pathlib.Path:
     bundle = root / "bundle.mjs"
     bundle.write_text("export {};\n", encoding="utf-8")
-    (root / "airflow-metadata.yaml").write_text(
-        "\n".join(
-            [
-                'airflow_bundle_metadata_version: "1.0"',
-                "sdk:",
-                "  language: typescript",
-                '  version: "0.1.0"',
-                f'  supervisor_schema_version: "{schema_version}"',
-                "source: src/airflow.ts",
-                "dags:",
-                "  test_dag:",
-                "    tasks:",
-                "      - test_task",
-                "",
-            ]
-        ),
-        encoding="utf-8",
-    )
+    (root / 
"airflow-metadata.yaml").write_text(_metadata_yaml(schema_version), 
encoding="utf-8")
+    return bundle
+
+
+def write_embedded_bundle(root: pathlib.Path, payload: str | None = None) -> 
pathlib.Path:
+    if payload is None:
+        payload = 
base64.b64encode(_metadata_yaml(SCHEMA_VERSION).encode("utf-8")).decode("ascii")
+    bundle = root / "bundle.mjs"
+    bundle.write_text(f"//# airflowMetadata={payload}\nexport {{}};\n", 
encoding="utf-8")
     return bundle
 
 
@@ -105,6 +113,48 @@ class TestNodeCoordinatorBundleSelection:
         assert found.path == bundle
         assert found.schema_version == SCHEMA_VERSION
 
+    def test_find_bundle_reads_embedded_metadata_without_sidecar(self, 
tmp_path):
+        bundle = write_embedded_bundle(tmp_path)
+
+        found = _find_bundle([tmp_path])
+
+        assert found.path == bundle
+        assert found.schema_version == SCHEMA_VERSION
+
+    def test_find_bundle_prefers_embedded_metadata_over_sidecar(self, 
tmp_path):
+        bundle = write_embedded_bundle(tmp_path)
+        (tmp_path / "airflow-metadata.yaml").write_text("[not-a-mapping]\n", 
encoding="utf-8")
+
+        found = _find_bundle([tmp_path])
+
+        assert found.path == bundle
+        assert found.schema_version == SCHEMA_VERSION
+
+    @pytest.mark.parametrize(
+        ("payload", "message"),
+        [
+            ("not-base64!", "cannot parse embedded airflow metadata"),
+            (base64.b64encode(b"[not-a-mapping]").decode("ascii"), "must 
contain a mapping"),
+        ],
+    )
+    def test_find_bundle_rejects_invalid_embedded_metadata(self, tmp_path, 
payload, message):
+        write_embedded_bundle(tmp_path, payload=payload)
+
+        with pytest.raises(FileNotFoundError, match=message):
+            _find_bundle([tmp_path])
+
+    def test_find_bundle_rejects_oversized_embedded_metadata(self, tmp_path):
+        write_embedded_bundle(tmp_path, payload="A" * 
EMBEDDED_METADATA_MAX_BYTES)
+
+        with pytest.raises(FileNotFoundError, match="embedded airflow metadata 
exceeds"):
+            _find_bundle([tmp_path])
+
+    def test_find_bundle_rejects_empty_marker(self, tmp_path):
+        (tmp_path / "bundle.mjs").write_text("//# airflowMetadata=\nexport 
{};\n", encoding="utf-8")
+
+        with pytest.raises(FileNotFoundError, match="must contain a mapping"):
+            _find_bundle([tmp_path])
+
     def test_find_bundle_checks_multiple_roots(self, tmp_path):
         first = tmp_path / "first"
         second = tmp_path / "second"
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 3ac7360aca6..15d17efd937 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -90,8 +90,10 @@ coordinators = {
 queue_to_coordinator = {"typescript": "ts"}
 ```
 
-Each configured bundle directory must contain `bundle.mjs` and
-`airflow-metadata.yaml`.
+Each configured bundle directory must contain a `bundle.mjs` built with
+`airflow-ts-pack` (see [Packing bundles](#packing-bundles)), which embeds the
+Airflow metadata in the bundle itself. A `bundle.mjs` without embedded
+metadata is also accepted alongside an `airflow-metadata.yaml` sidecar.
 
 TypeScript entrypoint:
 
@@ -147,8 +149,32 @@ Airflow launches the bundled entrypoint with 
`--comm=host:port` and
 the task startup message, finds the registered handler for the Dag/task pair,
 and reports the terminal task state back to Airflow.
 
-See [`example/`](example/) for a coordinator-runtime example that builds a
-`bundle.mjs` with `esbuild` and uses a Python stub Dag.
+See [`example/`](example/) for a coordinator-runtime example that packs a
+bundle with `airflow-ts-pack` and uses a Python stub Dag.
+
+## Packing bundles
+
+`airflow-ts-pack` produces everything `NodeCoordinator` needs in one command.
+Packing is build-time only, so `esbuild` is an optional peer dependency the
+runtime install skips:
+
+```bash
+npm install --save-dev esbuild
+airflow-ts-pack src/main.ts --outdir dist
+```
+
+It bundles the entrypoint into `dist/bundle.mjs` with esbuild, runs the
+bundle with `--airflow-metadata` so the bundle reports its own registered
+Dag/task pairs and supervisor schema version, and embeds that manifest in the
+bundle as a leading `//# airflowMetadata=<base64>` comment. The result is a
+single deployable file whose metadata cannot drift from its code; no
+hand-written sidecar is needed.
+
+Options:
+
+- `--outdir <dir>` — output directory (default `dist`)
+- `--source <name>` — display name of the primary source file shown in the
+  Airflow UI (default: entry basename)
 
 ## TaskClient
 
diff --git a/ts-sdk/example/README.md b/ts-sdk/example/README.md
index 181135a5231..1ea267bdedf 100644
--- a/ts-sdk/example/README.md
+++ b/ts-sdk/example/README.md
@@ -26,8 +26,9 @@ This example shows the coordinator-mode shape for TypeScript 
task handlers:
   starts the coordinator runtime.
 - `dist/bundle.mjs` is the generated Node.js bundle that Airflow launches.
 
-The TypeScript SDK does not include a packer yet, so this example builds the
-bundle with `esbuild` and writes the Airflow metadata file manually.
+The build uses the SDK's `airflow-ts-pack` tool, which bundles the entrypoint
+with esbuild and embeds the Airflow metadata generated from the bundle's
+registered tasks, producing a single deployable file.
 
 ## Build
 
@@ -39,7 +40,7 @@ pnpm install
 pnpm run build
 ```
 
-Build the example bundle:
+Build the example bundle and its metadata:
 
 ```bash
 cd ts-sdk/example
@@ -47,23 +48,11 @@ pnpm install
 pnpm run build
 ```
 
-Create the metadata file next to the generated bundle:
-
-```bash
-node --input-type=module > dist/airflow-metadata.yaml <<'EOF'
-import { SUPERVISOR_API_VERSION } from "@apache-airflow/ts-sdk";
-
-console.log(`sdk:
-  supervisor_schema_version: "${SUPERVISOR_API_VERSION}"`);
-EOF
-```
-
 The coordinator expects this layout:
 
 ```text
 ts-sdk/example/dist/
   bundle.mjs
-  airflow-metadata.yaml
 ```
 
 ## Airflow Configuration
diff --git a/ts-sdk/example/package.json b/ts-sdk/example/package.json
index 5594727113c..618747ca523 100644
--- a/ts-sdk/example/package.json
+++ b/ts-sdk/example/package.json
@@ -5,7 +5,7 @@
   "type": "module",
   "license": "Apache-2.0",
   "scripts": {
-    "build": "esbuild src/main.ts --bundle --platform=node --format=esm 
--target=node22 --outfile=dist/bundle.mjs",
+    "build": "airflow-ts-pack src/main.ts --outdir dist",
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
diff --git a/ts-sdk/package.json b/ts-sdk/package.json
index 190df171014..0599e165524 100644
--- a/ts-sdk/package.json
+++ b/ts-sdk/package.json
@@ -7,6 +7,9 @@
   "type": "module",
   "main": "./dist/index.js",
   "types": "./dist/index.d.ts",
+  "bin": {
+    "airflow-ts-pack": "./dist/cli/main.js"
+  },
   "homepage": "https://airflow.apache.org";,
   "repository": {
     "type": "git",
@@ -59,8 +62,17 @@
   "dependencies": {
     "@msgpack/msgpack": "^3.1.2"
   },
+  "peerDependencies": {
+    "esbuild": "^0.28.1"
+  },
+  "peerDependenciesMeta": {
+    "esbuild": {
+      "optional": true
+    }
+  },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
+    "esbuild": "^0.28.1",
     "@types/node": "^22.19.17",
     "eslint": "^10.4.0",
     "json-schema-to-typescript": "^15.0.4",
diff --git a/ts-sdk/pnpm-lock.yaml b/ts-sdk/pnpm-lock.yaml
index 887acdcc7e4..1689afb0eba 100644
--- a/ts-sdk/pnpm-lock.yaml
+++ b/ts-sdk/pnpm-lock.yaml
@@ -18,6 +18,9 @@ importers:
       '@types/node':
         specifier: ^22.19.17
         version: 22.19.17
+      esbuild:
+        specifier: ^0.28.1
+        version: 0.28.1
       eslint:
         specifier: ^10.4.0
         version: 10.4.0
@@ -38,7 +41,7 @@ importers:
         version: 8.60.0([email protected])([email protected])
       vitest:
         specifier: ^4.1.7
-        version: 
4.1.7(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
+        version: 
4.1.7(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
 
 packages:
 
@@ -82,156 +85,312 @@ packages:
     cpu: [ppc64]
     os: [aix]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [android]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
     engines: {node: '>=18'}
     cpu: [arm]
     os: [android]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [android]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [darwin]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [darwin]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [freebsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [freebsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
     engines: {node: '>=18'}
     cpu: [arm]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
     engines: {node: '>=18'}
     cpu: [ia32]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
     engines: {node: '>=18'}
     cpu: [loong64]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
     engines: {node: '>=18'}
     cpu: [mips64el]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
     engines: {node: '>=18'}
     cpu: [ppc64]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
     engines: {node: '>=18'}
     cpu: [riscv64]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
     engines: {node: '>=18'}
     cpu: [s390x]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [linux]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [netbsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [netbsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [openbsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [openbsd]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [openharmony]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [sunos]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
     engines: {node: '>=18'}
     cpu: [arm64]
     os: [win32]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
     engines: {node: '>=18'}
     cpu: [ia32]
     os: [win32]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
   '@esbuild/[email protected]':
     resolution: {integrity: 
sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
     engines: {node: '>=18'}
     cpu: [x64]
     os: [win32]
 
+  '@esbuild/[email protected]':
+    resolution: {integrity: 
sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
   '@eslint-community/[email protected]':
     resolution: {integrity: 
sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -599,6 +758,11 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: 
sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   [email protected]:
     resolution: {integrity: 
sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
@@ -1160,81 +1324,159 @@ snapshots:
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@esbuild/[email protected]':
     optional: true
 
+  '@esbuild/[email protected]':
+    optional: true
+
   '@eslint-community/[email protected]([email protected])':
     dependencies:
       eslint: 10.4.0
@@ -1489,7 +1731,7 @@ snapshots:
       obug: 2.1.1
       std-env: 4.1.0
       tinyrainbow: 3.1.0
-      vitest: 
4.1.7(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
+      vitest: 
4.1.7(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
     optional: true
 
   '@vitest/[email protected]':
@@ -1501,13 +1743,13 @@ snapshots:
       chai: 6.2.2
       tinyrainbow: 3.1.0
 
-  
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))':
+  
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))':
     dependencies:
       '@vitest/spy': 4.1.7
       estree-walker: 3.0.3
       magic-string: 0.30.21
     optionalDependencies:
-      vite: 8.0.10(@types/[email protected])([email protected])([email protected])
+      vite: 8.0.10(@types/[email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:
@@ -1612,6 +1854,35 @@ snapshots:
       '@esbuild/win32-ia32': 0.27.7
       '@esbuild/win32-x64': 0.27.7
 
+  [email protected]:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.28.1
+      '@esbuild/android-arm': 0.28.1
+      '@esbuild/android-arm64': 0.28.1
+      '@esbuild/android-x64': 0.28.1
+      '@esbuild/darwin-arm64': 0.28.1
+      '@esbuild/darwin-x64': 0.28.1
+      '@esbuild/freebsd-arm64': 0.28.1
+      '@esbuild/freebsd-x64': 0.28.1
+      '@esbuild/linux-arm': 0.28.1
+      '@esbuild/linux-arm64': 0.28.1
+      '@esbuild/linux-ia32': 0.28.1
+      '@esbuild/linux-loong64': 0.28.1
+      '@esbuild/linux-mips64el': 0.28.1
+      '@esbuild/linux-ppc64': 0.28.1
+      '@esbuild/linux-riscv64': 0.28.1
+      '@esbuild/linux-s390x': 0.28.1
+      '@esbuild/linux-x64': 0.28.1
+      '@esbuild/netbsd-arm64': 0.28.1
+      '@esbuild/netbsd-x64': 0.28.1
+      '@esbuild/openbsd-arm64': 0.28.1
+      '@esbuild/openbsd-x64': 0.28.1
+      '@esbuild/openharmony-arm64': 0.28.1
+      '@esbuild/sunos-x64': 0.28.1
+      '@esbuild/win32-arm64': 0.28.1
+      '@esbuild/win32-ia32': 0.28.1
+      '@esbuild/win32-x64': 0.28.1
+
   [email protected]: {}
 
   [email protected]:
@@ -2007,7 +2278,7 @@ snapshots:
     dependencies:
       punycode: 2.3.1
 
-  [email protected](@types/[email protected])([email protected])([email protected]):
+  [email protected](@types/[email protected])([email protected])([email protected]):
     dependencies:
       lightningcss: 1.32.0
       picomatch: 4.0.4
@@ -2016,14 +2287,14 @@ snapshots:
       tinyglobby: 0.2.16
     optionalDependencies:
       '@types/node': 22.19.17
-      esbuild: 0.27.7
+      esbuild: 0.28.1
       fsevents: 2.3.3
       tsx: 4.21.0
 
-  
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])):
+  
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])):
     dependencies:
       '@vitest/expect': 4.1.7
-      '@vitest/mocker': 
4.1.7([email protected](@types/[email protected])([email protected])([email protected]))
+      '@vitest/mocker': 
4.1.7([email protected](@types/[email protected])([email protected])([email protected]))
       '@vitest/pretty-format': 4.1.7
       '@vitest/runner': 4.1.7
       '@vitest/snapshot': 4.1.7
@@ -2040,7 +2311,7 @@ snapshots:
       tinyexec: 1.1.1
       tinyglobby: 0.2.16
       tinyrainbow: 3.1.0
-      vite: 8.0.10(@types/[email protected])([email protected])([email protected])
+      vite: 8.0.10(@types/[email protected])([email protected])([email protected])
       why-is-node-running: 2.3.0
     optionalDependencies:
       '@types/node': 22.19.17
diff --git a/ts-sdk/src/cli/main.ts b/ts-sdk/src/cli/main.ts
new file mode 100644
index 00000000000..1db958183fe
--- /dev/null
+++ b/ts-sdk/src/cli/main.ts
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/*!
+ * 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 { runPack } from "./pack.js";
+
+try {
+  await runPack(process.argv.slice(2));
+} catch (error) {
+  console.error(error instanceof Error ? error.message : error);
+  process.exit(1);
+}
diff --git a/ts-sdk/src/cli/pack.ts b/ts-sdk/src/cli/pack.ts
new file mode 100644
index 00000000000..0d863fbe898
--- /dev/null
+++ b/ts-sdk/src/cli/pack.ts
@@ -0,0 +1,225 @@
+/*!
+ * 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.
+ */
+
+// airflow-ts-pack: bundle a TypeScript entrypoint into the single-file
+// artifact NodeCoordinator consumes — `bundle.mjs` with the airflow
+// metadata embedded as a leading `//# airflowMetadata=<base64>` comment.
+//
+// Mirrors airflow-go-pack: build first, then run the built bundle with
+// --airflow-metadata so the manifest comes from the bundle's own task
+// registry and schema version, never from a hand-written sidecar.
+
+import { execFileSync } from "node:child_process";
+import { readFileSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+import {
+  AIRFLOW_METADATA_FLAG,
+  AIRFLOW_METADATA_SENTINEL,
+  type BundleManifest,
+} from "../coordinator/manifest.js";
+
+const AIRFLOW_BUNDLE_METADATA_VERSION = "1.0";
+const BUNDLE_FILENAME = "bundle.mjs";
+// bundle.mjs is written only after validation, so failures leave no partial 
artifact.
+const STAGING_FILENAME = "bundle.pack-staging.mjs";
+const MANIFEST_TIMEOUT_MS = 60_000;
+const MANIFEST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
+const EMBEDDED_METADATA_MAX_BYTES = 1024 * 1024;
+export const EMBEDDED_METADATA_PREFIX = "//# airflowMetadata=";
+
+const USAGE = `Usage: airflow-ts-pack <entry> [--outdir <dir>] [--source 
<name>]
+
+Bundles <entry> into <outdir>/${BUNDLE_FILENAME} with esbuild and embeds the
+airflow metadata generated from the bundle's registered tasks.
+
+Options:
+  --outdir <dir>   Output directory (default: dist)
+  --source <name>  Display name of the primary source file (default: <entry> 
basename)
+`;
+
+export interface PackArgs {
+  entry: string;
+  outdir: string;
+  source: string;
+}
+
+function usageError(message: string): Error {
+  return new Error(`${message}\n\n${USAGE}`);
+}
+
+export function parsePackArgs(argv: readonly string[]): PackArgs {
+  let entry: string | null = null;
+  let outdir = "dist";
+  let source: string | null = null;
+  for (let i = 0; i < argv.length; i += 1) {
+    const arg = argv[i]!;
+    if (arg === "--outdir" || arg === "--source") {
+      const value = argv[i + 1];
+      if (!value) throw usageError(`${arg} requires a value`);
+      if (arg === "--outdir") outdir = value;
+      else source = value;
+      i += 1;
+    } else if (arg.startsWith("-")) {
+      throw usageError(`Unknown option ${arg}`);
+    } else if (entry) {
+      throw usageError(`Unexpected argument ${arg}`);
+    } else {
+      entry = arg;
+    }
+  }
+  if (!entry) throw usageError("Missing entry file");
+  return { entry, outdir, source: source ?? path.basename(entry) };
+}
+
+export interface PackMetadata {
+  airflow_bundle_metadata_version: string;
+  sdk: { language: string; version: string; supervisor_schema_version: string 
};
+  source: string;
+  dags: BundleManifest["dags"];
+}
+
+// JSON string literals are valid YAML double-quoted scalars, so every
+// scalar below is emitted through JSON.stringify for correct escaping.
+export function renderMetadataYaml(metadata: PackMetadata): string {
+  const lines = [
+    `airflow_bundle_metadata_version: 
${JSON.stringify(metadata.airflow_bundle_metadata_version)}`,
+    "sdk:",
+    `  language: ${JSON.stringify(metadata.sdk.language)}`,
+    `  version: ${JSON.stringify(metadata.sdk.version)}`,
+    `  supervisor_schema_version: 
${JSON.stringify(metadata.sdk.supervisor_schema_version)}`,
+    `source: ${JSON.stringify(metadata.source)}`,
+    "dags:",
+  ];
+  for (const [dagId, dag] of Object.entries(metadata.dags)) {
+    lines.push(`  ${JSON.stringify(dagId)}:`);
+    lines.push(`    tasks: [${dag.tasks.map((task) => 
JSON.stringify(task)).join(", ")}]`);
+  }
+  return `${lines.join("\n")}\n`;
+}
+
+function readSdkVersion(): string {
+  const packageJsonUrl = new URL("../../package.json", import.meta.url);
+  const { version } = JSON.parse(readFileSync(packageJsonUrl, "utf-8")) as { 
version: string };
+  return version;
+}
+
+function readBundleManifest(bundlePath: string): BundleManifest {
+  let stdout: string;
+  try {
+    stdout = execFileSync(process.execPath, [bundlePath, 
AIRFLOW_METADATA_FLAG], {
+      encoding: "utf-8",
+      timeout: MANIFEST_TIMEOUT_MS,
+      maxBuffer: MANIFEST_MAX_BUFFER_BYTES,
+    });
+  } catch (error) {
+    throw new Error(`Running the bundle with ${AIRFLOW_METADATA_FLAG} failed: 
${String(error)}`, {
+      cause: error,
+    });
+  }
+
+  // Import-time logging from user code lands on stdout too; pick the sentinel 
line.
+  const line = stdout
+    .split("\n")
+    .reverse()
+    .find((candidate) => candidate.startsWith(AIRFLOW_METADATA_SENTINEL));
+  if (line === undefined) {
+    throw new Error(`Bundle produced no ${AIRFLOW_METADATA_FLAG} output`);
+  }
+
+  let manifest: BundleManifest;
+  try {
+    manifest = JSON.parse(line.slice(AIRFLOW_METADATA_SENTINEL.length)) as 
BundleManifest;
+  } catch (error) {
+    throw new Error(`Bundle produced invalid ${AIRFLOW_METADATA_FLAG} output: 
${String(error)}`, {
+      cause: error,
+    });
+  }
+  if (!manifest.supervisor_schema_version || !manifest.dags || typeof 
manifest.dags !== "object") {
+    throw new Error(`Bundle produced incomplete ${AIRFLOW_METADATA_FLAG} 
output`);
+  }
+  return manifest;
+}
+
+// esbuild keeps an entry hashbang as line 1, where the metadata comment must 
go;
+// NodeCoordinator always runs the bundle through `node`, so drop it.
+function stripShebang(bundle: string): string {
+  if (!bundle.startsWith("#!")) return bundle;
+  const newline = bundle.indexOf("\n");
+  return newline === -1 ? "" : bundle.slice(newline + 1);
+}
+
+async function loadEsbuild(): Promise<typeof import("esbuild")> {
+  try {
+    return await import("esbuild");
+  } catch (error) {
+    throw new Error(
+      "airflow-ts-pack needs esbuild; install it alongside the SDK (e.g. `npm 
i -D esbuild`)",
+      { cause: error },
+    );
+  }
+}
+
+export async function runPack(argv: readonly string[]): Promise<void> {
+  const args = parsePackArgs(argv);
+  const bundlePath = path.join(args.outdir, BUNDLE_FILENAME);
+  const stagingPath = path.join(args.outdir, STAGING_FILENAME);
+  const { build } = await loadEsbuild();
+
+  try {
+    await build({
+      entryPoints: [args.entry],
+      bundle: true,
+      platform: "node",
+      format: "esm",
+      target: "node22",
+      outfile: stagingPath,
+    });
+
+    const manifest = readBundleManifest(stagingPath);
+    if (Object.keys(manifest.dags).length === 0) {
+      throw new Error(
+        `${args.entry} registered no tasks; call registerTask(...) before 
startCoordinator()`,
+      );
+    }
+
+    const metadataYaml = renderMetadataYaml({
+      airflow_bundle_metadata_version: AIRFLOW_BUNDLE_METADATA_VERSION,
+      sdk: {
+        language: "typescript",
+        version: readSdkVersion(),
+        supervisor_schema_version: manifest.supervisor_schema_version,
+      },
+      source: args.source,
+      dags: manifest.dags,
+    });
+    const metadataLine = 
`${EMBEDDED_METADATA_PREFIX}${Buffer.from(metadataYaml, 
"utf-8").toString("base64")}\n`;
+    if (metadataLine.length > EMBEDDED_METADATA_MAX_BYTES) {
+      throw new Error(
+        `Embedded airflow metadata is ${metadataLine.length} bytes, ` +
+          `over the ${EMBEDDED_METADATA_MAX_BYTES} byte limit; reduce the 
number of registered tasks`,
+      );
+    }
+    writeFileSync(bundlePath, metadataLine + 
stripShebang(readFileSync(stagingPath, "utf-8")));
+  } finally {
+    rmSync(stagingPath, { force: true });
+  }
+
+  console.log(`Wrote ${bundlePath} (airflow metadata embedded)`);
+}
diff --git a/ts-sdk/src/coordinator/manifest.ts 
b/ts-sdk/src/coordinator/manifest.ts
new file mode 100644
index 00000000000..32e503dce7a
--- /dev/null
+++ b/ts-sdk/src/coordinator/manifest.ts
@@ -0,0 +1,44 @@
+/*!
+ * 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 { SUPERVISOR_API_VERSION } from "./protocol.js";
+import { listRegisteredTasks, type TaskRegistration } from 
"../sdk/registry.js";
+
+export const AIRFLOW_METADATA_FLAG = "--airflow-metadata";
+
+/** Marks the manifest line on stdout, which import-time logging may also 
reach. */
+export const AIRFLOW_METADATA_SENTINEL = "__AIRFLOW_METADATA__ ";
+
+/** Bundle manifest fields only the built bundle itself knows: the schema
+ *  version it was compiled against and the Dag/task pairs it registered.
+ *  `airflow-ts-pack` runs `node bundle.mjs --airflow-metadata` to read this. 
*/
+export interface BundleManifest {
+  supervisor_schema_version: string;
+  dags: Record<string, { tasks: string[] }>;
+}
+
+export function buildBundleManifest(
+  registrations: readonly TaskRegistration[] = listRegisteredTasks(),
+): BundleManifest {
+  const dags: BundleManifest["dags"] = {};
+  for (const { dagId, taskId } of registrations) {
+    (dags[dagId] ??= { tasks: [] }).tasks.push(taskId);
+  }
+  return { supervisor_schema_version: SUPERVISOR_API_VERSION, dags };
+}
diff --git a/ts-sdk/src/coordinator/runtime.ts 
b/ts-sdk/src/coordinator/runtime.ts
index d083bf4acce..5626aae3106 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -37,6 +37,11 @@
 import { createCoordinatorClient } from "./client.js";
 import { CommChannel } from "./comm-channel.js";
 import { LogChannel } from "./log-channel.js";
+import {
+  AIRFLOW_METADATA_FLAG,
+  AIRFLOW_METADATA_SENTINEL,
+  buildBundleManifest,
+} from "./manifest.js";
 import {
   asMsgFromSupervisor,
   SUPERVISOR_API_VERSION,
@@ -107,6 +112,10 @@ export function parseArgs(argv: readonly string[]): 
ParsedArgs {
  *  delivered its terminal frame and closed both sockets. */
 export async function startCoordinator(opts: StartCoordinatorOptions = {}): 
Promise<void> {
   const argv = opts.argv ?? process.argv;
+  if (argv.includes(AIRFLOW_METADATA_FLAG)) {
+    
process.stdout.write(`${AIRFLOW_METADATA_SENTINEL}${JSON.stringify(buildBundleManifest())}\n`);
+    return;
+  }
   const parsed =
     opts.commAddr && opts.logsAddr
       ? { commAddr: opts.commAddr, logsAddr: opts.logsAddr }
diff --git a/ts-sdk/tests/cli/fixtures/empty-entry.ts 
b/ts-sdk/tests/cli/fixtures/empty-entry.ts
new file mode 100644
index 00000000000..3e2b582b224
--- /dev/null
+++ b/ts-sdk/tests/cli/fixtures/empty-entry.ts
@@ -0,0 +1,22 @@
+/*!
+ * 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 { startCoordinator } from "../../../src/index.js";
+
+await startCoordinator();
diff --git a/ts-sdk/tests/cli/fixtures/entry.ts 
b/ts-sdk/tests/cli/fixtures/entry.ts
new file mode 100644
index 00000000000..8d53c43aee5
--- /dev/null
+++ b/ts-sdk/tests/cli/fixtures/entry.ts
@@ -0,0 +1,26 @@
+/*!
+ * 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 { registerTask, startCoordinator } from "../../../src/index.js";
+
+registerTask({ dagId: "fixture_dag", taskId: "extract" }, async () => 
"extracted");
+registerTask({ dagId: "fixture_dag", taskId: "transform" }, async () => 
"transformed");
+registerTask({ dagId: "other_dag", taskId: "solo" }, async () => undefined);
+
+await startCoordinator();
diff --git a/ts-sdk/tests/cli/fixtures/noisy-entry.ts 
b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
new file mode 100644
index 00000000000..3b9675619d3
--- /dev/null
+++ b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+/*!
+ * 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 { registerTask, startCoordinator } from "../../../src/index.js";
+
+console.log("noise from an import-time dependency");
+
+registerTask({ dagId: "noisy_dag", taskId: "only" }, async () => undefined);
+
+await startCoordinator();
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
new file mode 100644
index 00000000000..4a5c2d572bf
--- /dev/null
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -0,0 +1,190 @@
+/*!
+ * 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 { execFileSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 
"node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+  EMBEDDED_METADATA_PREFIX,
+  parsePackArgs,
+  renderMetadataYaml,
+  runPack,
+} from "../../src/cli/pack.js";
+import { SUPERVISOR_API_VERSION } from "../../src/coordinator/protocol.js";
+import { AIRFLOW_METADATA_SENTINEL } from "../../src/coordinator/manifest.js";
+
+const FIXTURE_ENTRY = fileURLToPath(new URL("fixtures/entry.ts", 
import.meta.url));
+const NOISY_ENTRY = fileURLToPath(new URL("fixtures/noisy-entry.ts", 
import.meta.url));
+const EMPTY_ENTRY = fileURLToPath(new URL("fixtures/empty-entry.ts", 
import.meta.url));
+const SDK_INDEX = fileURLToPath(new URL("../../src/index.ts", 
import.meta.url));
+const SDK_VERSION = (
+  JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), 
"utf-8")) as {
+    version: string;
+  }
+).version;
+
+describe("parsePackArgs", () => {
+  it("parses entry with defaults", () => {
+    expect(parsePackArgs(["src/main.ts"])).toEqual({
+      entry: "src/main.ts",
+      outdir: "dist",
+      source: "main.ts",
+    });
+  });
+
+  it("parses --outdir and --source overrides", () => {
+    expect(parsePackArgs(["src/main.ts", "--outdir", "build", "--source", 
"pipeline.ts"])).toEqual({
+      entry: "src/main.ts",
+      outdir: "build",
+      source: "pipeline.ts",
+    });
+  });
+
+  it.each([
+    [[], "Missing entry file"],
+    [["--outdir"], "--outdir requires a value"],
+    [["a.ts", "b.ts"], "Unexpected argument b.ts"],
+    [["a.ts", "--bogus"], "Unknown option --bogus"],
+  ])("rejects %j", (argv, message) => {
+    expect(() => parsePackArgs(argv)).toThrow(message);
+  });
+});
+
+describe("renderMetadataYaml", () => {
+  it("emits schema-conformant YAML with escaped scalars", () => {
+    const yaml = renderMetadataYaml({
+      airflow_bundle_metadata_version: "1.0",
+      sdk: { language: "typescript", version: "0.1.0", 
supervisor_schema_version: "2026-06-16" },
+      source: 'we"ird.ts',
+      dags: { my_dag: { tasks: ["a", 'b"c'] } },
+    });
+    expect(yaml).toBe(
+      [
+        'airflow_bundle_metadata_version: "1.0"',
+        "sdk:",
+        '  language: "typescript"',
+        '  version: "0.1.0"',
+        '  supervisor_schema_version: "2026-06-16"',
+        'source: "we\\"ird.ts"',
+        "dags:",
+        '  "my_dag":',
+        '    tasks: ["a", "b\\"c"]',
+        "",
+      ].join("\n"),
+    );
+  });
+});
+
+describe("runPack", () => {
+  let outdir: string;
+
+  afterEach(() => {
+    if (outdir) rmSync(outdir, { recursive: true, force: true });
+  });
+
+  it("bundles the entry and embeds metadata from the bundle's registry", async 
() => {
+    outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+    const nested = path.join(outdir, "dist");
+    await runPack([FIXTURE_ENTRY, "--outdir", nested]);
+
+    const bundlePath = path.join(nested, "bundle.mjs");
+    expect(existsSync(path.join(nested, "airflow-metadata.yaml"))).toBe(false);
+
+    const firstLine = readFileSync(bundlePath, "utf-8").split("\n")[0]!;
+    expect(firstLine.startsWith(EMBEDDED_METADATA_PREFIX)).toBe(true);
+    const metadata = Buffer.from(
+      firstLine.slice(EMBEDDED_METADATA_PREFIX.length),
+      "base64",
+    ).toString("utf-8");
+    expect(metadata).toBe(
+      [
+        'airflow_bundle_metadata_version: "1.0"',
+        "sdk:",
+        '  language: "typescript"',
+        `  version: ${JSON.stringify(SDK_VERSION)}`,
+        `  supervisor_schema_version: 
${JSON.stringify(SUPERVISOR_API_VERSION)}`,
+        'source: "entry.ts"',
+        "dags:",
+        '  "fixture_dag":',
+        '    tasks: ["extract", "transform"]',
+        '  "other_dag":',
+        '    tasks: ["solo"]',
+        "",
+      ].join("\n"),
+    );
+
+    const dumped = execFileSync(process.execPath, [bundlePath, 
"--airflow-metadata"], {
+      encoding: "utf-8",
+    });
+    expect(dumped.startsWith(AIRFLOW_METADATA_SENTINEL)).toBe(true);
+    expect(
+      
JSON.parse(dumped.slice(AIRFLOW_METADATA_SENTINEL.length)).supervisor_schema_version,
+    ).toBe(SUPERVISOR_API_VERSION);
+  });
+
+  it("keeps a shebang entry runnable and reads the manifest past import-time 
logging", async () => {
+    outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+    await runPack([NOISY_ENTRY, "--outdir", outdir]);
+
+    const bundlePath = path.join(outdir, "bundle.mjs");
+    const bundle = readFileSync(bundlePath, "utf-8");
+    expect(bundle.startsWith(EMBEDDED_METADATA_PREFIX)).toBe(true);
+    expect(bundle).not.toContain("#!/usr/bin/env node");
+    expect(existsSync(path.join(outdir, 
"bundle.pack-staging.mjs"))).toBe(false);
+
+    const metadata = Buffer.from(
+      bundle.split("\n")[0]!.slice(EMBEDDED_METADATA_PREFIX.length),
+      "base64",
+    ).toString("utf-8");
+    expect(metadata).toContain('  "noisy_dag":');
+
+    execFileSync(process.execPath, [bundlePath, "--airflow-metadata"], { 
encoding: "utf-8" });
+  });
+
+  it("leaves no bundle behind when the metadata exceeds the embedded size 
limit", async () => {
+    outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+    const entry = path.join(outdir, "huge-entry.ts");
+    writeFileSync(
+      entry,
+      [
+        `import { registerTask, startCoordinator } from 
${JSON.stringify(SDK_INDEX)};`,
+        'for (let i = 0; i < 4000; i += 1) registerTask({ dagId: "big_dag", 
taskId: String(i).padStart(240, "t") }, async () => undefined);',
+        "await startCoordinator();",
+      ].join("\n"),
+    );
+
+    await expect(runPack([entry, "--outdir", outdir])).rejects.toThrow(
+      "over the 1048576 byte limit",
+    );
+    expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
+    expect(existsSync(path.join(outdir, 
"bundle.pack-staging.mjs"))).toBe(false);
+  });
+
+  it("leaves no bundle behind when the entry registers no tasks", async () => {
+    outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+
+    await expect(runPack([EMPTY_ENTRY, "--outdir", 
outdir])).rejects.toThrow("registered no tasks");
+    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/runtime-manifest.test.ts 
b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
new file mode 100644
index 00000000000..f6aea6f2fd5
--- /dev/null
+++ b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
@@ -0,0 +1,61 @@
+/*!
+ * 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 { afterEach, describe, expect, it, vi } from "vitest";
+
+import { AIRFLOW_METADATA_SENTINEL, buildBundleManifest } from 
"../../src/coordinator/manifest.js";
+import { startCoordinator } from "../../src/coordinator/runtime.js";
+import { SUPERVISOR_API_VERSION } from "../../src/coordinator/protocol.js";
+
+describe("buildBundleManifest", () => {
+  it("groups registrations by Dag ID under the SDK's schema version", () => {
+    expect(
+      buildBundleManifest([
+        { dagId: "dag_a", taskId: "t1" },
+        { dagId: "dag_b", taskId: "t2" },
+        { dagId: "dag_a", taskId: "t3" },
+      ]),
+    ).toEqual({
+      supervisor_schema_version: SUPERVISOR_API_VERSION,
+      dags: {
+        dag_a: { tasks: ["t1", "t3"] },
+        dag_b: { tasks: ["t2"] },
+      },
+    });
+  });
+});
+
+describe("startCoordinator --airflow-metadata", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("dumps the manifest to stdout and returns without connecting", async () 
=> {
+    const write = vi.spyOn(process.stdout, "write").mockReturnValue(true);
+
+    await startCoordinator({ argv: ["node", "bundle.mjs", 
"--airflow-metadata"] });
+
+    expect(write).toHaveBeenCalledTimes(1);
+    const written = String(write.mock.calls[0]![0]);
+    expect(written.startsWith(AIRFLOW_METADATA_SENTINEL)).toBe(true);
+    const payload = 
JSON.parse(written.slice(AIRFLOW_METADATA_SENTINEL.length));
+    expect(payload.supervisor_schema_version).toBe(SUPERVISOR_API_VERSION);
+    expect(payload.dags).toEqual({});
+  });
+});

Reply via email to