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 5433ffe893c TS SDK: replace TaskHandlerArgs with getContext and
getClient (#73122)
5433ffe893c is described below
commit 5433ffe893c82af33abf07a70c57e0a61e754ef6
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Sep 17 10:03:14 2026 +0800
TS SDK: replace TaskHandlerArgs with getContext and getClient (#73122)
---
.../language-sdks/typescript.rst | 59 ++++---
ts-sdk/README.md | 53 ++++---
ts-sdk/api-docs/dag-authoring-api.ts | 5 +-
ts-sdk/docs/index.md | 17 ++-
ts-sdk/example/src/main.ts | 9 +-
ts-sdk/scripts/verify-package.mjs | 12 +-
ts-sdk/src/coordinator/runtime.ts | 17 ++-
ts-sdk/src/index.ts | 3 +-
ts-sdk/src/sdk/dag.ts | 28 ++--
ts-sdk/src/sdk/registry.ts | 14 +-
ts-sdk/src/sdk/task.ts | 97 ++++++++++--
ts-sdk/tests/coordinator/integration.test.ts | 38 ++---
ts-sdk/tests/public-api.test.ts | 27 +++-
ts-sdk/tests/sdk/dag.test.ts | 2 +-
ts-sdk/tests/sdk/task-scope.test.ts | 170 +++++++++++++++++++++
15 files changed, 421 insertions(+), 130 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 38616832436..a1d1137dd7f 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -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``, ``DagRegistry``,
``serveDags``, task handlers,
+ For the full TypeScript API reference (``Dag``, ``DagRegistry``,
``serveDags``, the task handler getters,
``TaskClient``, supporting types, and exceptions),
see the `TypeScript SDK API reference
<https://airflow.apache.org/docs/ts-sdk/stable/>`__.
@@ -93,16 +93,19 @@ value routes the task to the Node.js coordinator.
TypeScript implementation
~~~~~~~~~~~~~~~~~~~~~~~~~
-A task is an ordinary (usually ``async``) function receiving
``TaskHandlerArgs``. Create a ``Dag`` with
-the ``dag_id`` it implements, attach each handler with ``dag.task``, collect
the Dags in a ``DagRegistry``,
-then serve them to Airflow with ``serveDags``; that top-level ``await`` makes
the module a runnable bundle
-entry point.
+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``,
+collect the Dags in a ``DagRegistry``, then serve them to Airflow with
``serveDags``.
+That top-level ``await`` makes the module a runnable bundle entry point.
.. code-block:: typescript
- import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from
"apache-airflow-ts-sdk";
+ import { Dag, DagRegistry, getClient, serveDags } from
"apache-airflow-ts-sdk";
- export async function buildMessage({ ctx, client }: TaskHandlerArgs) {
+ export async function buildMessage() {
+ const client = getClient();
const upstream = await client.getXCom<string>({
key: "return_value",
taskId: "python_start",
@@ -125,14 +128,14 @@ of the registry is not part of the packed bundle, and its
tasks are marked remov
through ``registry.getTaskHandler(dagId, taskId)`` without a coordinator
runtime. A bundle that collects
its Dags across several modules can add them incrementally with
``registry.register(...)``.
-``new Dag`` and ``dag.task`` take a trailing options object — ``spec`` on
both, plus ``inputs`` on a task.
+``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.
.. note::
As with the other language SDKs, XCom *dependencies* are declared in the
Python stub Dag (they define task
- order). The value must still be read explicitly in TypeScript via
``client.getXCom``, and produced either
- by the task's return value or by ``client.setXCom``.
+ order). The value must still be read explicitly in TypeScript via
``getClient().getXCom``, and produced
+ either by the task's return value or by ``getClient().setXCom``.
Coordinator configuration
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -169,22 +172,30 @@ task instance.
Writing tasks
-------------
-Every task handler receives a single ``TaskHandlerArgs`` object:
+A task handler takes no SDK-supplied argument. Two getters, valid for as long
as the handler runs, supply what it needs:
.. list-table::
:header-rows: 1
- :widths: 15 85
+ :widths: 20 80
- * - Field
+ * - Getter
- Value
- * - ``ctx``
- - The task's execution context: ``dagId``, ``taskId`` (including any
TaskGroup prefix), ``runId``,
- ``tryNumber``, ``mapIndex`` (``-1`` for an unmapped task), and
``signal`` — an ``AbortSignal`` that
- fires when Airflow terminates the task. Pass ``signal`` to ``fetch()``,
timers, or other APIs that
- accept an ``AbortSignal`` for cooperative cancellation.
- * - ``client``
+ * - ``getContext()``
+ - The task's execution context (a ``TaskContext``): ``dagId``, ``taskId``
(including any TaskGroup
+ prefix), ``runId``, ``tryNumber``, ``mapIndex`` (``-1`` for an unmapped
task), and ``signal``, an
+ ``AbortSignal`` that fires when Airflow terminates the task. Pass
``signal`` to ``fetch()``, timers,
+ or other APIs that accept an ``AbortSignal`` for cooperative
cancellation.
+ * - ``getClient()``
- A ``TaskClient`` for Airflow Variables, Connections, and XCom.
+Both read a store the runtime installs around the handler call,
+which follows the handler across every ``await`` and into every promise it
creates,
+so a helper several frames deep reads them without being passed anything. Both
throw outside a handler.
+
+Work that outlives the handler is the one gap.
+A promise the handler never awaits still resolves, but it runs after Airflow
has been told the task's terminal state,
+so await everything a handler starts.
+
A non-``undefined`` return value becomes the task's ``return_value`` XCom,
matching Python ``@task``
behavior. An uncaught exception (or rejected promise) marks the task instance
failed in Airflow, triggering
retries if configured on the stub.
@@ -192,18 +203,18 @@ retries if configured on the stub.
The ``TaskClient`` surface
~~~~~~~~~~~~~~~~~~~~~~~~~~
-* ``getVariable(key)`` — returns the Variable as a string, or ``null`` when it
is missing;
+* ``getVariable(key)`` returns the Variable as a string, or ``null`` when it
is missing;
``getVariableOrThrow(key)`` throws ``VariableNotFoundError`` instead,
matching Python ``Variable.get``
with no default.
-* ``getConnection(connId)`` — returns a ``ConnectionResult`` with fields
``id`` and ``type``, plus the
+* ``getConnection(connId)`` returns a ``ConnectionResult`` with fields ``id``
and ``type``, plus the
optional fields ``host``, ``schema``, ``login``, ``password``, ``port``, and
``extra`` (each may be
missing or ``null``), or ``null`` when the connection does not exist;
``getConnectionOrThrow(connId)`` throws ``ConnectionNotFoundError`` instead,
matching Python
``BaseHook.get_connection``.
-* ``getXCom<T>({key, ...})`` — reads an XCom value, or ``null`` when it is
missing. The locator fields
+* ``getXCom<T>({key, ...})`` reads an XCom value, or ``null`` when it is
missing. The locator fields
(``dagId``, ``runId``, ``taskId``, ``mapIndex``) default to the current
task; pass ``taskId`` to read an
upstream task's XCom. See :ref:`typescript-sdk/types` for how the stored
JSON maps to JavaScript types.
-* ``setXCom({key, value, ...})`` — publishes an XCom value.
+* ``setXCom({key, value, ...})`` publishes an XCom value.
Logging
-------
@@ -262,7 +273,7 @@ Building and packaging
``airflow-ts-pack`` (shipped with the SDK) bundles the entry module and all of
its imports with esbuild into
a single self-contained ESM file, ``bundle.mjs``, and embeds the manifest (the
``dag_id`` and ``task_id``
map plus the supervisor schema version) after a leading compact JSON ``//#
airflowBundle=...`` layout header.
-The layout records the byte ranges and SHA-256 digests of the manifest and
executable code — one file to
+The layout records the byte ranges and SHA-256 digests of the manifest and
executable code, so one file to
deploy, with no separate manifest or ``node_modules``.
``esbuild`` is an optional peer dependency: packing is build-time only, so the
runtime install of
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 33b25fea65d..2c5b9345748 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -35,11 +35,11 @@ npm install [email protected]
## Task Handlers
```ts
-import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from
"apache-airflow-ts-sdk";
+import { Dag, DagRegistry, getClient, getContext, serveDags } from
"apache-airflow-ts-sdk";
-export async function sayHello({ ctx, client }: TaskHandlerArgs) {
- const greeting = await client.getVariable("greeting");
- return { message: `Hello from ${ctx.taskId}: ${greeting}` };
+export async function sayHello() {
+ const greeting = await getClient().getVariable("greeting");
+ return { message: `Hello from ${getContext().taskId}: ${greeting}` };
}
const dag = new Dag("example_dag");
@@ -48,6 +48,9 @@ dag.task("say_hello", sayHello);
await serveDags(new DagRegistry(dag));
```
+A handler is a plain function. `getContext()` and `getClient()` reach the
runtime from inside the call,
+so a handler takes no SDK-supplied parameter.
+
Non-`undefined` return values are pushed to XCom under the `"return_value"`
key by the active runtime, matching Python `@task` behavior.
@@ -100,9 +103,10 @@ Airflow metadata in the bundle itself.
TypeScript entrypoint:
```ts
-import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from
"apache-airflow-ts-sdk";
+import { Dag, DagRegistry, getClient, serveDags } from "apache-airflow-ts-sdk";
-export async function extract({ client }: TaskHandlerArgs) {
+export async function extract() {
+ const client = getClient();
const connection = await client.getConnection("sales_db");
const rowCount = Number((await client.getVariable("daily_row_count")) ??
"0");
@@ -112,8 +116,8 @@ export async function extract({ client }: TaskHandlerArgs) {
};
}
-export async function transform({ client }: TaskHandlerArgs) {
- const extracted = await client.getXCom<{ rowCount: number }>({
+export async function transform() {
+ const extracted = await getClient().getXCom<{ rowCount: number }>({
key: "return_value",
taskId: "extract",
});
@@ -144,8 +148,8 @@ marked removed at runtime. The registry itself holds no
sockets and starts
nothing, so a unit test can build one and dispatch through
`registry.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.
+`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:
@@ -195,13 +199,12 @@ one deployable file with no hand-written metadata sidecar.
Options:
-- `--outdir <dir>` — output directory (default `dist`)
-- `--source <name>` — display name of the primary source file shown in the
- Airflow UI (default: entry basename)
+- `--outdir <dir>`: output directory (default `dist`)
+- `--source <name>`: display name of the primary source file shown in the
Airflow UI (default: entry basename)
## TaskClient
-Every task handler receives a `TaskClient` for task-time Airflow data access:
+`getClient()` returns a `TaskClient` for task-time Airflow data access, for as
long as a handler is running:
| Method | Description |
| ------------------------------------------------ | ------------------- |
@@ -214,10 +217,9 @@ current task context when omitted.
## Cancellation
-`ctx.signal` is an `AbortSignal` controlled by the active runtime. Pass it to
-`fetch()`, timers, database clients, child processes, or any other API that
-accepts an abort signal so tasks can clean up cooperatively when Airflow
-terminates the task subprocess with SIGTERM or SIGINT.
+`getContext().signal` is an `AbortSignal` controlled by the active runtime.
+Pass it to `fetch()`, timers, database clients, child processes, or any other
API that accepts an abort signal,
+so tasks can clean up cooperatively when Airflow terminates the task
subprocess.
## Compatibility matrix
@@ -225,8 +227,7 @@ Which Airflow TaskInstance states and capabilities this SDK
supports. This table
[`capabilities.yaml`](https://github.com/apache/airflow/blob/main/ts-sdk/capabilities.yaml);
the conformance dimensions are defined in the
[Language SDK conformance
spec](https://github.com/apache/airflow/blob/main/contributing-docs/30_new_language_sdk.rst).
-Do not edit the table by hand — update the manifest and run the
-`update-ts-sdk-readme-matrix` prek hook.
+Do not edit the table by hand. Update the manifest and run the
`update-ts-sdk-readme-matrix` prek hook.
<!-- BEGIN AUTO-GENERATED LANG-SDK COMPAT MATRIX -->
@@ -275,13 +276,11 @@ Do not edit the table by hand — update the manifest and
run the
## Links
- [TypeScript SDK guide (staged
docs)](https://airflow.staged.apache.org/docs/apache-airflow/stable/authoring-and-scheduling/language-sdks/typescript.html)
- — how Airflow runs TypeScript task handlers
+ (how Airflow runs TypeScript task handlers)
- [API reference
(staged)](https://airflow.staged.apache.org/docs/ts-sdk/stable/)
- — generated from the TypeScript sources
-- [Source](https://github.com/apache/airflow/tree/main/ts-sdk) — the `ts-sdk/`
- directory of the Apache Airflow monorepo
-- [Issues](https://github.com/apache/airflow/issues) — bug reports and feature
- requests
+ (generated from the TypeScript sources)
+- [Source](https://github.com/apache/airflow/tree/main/ts-sdk): the `ts-sdk/`
directory of the Apache Airflow monorepo
+- [Issues](https://github.com/apache/airflow/issues): bug reports and feature
requests
- [Website](https://airflow.apache.org) ·
[Slack](https://s.apache.org/airflow-slack)
- [Developing this
package](https://github.com/apache/airflow/blob/main/ts-sdk/DEVELOPMENT.md)
- — local build, docs, and the release workflow
+ (local build, docs, and the release workflow)
diff --git a/ts-sdk/api-docs/dag-authoring-api.ts
b/ts-sdk/api-docs/dag-authoring-api.ts
index f91f824eabd..065cd0ae689 100644
--- a/ts-sdk/api-docs/dag-authoring-api.ts
+++ b/ts-sdk/api-docs/dag-authoring-api.ts
@@ -19,13 +19,12 @@
/** @module Authoring */
-export { Dag, DagRegistry, serveDags } from "../src/index.js";
+export { Dag, DagRegistry, getClient, getContext, serveDags } from
"../src/index.js";
export type {
DagSpec,
TaskClient,
TaskContext,
- TaskHandler,
- TaskHandlerArgs,
+ TaskFunction,
TaskInputs,
TaskOptions,
TaskRef,
diff --git a/ts-sdk/docs/index.md b/ts-sdk/docs/index.md
index 09c44272848..5082acd4a5f 100644
--- a/ts-sdk/docs/index.md
+++ b/ts-sdk/docs/index.md
@@ -35,17 +35,18 @@ Install the beta package from npm:
npm install [email protected]
```
-Define a Dag and register its task handlers. Handlers receive a `TaskContext`
-and a `TaskClient`; any non-`undefined` return value is pushed to XCom under
-the `"return_value"` key by the active runtime, matching Python `@task`
-behavior:
+Define a Dag and register its task handlers.
+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 { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from
"apache-airflow-ts-sdk";
+import { Dag, DagRegistry, getClient, getContext, serveDags } from
"apache-airflow-ts-sdk";
-export async function sayHello({ ctx, client }: TaskHandlerArgs) {
- const greeting = await client.getVariable("greeting");
- return { message: `Hello from ${ctx.taskId}: ${greeting}` };
+export async function sayHello() {
+ const greeting = await getClient().getVariable("greeting");
+ return { message: `Hello from ${getContext().taskId}: ${greeting}` };
}
const dag = new Dag("example_dag");
diff --git a/ts-sdk/example/src/main.ts b/ts-sdk/example/src/main.ts
index 563f474b422..e98be36b92e 100644
--- a/ts-sdk/example/src/main.ts
+++ b/ts-sdk/example/src/main.ts
@@ -17,11 +17,12 @@
* under the License.
*/
-import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from
"apache-airflow-ts-sdk";
+import { Dag, DagRegistry, getClient, serveDags } from "apache-airflow-ts-sdk";
const dag = new Dag("typescript_example");
-export async function buildMessage({ client }: TaskHandlerArgs) {
+export async function buildMessage() {
+ const client = getClient();
const upstream = await client.getXCom<string>({
key: "return_value",
taskId: "python_start",
@@ -37,8 +38,8 @@ export async function buildMessage({ client }:
TaskHandlerArgs) {
};
}
-export async function readConnection({ client }: TaskHandlerArgs) {
- const connection = await client.getConnection("typescript_example_http");
+export async function readConnection() {
+ const connection = await
getClient().getConnection("typescript_example_http");
return {
id: connection?.id ?? null,
diff --git a/ts-sdk/scripts/verify-package.mjs
b/ts-sdk/scripts/verify-package.mjs
index 975f05f033f..5655310a646 100644
--- a/ts-sdk/scripts/verify-package.mjs
+++ b/ts-sdk/scripts/verify-package.mjs
@@ -32,7 +32,9 @@ const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
export const REQUIRED_ROOT_FILES = ["LICENSE", "NOTICE", "README.md",
"package.json"];
// The Dag-authoring entrypoints a consumer must be able to reach from the
package root.
-export const REQUIRED_ROOT_EXPORTS = ["Dag", "DagRegistry", "serveDags"];
+// 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 = ["Dag", "DagRegistry", "getClient",
"getContext", "serveDags"];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -59,7 +61,7 @@ export function isAllowedFile(path) {
}
/**
- * Every path a consumer can resolve — the `exports` conditions plus the `bin`
targets. Derived
+ * Every path a consumer can resolve: the `exports` conditions plus the `bin`
targets. Derived
* from package.json so adding an export subpath extends the check instead of
silently escaping it.
*/
export function collectEntryPoints(packageJson) {
@@ -72,7 +74,7 @@ export function collectEntryPoints(packageJson) {
/**
* The `exports` subpath keys as specifier suffixes: `"."` becomes `""` and
`"./coordinator"`
- * becomes `"/coordinator"`. Presence in the tarball is not resolution — a
subpath can ship and
+ * becomes `"/coordinator"`. Presence in the tarball is not resolution, since
a subpath can ship and
* still fail to import if its conditions are wrong or an internal import is
broken.
*/
export function collectExportSubpaths(packageJson) {
@@ -84,7 +86,7 @@ export function collectExportSubpaths(packageJson) {
/** The trailing JSON object of `pnpm pack --json` stdout, past the `prepack`
build banner. */
export function parsePackMetadata(stdout) {
// `--silent` does not suppress the `prepack` lifecycle banner, so stdout is
build log followed
- // by the JSON report — take the trailing object rather than parsing the
whole stream.
+ // by the JSON report, so take the trailing object rather than parsing the
whole stream.
const metadataMatch = stdout.match(/(?:^|\n)(\{[\s\S]*\})\s*$/);
if (!metadataMatch) {
throw new Error("pnpm pack did not return package metadata");
@@ -166,7 +168,7 @@ function verifyPackage() {
});
// Spawning the installed bin shim proves it exists, is executable, and
resolves its imports.
- // Asserting only "exited non-zero, and blamed itself" keeps this a
packaging check — the CLI's
+ // Asserting only "exited non-zero, and blamed itself" keeps this a
packaging check, since the CLI's
// own argument handling is covered by its unit tests.
const [binName] = Object.keys(packageJson.bin ?? {});
if (!binName) {
diff --git a/ts-sdk/src/coordinator/runtime.ts
b/ts-sdk/src/coordinator/runtime.ts
index 5f8c1afaf25..79a273e4278 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -26,7 +26,8 @@
// 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(...)`, collects them in a `DagRegistry`, then awaits
-// `serveDags(registry)`.
+// `serveDags(registry)`. Each handler runs inside a task scope, which is what
+// `getContext()` and `getClient()` read.
//
// Lifecycle:
// 1. Parse --comm / --logs from argv
@@ -54,7 +55,7 @@ import {
} from "./protocol.js";
import { DagRegistry, isDagRegistry, listRegistryTasks } from
"../sdk/registry.js";
import { DUPLICATE_COPY_HINT } from "../sdk/brand.js";
-import type { TaskContext, TaskHandlerArgs } from "../sdk/task.js";
+import { runInTaskScope, type TaskContext } from "../sdk/task.js";
import type { JsonValue } from "../sdk/client-types.js";
export const ABORT_GRACE_PERIOD_MS = 30_000;
@@ -82,8 +83,8 @@ function serveLatch(): Record<symbol, boolean | undefined> {
* ```
*
* The registry is the bundle's complete set of Dags: this process serves one
- * supervisor request, so a second call — which would connect a second pair of
- * sockets — is rejected. A call that fails is not a serve, and may be retried.
+ * supervisor request, so a second call, which would connect a second pair of
+ * sockets, is rejected. A call that fails is not a serve, and may be retried.
* Resolves when Airflow's supervisor has been sent the terminal frame for the
* work this process was started for; the same call also answers the build-time
* `--airflow-metadata` query `airflow-ts-pack` makes.
@@ -361,18 +362,20 @@ async function handleTask(
const ctx = buildContext(details, signal);
const client = createCoordinatorClient(comm, ctx, clientLogs);
- const args: TaskHandlerArgs = { ctx, client };
// Startup-details fields already logged above (`Received task
// startup details`); this line just marks the handler-call boundary.
logs.debug("Dispatching to handler", { task_id: ctx.taskId });
try {
- const result = await handler(args);
+ // The scope is installed around the call, not awaited inside it: the store
+ // follows the handler across every `await` it makes, so `getContext()` and
+ // `getClient()` work at any depth without the handler being handed either.
+ const result = await runInTaskScope({ ctx, client }, () => handler());
if (result !== undefined) {
await client.setXCom({ key: "return_value", value: result as JsonValue
});
}
// SucceedTask MUST include task_outlets and outlet_events as
- // empty lists — the Execution API's TISuccessStatePayload
+ // empty lists, since the Execution API's TISuccessStatePayload
// tagged-union validator rejects null for these fields.
const response: RuntimeSucceedTask = {
type: "SucceedTask",
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index a7c8f4960b0..b4a412f172e 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -19,9 +19,10 @@
export { Dag } from "./sdk/dag.js";
export { DagRegistry } from "./sdk/registry.js";
+export { getClient, getContext } from "./sdk/task.js";
export { ConnectionNotFoundError, VariableNotFoundError } from
"./sdk/client.js";
export { serveDags, SUPERVISOR_API_VERSION } from "./coordinator/index.js";
export type { DagSpec, TaskInputs, TaskOptions, TaskRef, TaskSpec } from
"./sdk/dag.js";
export type { TaskClient } from "./sdk/client.js";
export type { ConnectionResult, GetXComOpts, JsonValue, SetXComOpts } from
"./sdk/client-types.js";
-export type { TaskContext, TaskHandler, TaskHandlerArgs } from "./sdk/task.js";
+export type { TaskContext, TaskFunction } from "./sdk/task.js";
diff --git a/ts-sdk/src/sdk/dag.ts b/ts-sdk/src/sdk/dag.ts
index b00492ffd87..a329aa8c869 100644
--- a/ts-sdk/src/sdk/dag.ts
+++ b/ts-sdk/src/sdk/dag.ts
@@ -20,7 +20,7 @@
// The Dag authoring surface: `new Dag(dagId)` plus `dag.task(taskId,
handler)`.
import { brand, hasBrand } from "./brand.js";
-import type { TaskHandler } from "./task.js";
+import type { TaskFunction } from "./task.js";
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
@@ -38,7 +38,7 @@ function validateEmptySpec(name: string, value: unknown):
void {
* Dag-level options.
*
* No fields yet, so only `{}` is accepted: a field that would be silently
- * dropped — `new Dag("d", { schedule: "@daily" })` — is a compile error.
+ * dropped, such as `new Dag("d", { schedule: "@daily" })`, is a compile error.
*
* Native Dag declaration will add optional fields here, generated from the
* serialized-Dag JSON schema as `src/generated/supervisor.ts` is.
@@ -73,7 +73,7 @@ export interface TaskRef {
* Task references keyed by input name.
*
* Stored and validated, but they do not create dependencies or pass values yet
- * — see {@link TaskOptions.inputs}. Each must identify an earlier task in the
+ * (see {@link TaskOptions.inputs}). Each must identify an earlier task in the
* same Dag. Literal values are not supported.
*/
export type TaskInputs = Readonly<Record<string, TaskRef>>;
@@ -89,23 +89,23 @@ export interface TaskOptions {
/**
* References to the upstream tasks this task consumes.
*
- * Not used yet: a handler receives `{ctx, client}` only, and the Python stub
- * Dag defines task order. Read an upstream return value explicitly instead —
- * `client.getXCom({ key: "return_value", taskId: "extract" })`, where
+ * Not used yet: a handler takes no arguments, and the Python stub Dag
+ * defines task order. Read an upstream return value explicitly instead, with
+ * `getClient().getXCom({ key: "return_value", taskId: "extract" })`, where
* omitting `taskId` reads the *running* task's own XCom, not the upstream.
*
* In the future these will declare dependencies in native TypeScript Dags.
*/
readonly inputs?: TaskInputs;
- /** Task-level options. Stored, but not used yet — see {@link TaskSpec}. */
+ /** Task-level options. Stored, but not used yet (see {@link TaskSpec}). */
readonly spec?: TaskSpec;
}
-/** Per-task record a Dag retains: the handle, the handler, its spec, and the
+/** Per-task record a Dag retains: the handle, the function, its spec, and the
* upstream handles feeding it. */
export interface TaskRecord {
readonly task: TaskRef;
- readonly handler: TaskHandler;
+ readonly fn: TaskFunction;
readonly spec: TaskSpec;
/** Upstream handles keyed by input name; empty when the task has no inputs.
*/
readonly inputs: TaskInputs;
@@ -150,7 +150,7 @@ export class Dag {
// Copied and frozen, as task specs and inputs are: nothing reads a spec
// until the bundle manifest is built, long after the user's module has
run,
// so a later mutation of their object would silently change what is
packed.
- // Shallow — a nested value in a future generated spec stays mutable.
+ // Shallow, so a nested value in a future generated spec stays mutable.
this.spec = Object.freeze({ ...spec });
}
@@ -167,7 +167,7 @@ export class Dag {
*/
task<TReturn = unknown>(
taskId: string,
- handler: TaskHandler<TReturn>,
+ handler: TaskFunction<TReturn>,
options: TaskOptions = {},
): TaskRef {
if (typeof handler !== "function") {
@@ -183,15 +183,15 @@ export class Dag {
const task: TaskRef = Object.freeze({ dagId: this.dagId, taskId });
this.#tasks.set(taskId, {
task,
- handler: handler as TaskHandler,
+ fn: handler as TaskFunction,
spec: Object.freeze({ ...spec }),
inputs: Object.freeze({ ...inputs }),
});
return task;
}
- // TypeScript is bypassable — from plain JavaScript, or an `as TaskOptions`
- // cast — so an unknown key is rejected rather than silently ignored.
+ // TypeScript is bypassable from plain JavaScript or an `as TaskOptions`
+ // cast, so an unknown key is rejected rather than silently ignored.
#validateOptions(taskId: string, options: TaskOptions): void {
const value: unknown = options;
if (!isPlainRecord(value)) {
diff --git a/ts-sdk/src/sdk/registry.ts b/ts-sdk/src/sdk/registry.ts
index ee4286de926..9d9259ad540 100644
--- a/ts-sdk/src/sdk/registry.ts
+++ b/ts-sdk/src/sdk/registry.ts
@@ -19,7 +19,7 @@
import { brand, DUPLICATE_COPY_HINT, hasBrand } from "./brand.js";
import { Dag, getDagTaskRecords, isDag, type TaskRef } from "./dag.js";
-import type { TaskHandler } from "./task.js";
+import type { TaskFunction } from "./task.js";
// Assigned inside DagRegistry's static block, as Dag does for its tasks.
let dagsOf: (registry: DagRegistry) => ReadonlyMap<string, Dag>;
@@ -53,7 +53,7 @@ export interface RegisteredDag {
* handler through {@link getTaskHandler} without any runtime in scope.
*
* Lookups delegate live to each Dag's task map, so tasks added to a Dag
- * after registration are visible — the registry records Dag identity, not
+ * after registration are visible: the registry records Dag identity, not
* a snapshot of its tasks.
*/
export class DagRegistry {
@@ -80,8 +80,8 @@ export class DagRegistry {
// Typed as Dag, so narrowing it would collapse to never; these guard
// callers reaching this from plain JavaScript.
const candidate: unknown = dag;
- // Another copy's Dag cannot be registered — lookups read a private task
- // map keyed to this copy's class — so it is rejected, but by its cause.
+ // 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)) {
throw new Error(
isDag(candidate)
@@ -101,14 +101,14 @@ export class DagRegistry {
/** Look up a registered handler, the way the runtime dispatches a task.
* Returns `undefined` when no handler exists. */
- getTaskHandler(dagId: string, taskId: string): TaskHandler | undefined {
+ getTaskHandler(dagId: string, taskId: string): TaskFunction | undefined {
const dag = this.#dags.get(dagId);
- return dag ? getDagTaskRecords(dag).get(taskId)?.handler : undefined;
+ return dag ? getDagTaskRecords(dag).get(taskId)?.fn : undefined;
}
}
/** Internal: the task handles across a registry's Dags. Not re-exported from
the
- * package root — enumerating what the runtime dispatches is the runtime's
job. */
+ * package root: enumerating what the runtime dispatches is the runtime's
job. */
export function listRegistryTasks(registry: DagRegistry): TaskRef[] {
return [...dagsOf(registry).values()].flatMap((dag) =>
[...getDagTaskRecords(dag).values()].map((record) => record.task),
diff --git a/ts-sdk/src/sdk/task.ts b/ts-sdk/src/sdk/task.ts
index 40d5c7ee0a7..3686691120b 100644
--- a/ts-sdk/src/sdk/task.ts
+++ b/ts-sdk/src/sdk/task.ts
@@ -17,7 +17,12 @@
* under the License.
*/
-// The task-handler call surface — types every user task handler sees.
+// The task-handler call surface: what a task handler sees while it runs.
+//
+// Everything the SDK supplies comes from a getter rather than a parameter, so
+// nothing it injects shares a namespace with an author's arguments.
+
+import { AsyncLocalStorage } from "node:async_hooks";
import type { TaskClient } from "./client.js";
@@ -43,19 +48,93 @@ export interface TaskContext {
readonly signal: AbortSignal;
}
-/** Arguments passed to every task handler. */
-export interface TaskHandlerArgs {
- /** Runtime metadata for the current task invocation. */
+/** What the runtime puts in scope for the duration of one handler call. */
+export interface TaskScope {
readonly ctx: TaskContext;
- /** Client for reading and writing Airflow task-time data. */
readonly client: TaskClient;
}
+// Keyed on a global symbol, as the serve latch is: two resolved copies of the
+// package would otherwise hold one storage each, and a handler reaching for
+// `getClient()` through the copy that is not running the task would find
+// nothing in scope.
+const SCOPE_STORAGE = Symbol.for("airflow.ts-sdk.task-scope");
+
+function scopeStorage(): AsyncLocalStorage<TaskScope> {
+ const holder = globalThis as unknown as Record<symbol,
AsyncLocalStorage<TaskScope> | undefined>;
+ return (holder[SCOPE_STORAGE] ??= new AsyncLocalStorage<TaskScope>());
+}
+
+/**
+ * Internal: call `fn` with `scope` in place, as the runtime does per task.
+ *
+ * `AsyncLocalStorage` carries the store across every `await` and into every
+ * promise created inside `fn`, so a handler's helpers see it without being
+ * passed anything. Not re-exported from the package root: a handler reads the
+ * scope, it does not install one.
+ */
+export function runInTaskScope<T>(scope: TaskScope, fn: () => T): T {
+ return scopeStorage().run(scope, fn);
+}
+
+type ScopeAccessor = "getContext" | "getClient";
+
+function currentScope(accessor: ScopeAccessor): TaskScope {
+ const scope = scopeStorage().getStore();
+ if (!scope) {
+ throw new Error(
+ `${accessor}() is only available inside a task handler. ` +
+ "The scope is in place only for the duration of the handler call, so "
+
+ "this ran either at module top level or in work that outlived it.",
+ );
+ }
+ return scope;
+}
+
+/**
+ * Runtime metadata for the task currently running.
+ *
+ * ```ts
+ * async function transform() {
+ * throw new Error(`task ${getContext().taskId} has nothing to transform`);
+ * }
+ * ```
+ *
+ * @throws when called outside a task handler.
+ */
+export function getContext(): TaskContext {
+ return currentScope("getContext").ctx;
+}
+
+/**
+ * Client for reading and writing Airflow task-time data for the task currently
+ * running.
+ *
+ * ```ts
+ * async function transform() {
+ * const client = getClient();
+ * return await client.getXCom<number>({ key: "return_value", taskId:
"extract" });
+ * }
+ * ```
+ *
+ * Work that outlives the handler is the one gap. Async context propagates into
+ * a promise created inside the handler, so one it never awaits still resolves,
+ * but it runs after the task's terminal state has been reported and writes to
+ * a finished task. Await everything a handler starts.
+ *
+ * @throws when called outside a task handler.
+ */
+export function getClient(): TaskClient {
+ return currentScope("getClient").client;
+}
+
/**
* Function signature for a TypeScript task handler.
*
- * Non-`undefined` return values are automatically pushed to XCom under
- * the `"return_value"` key, matching Python `@task` behavior. Return
- * `undefined` or omit a return value to skip the automatic XCom push.
+ * A handler takes no SDK-supplied parameter: {@link getContext} and
+ * {@link getClient} supply the runtime's side of the call.
+ *
+ * Non-`undefined` return values are automatically pushed to XCom under the
+ * `"return_value"` key, matching Python `@task` behavior.
*/
-export type TaskHandler<TReturn = unknown> = (args: TaskHandlerArgs) =>
TReturn | Promise<TReturn>;
+export type TaskFunction<TReturn = unknown> = () => TReturn | Promise<TReturn>;
diff --git a/ts-sdk/tests/coordinator/integration.test.ts
b/ts-sdk/tests/coordinator/integration.test.ts
index 040de00fa96..a3293a57847 100644
--- a/ts-sdk/tests/coordinator/integration.test.ts
+++ b/ts-sdk/tests/coordinator/integration.test.ts
@@ -25,7 +25,7 @@
// drives the runtime through task success, task failure, retry, abort
signaling,
// task-time RPCs, missing handlers, and parse-mode responses.
//
-// No Python, no Airflow install — but exercises the same wire format
+// No Python, no Airflow install, but exercises the same wire format
// the real coordinator speaks.
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -37,6 +37,7 @@ import {
} from "../../src/coordinator/runtime.js";
import { Dag } from "../../src/sdk/dag.js";
import { DagRegistry } from "../../src/sdk/registry.js";
+import { getClient, getContext } from "../../src/sdk/task.js";
const testDag = new Dag("test_dag");
const otherDag = new Dag("other_dag");
@@ -54,7 +55,7 @@ interface MockResult {
/** Callback used by `driveSupervisor` to answer runtime-initiated
* requests. Return `{ body, error? }` to reply with that arity-3
* frame, or `null` to ignore the request (the runtime will hang
- * waiting for a response — only useful for negative tests). */
+ * waiting for a response, which is only useful for negative tests). */
type Responder = (
msgType: string,
body: Record<string, unknown>,
@@ -157,7 +158,7 @@ async function driveSupervisor(initialFrame: unknown,
responder?: Responder): Pr
const [commSock, logsSock] = await Promise.all([commAccept, logsAccept]);
- // Send the kickoff frame as a _ResponseFrame (arity 3) — matches what
+ // Send the kickoff frame as a _ResponseFrame (arity 3), matching what
// Airflow's `_send_startup_details` actually emits on the wire.
commSock.write(frameBytes(0, initialFrame, true));
@@ -239,8 +240,8 @@ describe("coordinator runtime integration", () => {
it("dispatches StartupDetails to a registered handler and emits
SucceedTask", async () => {
let observedCtx: unknown = null;
- testDag.task("say_hello", async ({ ctx }) => {
- observedCtx = ctx;
+ testDag.task("say_hello", async () => {
+ observedCtx = getContext();
return "ok";
});
@@ -265,7 +266,7 @@ describe("coordinator runtime integration", () => {
// Logger names should be hierarchical (`ts-sdk.<subsystem>`) so the
// Python supervisor's ConsoleRenderer prints them as a distinct
- // `[name]` column — not hardcoded to "task" (which collides with
+ // `[name]` column, not hardcoded to "task" (which collides with
// user task logs).
const loggers = new Set(result.logRecords.map((r) => r["logger"]));
expect(loggers.has("ts-sdk.runtime")).toBe(true);
@@ -355,11 +356,11 @@ describe("coordinator runtime integration", () => {
});
});
- it("aborts ctx.signal on SIGTERM and reports a thrown task error", async ()
=> {
+ it("aborts the context signal on SIGTERM and reports a thrown task error",
async () => {
let sawAbort = false;
- testDag.task("aborted_then_failed", async ({ ctx }) => {
+ testDag.task("aborted_then_failed", async () => {
process.emit("SIGTERM");
- sawAbort = ctx.signal.aborted;
+ sawAbort = getContext().signal.aborted;
throw new Error("interrupted");
});
@@ -375,9 +376,9 @@ describe("coordinator runtime integration", () => {
it("returns RetryTask with the thrown error when a task fails after
SIGTERM", async () => {
let sawAbort = false;
- testDag.task("aborted_then_failed_retry", async ({ ctx }) => {
+ testDag.task("aborted_then_failed_retry", async () => {
process.emit("SIGTERM");
- sawAbort = ctx.signal.aborted;
+ sawAbort = getContext().signal.aborted;
throw new Error("interrupted");
});
@@ -398,9 +399,9 @@ describe("coordinator runtime integration", () => {
it("does not discard a completed task result after SIGTERM", async () => {
let sawAbort = false;
- testDag.task("completed_after_sigterm", async ({ ctx }) => {
+ testDag.task("completed_after_sigterm", async () => {
process.emit("SIGTERM");
- sawAbort = ctx.signal.aborted;
+ sawAbort = getContext().signal.aborted;
return "completed";
});
@@ -434,9 +435,10 @@ describe("coordinator runtime integration", () => {
const xcomStore = new Map<string, unknown>();
let observedGreeting: string | null = "<unset>";
- testDag.task("say_hello_client", async ({ ctx, client }) => {
- // The coordinator-mode handler MUST receive a client.
- if (!client) throw new Error("client missing in coordinator mode");
+ testDag.task("say_hello_client", async () => {
+ // The coordinator-mode handler MUST be able to reach a client.
+ const ctx = getContext();
+ const client = getClient();
observedGreeting = await client.getVariable("e6_greeting");
await client.setXCom({
@@ -503,8 +505,8 @@ describe("coordinator runtime integration", () => {
it("returns null from getVariable when the supervisor signals NOT_FOUND",
async () => {
let observed: string | null = "<unset>";
- testDag.task("missing_variable", async ({ client }) => {
- observed = await client.getVariable("missing_key");
+ testDag.task("missing_variable", async () => {
+ observed = await getClient().getVariable("missing_key");
});
const responder: Responder = (msgType) => {
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index c1722b85c89..9f0f732f838 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -26,7 +26,7 @@ import type {
SetXComOpts,
TaskClient,
TaskContext,
- TaskHandler,
+ TaskFunction,
TaskInputs,
TaskOptions,
TaskRef,
@@ -37,6 +37,8 @@ import {
ConnectionNotFoundError,
Dag,
DagRegistry,
+ getClient,
+ getContext,
serveDags,
SUPERVISOR_API_VERSION,
VariableNotFoundError,
@@ -153,6 +155,27 @@ describe("public API", () => {
expectTypeOf<typeof sdk>().not.toHaveProperty("startCoordinator");
});
+ 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;
+ // this pins that both reach the package root and say what went wrong.
+ expect(() => getContext()).toThrow(/^getContext\(\) is only available
inside a task handler/);
+ expect(() => getClient()).toThrow(/^getClient\(\) is only available
inside a task handler/);
+ });
+
+ it("are the only way a handler reaches the runtime", () => {
+ // A handler is a plain function of its own data, so the SDK hands it no
+ // parameter at all and the scope is not something an author installs.
+ expectTypeOf<TaskFunction>().toEqualTypeOf<() => unknown |
Promise<unknown>>();
+ expectTypeOf<typeof getContext>().toEqualTypeOf<() => TaskContext>();
+ expectTypeOf<typeof getClient>().toEqualTypeOf<() => TaskClient>();
+ for (const name of ["TaskHandlerArgs", "runInTaskScope", "TaskScope"]) {
+ expect(name in sdk).toBe(false);
+ }
+ expectTypeOf<typeof sdk>().not.toHaveProperty("runInTaskScope");
+ });
+ });
+
it("exports public error classes", () => {
const err = new VariableNotFoundError("missing");
expect(err).toBeInstanceOf(Error);
@@ -185,7 +208,7 @@ describe("public API", () => {
expectTypeOf<Dag["task"]>().toEqualTypeOf<
<TReturn = unknown>(
taskId: string,
- handler: TaskHandler<TReturn>,
+ handler: TaskFunction<TReturn>,
options?: TaskOptions,
) => TaskRef
>();
diff --git a/ts-sdk/tests/sdk/dag.test.ts b/ts-sdk/tests/sdk/dag.test.ts
index e76c24ab4c1..6177cf28b02 100644
--- a/ts-sdk/tests/sdk/dag.test.ts
+++ b/ts-sdk/tests/sdk/dag.test.ts
@@ -115,7 +115,7 @@ describe("Dag", () => {
expect(dag.spec).toEqual(dagSpec);
expect(Object.isFrozen(dag.spec)).toBe(true);
const record = getDagTaskRecords(dag).get("my_task");
- expect(record?.handler).toBe(handler);
+ expect(record?.fn).toBe(handler);
expect(record?.spec).toEqual(taskSpec);
expect(Object.isFrozen(record!.spec)).toBe(true);
});
diff --git a/ts-sdk/tests/sdk/task-scope.test.ts
b/ts-sdk/tests/sdk/task-scope.test.ts
new file mode 100644
index 00000000000..dc6299a4f6f
--- /dev/null
+++ b/ts-sdk/tests/sdk/task-scope.test.ts
@@ -0,0 +1,170 @@
+/*!
+ * 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, vi } from "vitest";
+
+import type { TaskClient } from "../../src/sdk/client.js";
+import {
+ getClient,
+ getContext,
+ runInTaskScope,
+ type TaskContext,
+ type TaskScope,
+} from "../../src/sdk/task.js";
+
+function makeScope(taskId: string): TaskScope {
+ const ctx: TaskContext = {
+ dagId: "scope_dag",
+ taskId,
+ runId: "r1",
+ tryNumber: 1,
+ mapIndex: -1,
+ signal: new AbortController().signal,
+ };
+ // Identity is all these tests read, so a cast beats a full fake client.
+ const client = { label: taskId } as unknown as TaskClient;
+ return { ctx, client };
+}
+
+describe("the task scope", () => {
+ it("hands a handler the context and client the runtime installed", async ()
=> {
+ const scope = makeScope("dispatched");
+
+ const seen = await runInTaskScope(scope, async () => ({
+ ctx: getContext(),
+ client: getClient(),
+ }));
+
+ expect(seen.ctx).toBe(scope.ctx);
+ expect(seen.client).toBe(scope.client);
+ });
+
+ it("survives await boundaries and reaches helpers it was never passed",
async () => {
+ // The point of AsyncLocalStorage over a parameter: a helper several frames
+ // and several awaits deep reads the scope without anything being threaded.
+ async function readTaskIdDeep(): Promise<string> {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ return getContext().taskId;
+ }
+
+ const taskId = await runInTaskScope(makeScope("deep"), async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setImmediate(resolve));
+ return await readTaskIdDeep();
+ });
+
+ expect(taskId).toBe("deep");
+ });
+
+ it("keeps concurrent handlers from seeing each other's scope", async () => {
+ // A module variable would pass every single-handler test above, so this
+ // is what pins the store as per-call.
+ const run = (taskId: string, delayMs: number) =>
+ runInTaskScope(makeScope(taskId), async () => {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ return getContext().taskId;
+ });
+
+ // The slower one is started first, so a shared store would answer
"second".
+ expect(await Promise.all([run("first", 10), run("second",
0)])).toEqual(["first", "second"]);
+ });
+
+ it("throws outside a handler, naming the accessor", () => {
+ expect(() => getContext()).toThrow(/^getContext\(\) is only available
inside a task handler/);
+ expect(() => getClient()).toThrow(/^getClient\(\) is only available inside
a task handler/);
+ });
+
+ it("throws in work started outside the handler it belongs to", async () => {
+ // A callback handed to a module-level queue or an emitter registered at
+ // import time does not carry the scope with it.
+ let escaped: (() => TaskContext) | null = null;
+ await runInTaskScope(makeScope("escaping"), async () => {
+ escaped = () => getContext();
+ });
+
+ expect(escaped).not.toBeNull();
+ expect(() => escaped!()).toThrow(/only available inside a task handler/);
+ });
+
+ it("still resolves in a floating promise, which is why one must be awaited",
async () => {
+ // By the time a promise the handler never awaited resolves, the runtime
+ // has already reported the task's terminal state. Pinned because the fix
+ // is to await the work, not to expect a throw here.
+ let floating: Promise<string> | null = null;
+ await runInTaskScope(makeScope("floating"), async () => {
+ floating = (async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ return getContext().taskId;
+ })();
+ });
+
+ await expect(floating!).resolves.toBe("floating");
+ });
+
+ it("restores an outer scope after a nested one returns", async () => {
+ const outer = makeScope("outer");
+
+ const seen = await runInTaskScope(outer, async () => {
+ const inner = await runInTaskScope(makeScope("inner"), async () =>
getContext().taskId);
+ return { inner, afterInner: getContext().taskId };
+ });
+
+ expect(seen).toEqual({ inner: "inner", afterInner: "outer" });
+ });
+
+ it("is keyed globally, so a second resolved copy shares one storage", async
() => {
+ // Two copies of the package each import their own module instance. The
+ // storage lives on globalThis under a well-known symbol so the copy that
+ // dispatches and the copy a handler imported `getClient` from agree.
+ const key = Symbol.for("airflow.ts-sdk.task-scope");
+ const holder = globalThis as unknown as Record<symbol, unknown>;
+
+ await runInTaskScope(makeScope("global"), async () => {
+ expect(holder[key]).toBeDefined();
+ expect(getContext().taskId).toBe("global");
+ });
+ });
+
+ it("does not swallow a handler's own error", async () => {
+ const boom = new Error("handler blew up");
+ await expect(
+ runInTaskScope(makeScope("failing"), async () => {
+ throw boom;
+ }),
+ ).rejects.toBe(boom);
+ // The scope unwinds with the throw rather than leaking to the next call.
+ expect(() => getContext()).toThrow();
+ });
+
+ it("does not hold a reference the handler can reassign", async () => {
+ const scope = makeScope("frozen");
+ const spy = vi.fn();
+
+ await runInTaskScope(scope, async () => {
+ const first = getClient();
+ const second = getClient();
+ // Same object every read: nothing rebuilds a client per access.
+ expect(first).toBe(second);
+ spy();
+ });
+
+ expect(spy).toHaveBeenCalledOnce();
+ });
+});