From 9d4086c590f279ce4cf16530fda88ea017784b15 Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Sat, 2 May 2026 13:05:32 -0700 Subject: [PATCH 1/2] ts-sdk: add minimal Node.js TypeScript SDK for Apache Airflow (alpha) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ts-sdk/, a Node.js / TypeScript Task SDK that lets users author and run Airflow tasks as TypeScript handlers in a Node worker process, dispatched via the Edge Executor (AIP-69) through the Task Execution API (AIP-72). Workers are standalone Node processes that talk to Airflow over HTTPS only — no DB access, no in-cluster Python required. Marked alpha (`0.1.0-alpha.0`) — a foundation for follow-up PRs. Includes: - `registerTask` + `startWorker` public API (src/index.ts). - Edge API client: register, heartbeat, fetch job, ack. HS512 JWT signing per request with configurable issuer (defaults to "airflow" to match Airflow's `[api_auth] jwt_issuer` default). - Execution API client with two-token lifecycle: the `Refreshed-API-Token` response header swaps the workload-scoped token from `command.token` for an execution-scoped token on `/run`, and the same mechanism refreshes execution tokens near expiry. Tracks Airflow PR #60108 (merged 2026-04-23). - Worker poll loop with graceful SIGTERM drain — the worker waits for the in-flight task to complete naturally rather than killing it, making it K8s and Lambda safe. - Heartbeat circuit breaker that aborts after consecutive failures. - Failsafe ack: every fetched edge job is ack'd even if the execute path crashes, so edge state always converges. - 71 vitest unit tests across worker, edge-client, execution-client, registry, errors, jwt, retry. Includes coverage for the `Refreshed-API-Token` swap (`tests/execution-client.test.ts`). - 4 reference DAG fixtures + 4 reference workers under `integration/`, plus a pure-JavaScript consumer canary (`integration/workers/js-consumer.mjs`) that imports the built `dist/` directly with stock node — proves the package works for consumers who don't write TypeScript. - TESTING.md: end-to-end recipe runnable from a single host terminal via `docker exec`, with separate "any Airflow" and "set up a local Airflow with breeze" sections. - LICENSE and NOTICE shipped in the npm tarball (matches go-sdk's precedent), enforced via `package.json`'s `files` allowlist. - `packageManager: pnpm@10.28.1` declared so corepack auto-installs the right pnpm. Build: - Plain `tsc` build (no bundler) — pure library, no tree-shaking needed. ESM-only, Node 22+ only. Strict tsconfig with `noUncheckedIndexedAccess`, `verbatimModuleSyntax`, `moduleDetection: force`, `NodeNext` module resolution. - `src/generated/edge.ts` and `src/generated/execution.ts` are produced by `openapi-typescript` from the YAML/JSON specs in `airflow-core/docs/_api/` (committed to keep the diff reviewable and `pnpm install` reproducible — matches `ui/openapi-gen/` precedent). Excluded from the prek `insert-license` hook for the same reason. Intentionally deferred (TODO markers in src/worker.ts): - Per-TI heartbeat - XCom / Variables / Connections - Log forwarding to Airflow UI - Subprocess isolation per task - Concurrency > 1 Manually verified end-to-end against `breeze start-airflow --executor edgeexecutor` (Airflow 3.3-dev / edge3 3.5-dev): happy path, handler-throws → failed, no-handler-registered → failed, SIGTERM-during-task → drained-success. Pure-JS canary also verified. --- .pre-commit-config.yaml | 6 +- ts-sdk/LICENSE | 201 + ts-sdk/NOTICE | 5 + ts-sdk/README.md | 114 + ts-sdk/TESTING.md | 193 + ts-sdk/integration/core_failure.py | 40 + ts-sdk/integration/core_integration.py | 53 + ts-sdk/integration/core_missing_handler.py | 40 + ts-sdk/integration/core_sigterm_inflight.py | 40 + ts-sdk/integration/workers/failure-worker.ts | 47 + ts-sdk/integration/workers/js-consumer.mjs | 59 + .../integration/workers/manual-test-worker.ts | 88 + ts-sdk/integration/workers/sigterm-worker.ts | 65 + ts-sdk/integration/workers/smoke-worker.ts | 61 + ts-sdk/package.json | 62 + ts-sdk/pnpm-lock.yaml | 1315 +++++ ts-sdk/src/edge-client.ts | 215 + ts-sdk/src/errors.ts | 99 + ts-sdk/src/execution-client.ts | 181 + ts-sdk/src/generated/edge.ts | 1556 ++++++ ts-sdk/src/generated/execution.ts | 4470 +++++++++++++++++ ts-sdk/src/index.ts | 25 + ts-sdk/src/registry.ts | 58 + ts-sdk/src/types.ts | 86 + ts-sdk/src/utils/jwt.ts | 89 + ts-sdk/src/utils/retry.ts | 88 + ts-sdk/src/worker-options.ts | 77 + ts-sdk/src/worker.ts | 252 + ts-sdk/tests/edge-client.test.ts | 318 ++ ts-sdk/tests/errors.test.ts | 141 + ts-sdk/tests/execution-client.test.ts | 302 ++ ts-sdk/tests/registry.test.ts | 84 + ts-sdk/tests/utils/jwt.test.ts | 104 + ts-sdk/tests/utils/retry.test.ts | 133 + ts-sdk/tsconfig.build.json | 10 + ts-sdk/tsconfig.json | 27 + 36 files changed, 10701 insertions(+), 3 deletions(-) create mode 100644 ts-sdk/LICENSE create mode 100644 ts-sdk/NOTICE create mode 100644 ts-sdk/README.md create mode 100644 ts-sdk/TESTING.md create mode 100644 ts-sdk/integration/core_failure.py create mode 100644 ts-sdk/integration/core_integration.py create mode 100644 ts-sdk/integration/core_missing_handler.py create mode 100644 ts-sdk/integration/core_sigterm_inflight.py create mode 100644 ts-sdk/integration/workers/failure-worker.ts create mode 100644 ts-sdk/integration/workers/js-consumer.mjs create mode 100644 ts-sdk/integration/workers/manual-test-worker.ts create mode 100644 ts-sdk/integration/workers/sigterm-worker.ts create mode 100644 ts-sdk/integration/workers/smoke-worker.ts create mode 100644 ts-sdk/package.json create mode 100644 ts-sdk/pnpm-lock.yaml create mode 100644 ts-sdk/src/edge-client.ts create mode 100644 ts-sdk/src/errors.ts create mode 100644 ts-sdk/src/execution-client.ts create mode 100644 ts-sdk/src/generated/edge.ts create mode 100644 ts-sdk/src/generated/execution.ts create mode 100644 ts-sdk/src/index.ts create mode 100644 ts-sdk/src/registry.ts create mode 100644 ts-sdk/src/types.ts create mode 100644 ts-sdk/src/utils/jwt.ts create mode 100644 ts-sdk/src/utils/retry.ts create mode 100644 ts-sdk/src/worker-options.ts create mode 100644 ts-sdk/src/worker.ts create mode 100644 ts-sdk/tests/edge-client.test.ts create mode 100644 ts-sdk/tests/errors.test.ts create mode 100644 ts-sdk/tests/execution-client.test.ts create mode 100644 ts-sdk/tests/registry.test.ts create mode 100644 ts-sdk/tests/utils/jwt.test.ts create mode 100644 ts-sdk/tests/utils/retry.test.ts create mode 100644 ts-sdk/tsconfig.build.json create mode 100644 ts-sdk/tsconfig.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 022ffa4c81506..d6ee8c766c19b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,9 +84,9 @@ repos: newsfragments/.*\.rst$| ^scripts/ci/license-templates/ - id: insert-license - name: Add license for CSS/JS/JSX/PUML/TS/TSX - files: \.(css|jsx?|puml|tsx?)$ - exclude: ^\.github/.*$|ui/openapi-gen/|www/openapi-gen/|.*/dist/.* + name: Add license for CSS/JS/JSX/MJS/PUML/TS/TSX + files: \.(css|jsx?|mjs|puml|tsx?)$ + exclude: ^\.github/.*$|ui/openapi-gen/|www/openapi-gen/|.*/dist/.*|^ts-sdk/src/generated/ args: - --comment-style - "/*!| *| */" diff --git a/ts-sdk/LICENSE b/ts-sdk/LICENSE new file mode 100644 index 0000000000000..11069edd79019 --- /dev/null +++ b/ts-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed 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. diff --git a/ts-sdk/NOTICE b/ts-sdk/NOTICE new file mode 100644 index 0000000000000..a51bd9390d030 --- /dev/null +++ b/ts-sdk/NOTICE @@ -0,0 +1,5 @@ +Apache Airflow +Copyright 2016-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/ts-sdk/README.md b/ts-sdk/README.md new file mode 100644 index 0000000000000..6500f277be01b --- /dev/null +++ b/ts-sdk/README.md @@ -0,0 +1,114 @@ + + +# Airflow TypeScript SDK + +A **Node.js** SDK for Apache Airflow. Run Airflow tasks as TypeScript handlers +in a Node worker, dispatched via the [Edge Executor](https://airflow.apache.org/docs/apache-airflow-providers-edge3/stable/index.html) +([AIP-69](https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-69+Edge+Executor)) +through the [Task Execution API](https://airflow.apache.org/docs/apache-airflow/stable/task-sdk-api.html) +([AIP-72](https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-72+Task+Execution+Interface+aka+Task+SDK)). + +**Status:** 🚧 alpha. Under active development. API will change. +**Runtime:** Node 22+ only. ESM-only. + +## Building + +```bash +pnpm install +pnpm run build +pnpm test +``` + +Uses **pnpm** (declared in `package.json`'s `packageManager` field — Node 22+ +with corepack enabled will pick the right version automatically). + +## Architecture + +Workers are **standalone Node processes** that connect to Airflow over HTTPS: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Apache Airflow │ +│ ┌──────────┐ ┌────────────────┐ ┌──────────────────┐ │ +│ │Scheduler │────────▶│ Edge Executor │────────▶│ Edge API Server │ │ +│ └──────────┘ └────────────────┘ └────────┬─────────┘ │ +│ │ HTTPS │ +└────────────────────────────────────────────────────────────┼────────────┘ + │ + ┌─────────────────────────────────────┘ + ▼ + ┌──────────────────────────┐ + │ Node.js Worker Process │ + │ (uses this SDK) │ + │ │ + │ startWorker({ ... }) │ + │ ↓ polls Edge API │ + │ registerTask("foo", fn) │ + │ ↓ runs handler │ + └──────────────────────────┘ +``` + +DAG authors declare TypeScript-backed tasks in Python via a companion +operator package (distributed separately): + +```python +# dags/hello_typescript_dag.py +from airflow.sdk import DAG +from airflow_ts_operator import TypeScriptOperator + +with DAG("hello_typescript", schedule=None, start_date=...) as dag: + TypeScriptOperator(task_id="hello_typescript", queue="ts-tasks") +``` + +## Quickstart + +```ts +// worker.ts +import { registerTask, startWorker } from "@apache-airflow/ts-sdk"; + +registerTask("hello_typescript", async ({ ctx }) => { + console.log(`running ${ctx.dagId}/${ctx.taskId}`, { + runId: ctx.runId, + tryNumber: ctx.tryNumber, + }); + return { ok: true }; +}); + +await startWorker({ + queues: ["ts-tasks"], + // baseUrl + secret default to env: AIRFLOW__EDGE__API_URL, AIRFLOW__API_AUTH__JWT_SECRET +}); +``` + +Run the worker: + +```bash +node worker.ts # Node 23.6+ (default) +# or: +npx tsx worker.ts # any Node 22+ +``` + +The worker registers with the Edge API, polls for jobs on `ts-tasks`, +runs the registered handler, and reports success/failure back. + +## Testing + +See [`TESTING.md`](TESTING.md) for the full end-to-end recipe (breeze +setup, reference workers, scenarios). diff --git a/ts-sdk/TESTING.md b/ts-sdk/TESTING.md new file mode 100644 index 0000000000000..995c41afd1324 --- /dev/null +++ b/ts-sdk/TESTING.md @@ -0,0 +1,193 @@ + + +# Testing the TypeScript SDK + +## Unit tests + +```bash +pnpm install +pnpm test # vitest, 69 tests, no network +pnpm run typecheck # strict TS check +``` + +## End-to-end against a live Airflow + +Drives a real Node worker against a real Airflow with the Edge Executor. +Use this when you change anything that touches the wire protocol. + +You need an Airflow already running with `EdgeExecutor` configured. If +you don't have one, jump to **[Setting up a local Airflow with +breeze](#setting-up-a-local-airflow-with-breeze)** first, then come +back here. + +### 1. Configure the worker + +Set the required env vars (and optionally override the defaults): + +```bash +# Required: +export AIRFLOW__API_AUTH__JWT_SECRET="" +export AIRFLOW__EDGE__API_URL="http://:/edge_worker/v1" + +# Optional — defaults shown: +# export AIRFLOW__API_AUTH__JWT_ISSUER="airflow" +# export AIRFLOW__EDGE__AIRFLOW_VERSION="3.3.0" +# export AIRFLOW__EDGE__PROVIDER_VERSION="3.5.0" +``` + +The optional vars only matter if your Airflow customizes +`[api_auth] jwt_issuer` or runs a different version than the defaults. + +### 2. Build and run the worker + +```bash +cd ts-sdk +pnpm install && pnpm run build +npx tsx integration/workers/manual-test-worker.ts +``` + +You should see: + +``` +[worker] -- registered on queues=core-integration-test,core-failure-test,core-sigterm-test,core-missing-test +``` + +### 3. Deploy the test DAGs + +Copy them into your Airflow's `dags/` folder: + +```bash +cp ts-sdk/integration/core_*.py / +``` + +### 4. Trigger and verify + +Easiest path: open the Airflow UI, find each DAG, unpause, hit ▶, +watch the task box turn green or red. + +| DAG | Task | Expected | What it proves | +|---|---|---|---| +| `core_integration` | `core_smoke` | 🟢 success | Happy path: register, /run + token swap, handler, markSuccess | +| `core_failure` | `core_fail` | 🔴 failed | Handler throws → markFailed propagates | +| `core_missing_handler` | `core_unknown` | 🔴 failed | No handler registered → clean failure log | +| `core_sigterm_inflight` | `core_slow` | 🟢 success after 2 min | SIGTERM during handler → worker drains by waiting, never kills the task | + +For `core_sigterm_inflight`: trigger the DAG, wait until the worker +prints `[core_slow] sleeping 2 min`, then `Ctrl+C` the worker. The +handler runs to completion before the worker exits. + +### Pure-JS consumer canary + +Proves the published-package experience for users who don't write +TypeScript. Imports the built `dist/` directly with stock `node`: + +```bash +pnpm run build +node integration/workers/js-consumer.mjs +``` + +Triggers the same `core_integration` DAG. + +## Setting up a local Airflow with breeze + +If you don't already have an Airflow stack to test against, breeze +gives you one in ~5 min. Everything below is copy-paste from the host. + +### Start breeze + +```bash +breeze start-airflow --backend postgres --executor edgeexecutor +``` + +First run takes ~5 min to build images. The Airflow UI lands on +**http://localhost:28080** (login: `admin` / `admin`). Breeze maps +container port 8080 → host 28080. + +### Set a reusable shell variable + +```bash +export CONTAINER=$(docker ps --format '{{.Names}}' | grep breeze-airflow-run) +``` + +Verify the breeze config: + +```bash +docker exec "$CONTAINER" airflow config get-value core executor +# expect: airflow.providers.edge3.executors.edge_executor.EdgeExecutor + +docker exec "$CONTAINER" airflow config get-value api_auth jwt_issuer +# expect: airflow +``` + +### Get the env vars for the worker + +Plug these into step 1 of the E2E test: + +```bash +export AIRFLOW__API_AUTH__JWT_SECRET=$(docker exec "$CONTAINER" airflow config get-value api_auth jwt_secret 2>/dev/null | tail -1) +export AIRFLOW__EDGE__API_URL="http://localhost:28080/edge_worker/v1" +``` + +### Deploy the test DAGs + +Breeze mounts `files/dags/` from the repo root into the container: + +```bash +cp ts-sdk/integration/core_*.py files/dags/ +``` + +Confirm the scheduler picked them up: + +```bash +docker exec "$CONTAINER" airflow dags list | grep core_ +``` + +### Trigger from the host (instead of the UI) + +```bash +# Unpause all four DAGs once: +for d in core_integration core_failure core_missing_handler core_sigterm_inflight; do + docker exec "$CONTAINER" airflow dags unpause "$d" +done + +# Trigger one: +docker exec "$CONTAINER" airflow dags trigger core_integration + +# Check the latest run state: +docker exec "$CONTAINER" airflow dags list-runs core_integration | head -3 +``` + +### Cleanup + +```bash +# Stop the SDK worker: Ctrl+C in the worker terminal +# Stop breeze: +breeze down +``` + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `ECONNREFUSED 127.0.0.1:8080` | breeze maps to host port **28080**, not 8080. | +| `403 Invalid issuer` | Set `AIRFLOW__API_AUTH__JWT_ISSUER` to match the server's `[api_auth] jwt_issuer`. SDK defaults to `"airflow"`. | +| `403 ... workload not allowed` | Stale build of this SDK. Pull latest, rebuild. | +| `400 Edge Worker runs on Airflow X.X.X and the core runs on Y.Y.Y` | Set `AIRFLOW__EDGE__AIRFLOW_VERSION` and `AIRFLOW__EDGE__PROVIDER_VERSION` to match the running Airflow. | +| Worker registers but never picks up jobs | DAG's `queue=` must match one of the worker's `queues: [...]` entries exactly. | diff --git a/ts-sdk/integration/core_failure.py b/ts-sdk/integration/core_failure.py new file mode 100644 index 0000000000000..3e4b86bc87304 --- /dev/null +++ b/ts-sdk/integration/core_failure.py @@ -0,0 +1,40 @@ +# 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. + +"""Integration DAG — handler-throws scenario. Task should end in state=failed.""" + +from __future__ import annotations + +import pendulum + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +with DAG( + dag_id="core_failure", + start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + tags=["ts-sdk-core", "integration"], + description="Integration-test DAG — handler throws, expect state=failed.", +) as dag: + core_fail = BashOperator( + task_id="core_fail", + queue="core-failure-test", + bash_command='echo "placeholder"', + retries=0, + ) diff --git a/ts-sdk/integration/core_integration.py b/ts-sdk/integration/core_integration.py new file mode 100644 index 0000000000000..2c44a0fafa10b --- /dev/null +++ b/ts-sdk/integration/core_integration.py @@ -0,0 +1,53 @@ +# 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. + +""" +Integration-test DAG for @apache-airflow/ts-sdk (PR #1 minimal-worker scope). + +Exposes a single-task DAG on queue "core-integration-test" so the core +package's integration test can: + 1. Register a handler for task_id "core_smoke" + 2. Start a worker on queue "core-integration-test" + 3. Trigger this DAG + 4. Verify the task reaches state=success + +The task body is a BashOperator purely so the DAG is authoring-valid on +the Python side. EdgeExecutor dispatches based on queue name; the actual +work is done by whatever handler the TS worker has registered for the +matching task_id. +""" + +from __future__ import annotations + +import pendulum + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +with DAG( + dag_id="core_integration", + start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + tags=["ts-sdk-core", "integration"], + description="Integration-test DAG for @apache-airflow/ts-sdk minimal worker.", +) as dag: + core_smoke = BashOperator( + task_id="core_smoke", + queue="core-integration-test", + bash_command='echo "placeholder — real handler runs on TS worker"', + ) diff --git a/ts-sdk/integration/core_missing_handler.py b/ts-sdk/integration/core_missing_handler.py new file mode 100644 index 0000000000000..3b4ab459dca57 --- /dev/null +++ b/ts-sdk/integration/core_missing_handler.py @@ -0,0 +1,40 @@ +# 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. + +"""Integration DAG — missing-handler scenario. Worker has no handler registered for this task_id.""" + +from __future__ import annotations + +import pendulum + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +with DAG( + dag_id="core_missing_handler", + start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + tags=["ts-sdk-core", "integration"], + description="Integration DAG — task_id has no handler registered; expect state=failed.", +) as dag: + core_unknown = BashOperator( + task_id="core_unknown", + queue="core-missing-test", + bash_command='echo "placeholder"', + retries=0, + ) diff --git a/ts-sdk/integration/core_sigterm_inflight.py b/ts-sdk/integration/core_sigterm_inflight.py new file mode 100644 index 0000000000000..e3993e718003a --- /dev/null +++ b/ts-sdk/integration/core_sigterm_inflight.py @@ -0,0 +1,40 @@ +# 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. + +"""Integration DAG — SIGTERM during in-flight task. Handler sleeps long; worker receives SIGTERM mid-handler.""" + +from __future__ import annotations + +import pendulum + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +with DAG( + dag_id="core_sigterm_inflight", + start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + tags=["ts-sdk-core", "integration"], + description="Integration-test DAG — SIGTERM during long handler run.", +) as dag: + core_slow = BashOperator( + task_id="core_slow", + queue="core-sigterm-test", + bash_command='echo "placeholder"', + retries=0, + ) diff --git a/ts-sdk/integration/workers/failure-worker.ts b/ts-sdk/integration/workers/failure-worker.ts new file mode 100644 index 0000000000000..44332e5201c78 --- /dev/null +++ b/ts-sdk/integration/workers/failure-worker.ts @@ -0,0 +1,47 @@ +/*! + * 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. + */ + +/** + * Failure-path worker — handler throws on every call. Pair with the + * `core_failure` DAG; expect TI state=failed. + * + * Run: + * npx tsx integration/workers/failure-worker.ts + * + * See ../../TESTING.md for setup. + */ +import { registerTask, startWorker } from "../../src/index.js"; + +const BASE_URL = process.env.AIRFLOW__EDGE__API_URL + ? process.env.AIRFLOW__EDGE__API_URL.replace(/\/edge_worker\/v1.*$/, "") + : "http://localhost:8080"; +const SECRET = process.env.AIRFLOW__API_AUTH__JWT_SECRET ?? "dev-jwt-secret-not-for-prod"; + +registerTask("core_fail", async ({ ctx }) => { + console.log(`[handler] core_fail invoked ti=${ctx.taskInstanceId}`); + throw new Error("intentional failure for integration test"); +}); + +await startWorker({ + baseUrl: BASE_URL, + secret: SECRET, + queues: ["core-failure-test"], + pollIntervalInMs: 2000, + heartbeatIntervalInMs: 10000, +}); diff --git a/ts-sdk/integration/workers/js-consumer.mjs b/ts-sdk/integration/workers/js-consumer.mjs new file mode 100644 index 0000000000000..48964b842b97b --- /dev/null +++ b/ts-sdk/integration/workers/js-consumer.mjs @@ -0,0 +1,59 @@ +/*! + * 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. + */ + +// Pure-JavaScript consumer of @apache-airflow/ts-sdk. +// +// No TypeScript, no tsx, no transpile step at runtime. Imports the built +// dist/ output directly — proves the "plain-JS users consume the compiled +// output and skip the types" claim. +// +// Run: +// pnpm run build # produce dist/ +// node integration/workers/js-consumer.mjs + +import { registerTask, startWorker } from "../../dist/index.js"; + +registerTask("core_smoke", async ({ ctx, job }) => { + console.log("[js-handler] core_smoke invoked", { + dagId: ctx.dagId, + taskId: ctx.taskId, + runId: ctx.runId, + tryNumber: ctx.tryNumber, + mapIndex: ctx.mapIndex, + taskInstanceId: ctx.taskInstanceId, + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + console.log( + `[js-handler] core_smoke done (token length=${job.command.token.length})`, + ); + return { ok: true }; +}); + +const BASE_URL = process.env.AIRFLOW__EDGE__API_URL + ? process.env.AIRFLOW__EDGE__API_URL.replace(/\/edge_worker\/v1.*$/, "") + : "http://localhost:8080"; +const SECRET = process.env.AIRFLOW__API_AUTH__JWT_SECRET ?? "dev-jwt-secret-not-for-prod"; + +await startWorker({ + baseUrl: BASE_URL, + secret: SECRET, + queues: ["core-integration-test"], + pollIntervalInMs: 2000, + heartbeatIntervalInMs: 10000, +}); diff --git a/ts-sdk/integration/workers/manual-test-worker.ts b/ts-sdk/integration/workers/manual-test-worker.ts new file mode 100644 index 0000000000000..e05d52005a323 --- /dev/null +++ b/ts-sdk/integration/workers/manual-test-worker.ts @@ -0,0 +1,88 @@ +/*! + * 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. + */ + +/** + * Reference worker for manual end-to-end testing against a running Airflow. + * Registers handlers for several test task_ids and listens on all the + * corresponding queues, so a single worker process can service every + * integration DAG in `integration/core_*.py`. + * + * Run: + * npx tsx integration/workers/manual-test-worker.ts + * + * Trigger from the Airflow side: + * airflow dags trigger core_integration → expect success + * airflow dags trigger core_failure → expect failed + * airflow dags trigger core_missing_handler → expect failed (no handler registered) + * airflow dags trigger core_sigterm_inflight → start, then Ctrl+C the worker + * mid-task; expect success + * once the handler completes + * + * See ../../TESTING.md for the full setup recipe (breeze, env vars, etc.). + */ + +import { registerTask, startWorker } from "../../src/index.js"; + +const BASE_URL = process.env.AIRFLOW__EDGE__API_URL + ? process.env.AIRFLOW__EDGE__API_URL.replace(/\/edge_worker\/v1.*$/, "") + : "http://localhost:8080"; +const SECRET = process.env.AIRFLOW__API_AUTH__JWT_SECRET ?? "dev-jwt-secret-not-for-prod"; + +// Happy path. +registerTask("core_smoke", async ({ ctx }) => { + console.log(`[core_smoke] invoked dag=${ctx.dagId} run=${ctx.runId}`); + await new Promise((r) => setTimeout(r, 500)); + console.log(`[core_smoke] done`); + return { ok: true }; +}); + +// Failure path — handler throws, worker reports state=failed. +registerTask("core_fail", async ({ ctx }) => { + console.log(`[core_fail] invoked dag=${ctx.dagId} run=${ctx.runId}`); + throw new Error("intentional failure for manual test"); +}); + +// Graceful shutdown — handler runs for 2 minutes, ignoring abort signal. +// Ctrl+C the worker while this is running to confirm the worker drains by +// waiting for the in-flight task rather than killing it. +registerTask("core_slow", async ({ ctx }) => { + console.log(`[core_slow] sleeping 2 min — Ctrl+C the worker mid-sleep to test drain`); + const startedAt = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 120_000)); + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + console.log(`[core_slow] done after ${elapsed}s (signal aborted=${ctx.signal?.aborted})`); + return { ok: true }; +}); + +// Note: NO handler registered for "core_unknown". The core_missing_handler +// DAG dispatches a task with that id; the worker should mark it failed with +// a "no handler registered" log line. + +await startWorker({ + baseUrl: BASE_URL, + secret: SECRET, + queues: [ + "core-integration-test", // core_smoke + "core-failure-test", // core_fail + "core-sigterm-test", // core_slow + "core-missing-test", // core_unknown (no handler) + ], + pollIntervalInMs: 2000, + heartbeatIntervalInMs: 10000, +}); diff --git a/ts-sdk/integration/workers/sigterm-worker.ts b/ts-sdk/integration/workers/sigterm-worker.ts new file mode 100644 index 0000000000000..702391e446af7 --- /dev/null +++ b/ts-sdk/integration/workers/sigterm-worker.ts @@ -0,0 +1,65 @@ +/*! + * 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. + */ + +/** + * SIGTERM-during-handler worker. Handler sleeps ~20s while honoring + * AbortSignal. SIGTERM during the handler propagates to ctx.signal, aborts + * the sleep, and the handler throws. Pair with the `core_sigterm_inflight` + * DAG; expected final TI state=failed (because handler threw). + * + * Run: + * npx tsx integration/workers/sigterm-worker.ts + * + * See ../../TESTING.md for setup. + */ +import { registerTask, startWorker } from "../../src/index.js"; + +const BASE_URL = process.env.AIRFLOW__EDGE__API_URL + ? process.env.AIRFLOW__EDGE__API_URL.replace(/\/edge_worker\/v1.*$/, "") + : "http://localhost:8080"; +const SECRET = process.env.AIRFLOW__API_AUTH__JWT_SECRET ?? "dev-jwt-secret-not-for-prod"; + +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) return reject(signal.reason ?? new Error("aborted")); + const t = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(t); + reject(signal.reason ?? new Error("aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +registerTask("core_slow", async ({ ctx }) => { + console.log(`[handler] core_slow starting (sleeping 20s) ti=${ctx.taskInstanceId}`); + await abortableSleep(20_000, ctx.signal); + console.log(`[handler] core_slow completed normally`); +}); + +await startWorker({ + baseUrl: BASE_URL, + secret: SECRET, + queues: ["core-sigterm-test"], + pollIntervalInMs: 1000, + heartbeatIntervalInMs: 10000, +}); diff --git a/ts-sdk/integration/workers/smoke-worker.ts b/ts-sdk/integration/workers/smoke-worker.ts new file mode 100644 index 0000000000000..39f21824deb33 --- /dev/null +++ b/ts-sdk/integration/workers/smoke-worker.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. + */ + +/** + * Smoke-test worker — registers a single handler for task_id "core_smoke" + * on queue "core-integration-test". Pair with the `core_integration` DAG. + * + * Run: + * npx tsx integration/workers/smoke-worker.ts + * + * For a multi-scenario worker that exercises success/failure/drain in one + * process, see `manual-test-worker.ts`. See ../../TESTING.md for setup. + */ + +import { registerTask, startWorker } from "../../src/index.js"; + +const BASE_URL = process.env.AIRFLOW__EDGE__API_URL + ? process.env.AIRFLOW__EDGE__API_URL.replace(/\/edge_worker\/v1.*$/, "") + : "http://localhost:8080"; +const SECRET = process.env.AIRFLOW__API_AUTH__JWT_SECRET ?? "dev-jwt-secret-not-for-prod"; + +registerTask("core_smoke", async ({ ctx, job }) => { + console.log(`[handler] core_smoke invoked`, { + dagId: ctx.dagId, + taskId: ctx.taskId, + runId: ctx.runId, + tryNumber: ctx.tryNumber, + mapIndex: ctx.mapIndex, + taskInstanceId: ctx.taskInstanceId, + }); + // Simulate some work; nothing real, but prove AbortSignal is wired. + await new Promise((resolve) => setTimeout(resolve, 500)); + console.log(`[handler] core_smoke done (job.command.token length=${ + (job as { command: { token: string } }).command.token.length + })`); + return { ok: true }; +}); + +await startWorker({ + baseUrl: BASE_URL, + secret: SECRET, + queues: ["core-integration-test"], + pollIntervalInMs: 2000, + heartbeatIntervalInMs: 10000, +}); diff --git a/ts-sdk/package.json b/ts-sdk/package.json new file mode 100644 index 0000000000000..6f97b3b9bf00e --- /dev/null +++ b/ts-sdk/package.json @@ -0,0 +1,62 @@ +{ + "name": "@apache-airflow/ts-sdk", + "version": "0.1.0-alpha.0", + "packageManager": "pnpm@10.28.1", + "description": "TypeScript Task SDK for Apache Airflow — run Airflow tasks in Node via the Edge Executor (AIP-72 / AIP-69)", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://airflow.apache.org", + "repository": { + "type": "git", + "url": "https://github.com/apache/airflow.git", + "directory": "ts-sdk" + }, + "bugs": { + "url": "https://github.com/apache/airflow/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE", + "NOTICE" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "build": "tsc -p tsconfig.build.json", + "generate:edge": "openapi-typescript ../airflow-core/docs/_api/edge-api.yaml -o src/generated/edge.ts", + "generate:execution": "openapi-typescript ../airflow-core/docs/_api/execution-api.json -o src/generated/execution.ts", + "generate:types": "npm run generate:edge && npm run generate:execution" + }, + "keywords": [ + "airflow", + "apache-airflow", + "task-sdk", + "edge-executor", + "aip-72", + "aip-69" + ], + "engines": { + "node": ">=22" + }, + "dependencies": { + "openapi-fetch": "^0.17.0" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "openapi-typescript": "^7.9.0", + "tsx": "^4.21.0", + "typescript": "^6.0.2", + "vitest": "^4.1.4" + } +} diff --git a/ts-sdk/pnpm-lock.yaml b/ts-sdk/pnpm-lock.yaml new file mode 100644 index 0000000000000..5deb48fb04f0e --- /dev/null +++ b/ts-sdk/pnpm-lock.yaml @@ -0,0 +1,1315 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.19.17 + openapi-typescript: + specifier: ^7.9.0 + version: 7.13.0(typescript@6.0.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vitest: + specifier: ^4.1.4 + version: 4.1.5(@types/node@22.19.17)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.13': + resolution: {integrity: sha512-4Tm4ysZkexx6ZTX7knqSZTqPlNgIvXc7Ha0pd30I694/GD0KtJE2xrElycfPds0vCLFAqoKyIzBtOF1xrLo8KA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.13(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + chai@6.2.2: {} + + change-case@5.4.4: {} + + colorette@1.4.0: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + index-to-position@1.2.0: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.13(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pluralize@8.0.0: {} + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@4.41.0: {} + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + uri-js-replace@1.0.1: {} + + vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + esbuild: 0.27.7 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.1.5(@types/node@22.19.17)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} diff --git a/ts-sdk/src/edge-client.ts b/ts-sdk/src/edge-client.ts new file mode 100644 index 0000000000000..9f35fc87a8930 --- /dev/null +++ b/ts-sdk/src/edge-client.ts @@ -0,0 +1,215 @@ +/*! + * 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. + */ + +// Edge Worker API client. Wraps openapi-fetch with HS512 JWT signing +// per request. Authorization header is RAW JWT (no Bearer prefix) — +// Edge API uses FastAPI's raw Header dependency. + +import createClient, { type Middleware } from "openapi-fetch"; +import type { paths, components } from "./generated/edge.js"; +import { EdgeApiError } from "./errors.js"; +import { signEdgeJwt, pathToEdgeMethod } from "./utils/jwt.js"; +import { withRetry, type RetryConfig } from "./utils/retry.js"; + +const EDGE_PREFIX = "/edge_worker/v1"; +type EDGE_PREFIX = typeof EDGE_PREFIX; + +// Makes the `authorization` header optional on every operation; the JWT +// middleware sets it, so callers shouldn't have to. +type DropAuthHeaderFromOp = Op extends { parameters: infer P } + ? Omit & { + parameters: Omit & { header?: { authorization?: string } }; + } + : Op; + +type DropAuthHeader = { [M in keyof Path]: DropAuthHeaderFromOp }; + +// Narrows the paths map to worker endpoints only and strips the +// `/edge_worker/v1` prefix from each key; the runtime baseUrl supplies it. +type StrippedPaths = { + [P in keyof paths as P extends `${EDGE_PREFIX}${infer Rest}` ? Rest : never]: DropAuthHeader< + paths[P] + >; +}; + +export type EdgeWorkerState = components["schemas"]["EdgeWorkerState"]; +export type WorkerStateBody = components["schemas"]["WorkerStateBody"]; +export type WorkerSetStateReturn = components["schemas"]["WorkerSetStateReturn"]; +export type EdgeJobFetched = components["schemas"]["EdgeJobFetched"]; +export type WorkerQueuesBody = components["schemas"]["WorkerQueuesBody"]; + +/** Worker lifecycle states matching `WorkerStateBody.state`. Use these + * constants instead of string literals at call sites — `satisfies` anchors + * every value to the spec union, so a typo fails to compile. */ +export const WORKER_STATE = { + STARTING: "starting", + RUNNING: "running", + IDLE: "idle", + SHUTDOWN_REQUEST: "shutdown request", + TERMINATING: "terminating", + OFFLINE: "offline", +} as const satisfies Record; + +/** Terminal states accepted by `reportJobState`. */ +export const JOB_STATE = { + SUCCESS: "success", + FAILED: "failed", +} as const; +export type JobState = (typeof JOB_STATE)[keyof typeof JOB_STATE]; + +export interface ReportJobStateArgs { + dagId: string; + taskId: string; + runId: string; + tryNumber: number; + mapIndex: number; + state: JobState; +} + + +export interface EdgeClientConfig { + baseUrl: string; + secret: string; + workerName: string; + fetchImpl?: typeof fetch; + retry?: RetryConfig; + /** Default 30. */ + jwtTtlSeconds?: number; + /** Per-request timeout. Default 120 000 (2 min). */ + requestTimeoutMs?: number; + /** JWT `iss` claim. Must match the server's `[api_auth] jwt_issuer` + * config. Required: callers should resolve via + * `worker-options.resolveWorkerOptions`, which applies the project-wide + * default ("airflow"). */ + jwtIssuer: string; +} + +export interface EdgeClient { + readonly workerName: string; + register(body: WorkerStateBody): Promise; + /** Response may carry `state="shutdown request"` signaling server-driven drain. */ + updateState(body: WorkerStateBody): Promise; + /** Returns null when no job is available. */ + fetchJob(body: WorkerQueuesBody): Promise; + reportJobState(args: ReportJobStateArgs): Promise; +} + +export function makeEdgeClient(cfg: EdgeClientConfig): EdgeClient { + const baseUrl = cfg.baseUrl.replace(/\/$/, ""); + const jwtTtl = cfg.jwtTtlSeconds ?? 30; + const requestTimeoutMs = cfg.requestTimeoutMs ?? 120_000; + + const jwtMiddleware: Middleware = { + async onRequest({ request }) { + const jwt = signEdgeJwt(cfg.secret, { + method: pathToEdgeMethod(new URL(request.url).pathname), + issuer: cfg.jwtIssuer, + ttlSeconds: jwtTtl, + }); + request.headers.set("Authorization", jwt); + return request; + }, + }; + + const baseFetch = cfg.fetchImpl ?? fetch; + const timedFetch: typeof fetch = (input, init) => + baseFetch(input, { ...init, signal: AbortSignal.timeout(requestTimeoutMs) }); + + const client = createClient({ + baseUrl: `${baseUrl}${EDGE_PREFIX}`, + fetch: timedFetch, + }); + client.use(jwtMiddleware); + + /** Run an openapi-fetch call under the configured retry policy. Converts + * the `{data, error}` shape into a thrown `EdgeApiError` on failure. */ + function call( + method: string, + path: string, + fn: () => Promise<{ data?: T; error?: unknown; response: Response }>, + ): Promise> { + return withRetry(async () => { + const result = await fn(); + if (result.error !== undefined) { + const body = + typeof result.error === "string" + ? result.error + : JSON.stringify(result.error); + throw new EdgeApiError(method, path, result.response.status, body); + } + return result.data as NonNullable; + }, cfg.retry); + } + + return { + workerName: cfg.workerName, + + async register(body) { + await call("POST", "/worker/{worker_name}", () => + client.POST("/worker/{worker_name}", { + params: { path: { worker_name: cfg.workerName } }, + body, + }), + ); + }, + + async updateState(body) { + return call("PATCH", "/worker/{worker_name}", () => + client.PATCH("/worker/{worker_name}", { + params: { path: { worker_name: cfg.workerName } }, + body, + }), + ); + }, + + async fetchJob(body) { + const data = await call("POST", "/jobs/fetch/{worker_name}", () => + client.POST("/jobs/fetch/{worker_name}", { + params: { path: { worker_name: cfg.workerName } }, + body, + }), + ); + // Server returns {} / null / undefined when no job is available. + if (!data || typeof data !== "object" || Object.keys(data).length === 0) { + return null; + } + return data; + }, + + async reportJobState(args) { + await call("PATCH", "/jobs/state/.../{state}", () => + client.PATCH( + "/jobs/state/{dag_id}/{task_id}/{run_id}/{try_number}/{map_index}/{state}", + { + params: { + path: { + dag_id: args.dagId, + task_id: args.taskId, + run_id: args.runId, + try_number: args.tryNumber, + map_index: args.mapIndex, + state: args.state, + }, + }, + }, + ), + ); + }, + }; +} diff --git a/ts-sdk/src/errors.ts b/ts-sdk/src/errors.ts new file mode 100644 index 0000000000000..ac684b7647ecc --- /dev/null +++ b/ts-sdk/src/errors.ts @@ -0,0 +1,99 @@ +/*! + * 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. + */ + +// Typed error classes for the two Airflow APIs. Distinguish transient +// (5xx, network) from permanent (4xx) so retry logic can fail fast. + +/** Coerce an `unknown` catch-binding into a readable log string. JSON.stringify + * returns undefined for undefined and throws on circular refs, so the explicit + * branches keep the return type honestly `string`. */ +export function formatError(err: unknown): string { + if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`; + if (typeof err === "string") return err; + if (err === undefined) return "undefined"; + try { + return JSON.stringify(err) ?? String(err); + } catch { + return String(err); + } +} + +/** Error from the Edge Worker API (/edge_worker/v1/*). */ +export class EdgeApiError extends Error { + readonly method: string; + readonly path: string; + readonly status: number; + readonly body: string; + + constructor(method: string, path: string, status: number, body: string) { + super(`Edge API ${method} ${path} → ${status}: ${body.slice(0, 200)}`); + this.name = "EdgeApiError"; + this.method = method; + this.path = path; + this.status = status; + this.body = body; + } + + /** Transient errors (5xx, 0/unknown, network) should be retried. */ + get isTransient(): boolean { + return this.status >= 500 || this.status === 0; + } + + /** 404 — the requested resource does not exist. */ + get isNotFound(): boolean { + return this.status === 404; + } +} + +/** Error from the Execution API (/execution/*). */ +export class ExecutionApiError extends Error { + readonly method: string; + readonly path: string; + readonly status: number; + readonly body: string; + + constructor(method: string, path: string, status: number, body: string) { + super(`Execution API ${method} ${path} → ${status}: ${body.slice(0, 200)}`); + this.name = "ExecutionApiError"; + this.method = method; + this.path = path; + this.status = status; + this.body = body; + } + + get isTransient(): boolean { + return this.status >= 500 || this.status === 0; + } + + /** 409 "invalid_state" — TI is already terminal; our transition is a no-op. */ + get isInvalidState(): boolean { + return this.status === 409 && this.body.includes("invalid_state"); + } + + // TODO(pr2): wire token refresh into client (server-driven via response header + self-sign fallback). + /** 403 with "Signature has expired" — the per-task JWT (10-min TTL) aged out. */ + get isTokenExpired(): boolean { + return this.status === 403 && this.body.includes("Signature has expired"); + } + + /** 404 — the requested resource does not exist. */ + get isNotFound(): boolean { + return this.status === 404; + } +} diff --git a/ts-sdk/src/execution-client.ts b/ts-sdk/src/execution-client.ts new file mode 100644 index 0000000000000..31347571521ed --- /dev/null +++ b/ts-sdk/src/execution-client.ts @@ -0,0 +1,181 @@ +/*! + * 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. + */ + +// Execution API client. Auth uses "Bearer " (token is server-issued +// in the job payload at command.token). Contrast with Edge API's raw JWT. +// +// Two-token lifecycle (Airflow PR #60108, merged 2026-04-23): +// - command.token is "workload"-scoped and only accepted on /run. +// - /run swaps it for an "execution"-scoped token returned in the +// `Refreshed-API-Token` response header. +// - Subsequent calls (markSuccess, markFailed, XCom, etc.) require the +// execution token. The same header is also used by the server for +// near-expiry refresh on any endpoint. +// The bearerMiddleware below captures the header on every response and updates +// `currentToken` so the next request uses it. +// +// TODO(pr2): per-TI heartbeat. +// TODO(pr3): getVariable, getConnection, XCom push/pull. + +import createClient, { type Middleware } from "openapi-fetch"; +import type { paths } from "./generated/execution.js"; +import { ExecutionApiError } from "./errors.js"; +import { withRetry, type RetryConfig } from "./utils/retry.js"; + +/** TI lifecycle states written on the wire. */ +export const TI_STATE = { + RUNNING: "running", + SUCCESS: "success", + FAILED: "failed", +} as const; + +export interface EnterRunningArgs { + hostname: string; + unixname: string; + pid: number; + start_date: string; +} +export interface MarkSuccessArgs { + end_date: string; +} +export interface MarkFailedArgs { + end_date: string; +} + +export interface ExecutionClientConfig { + /** Base URL of the Airflow api-server. We append `/execution`. */ + baseUrl: string; + /** Task instance UUID from the job payload. */ + taskInstanceId: string; + /** Server-issued bearer token (from `job.command.token`). */ + token: string; + fetchImpl?: typeof fetch; + retry?: RetryConfig; + /** API version header. Default "2025-08-10" (matches Airflow 3.2+). */ + apiVersion?: string; + /** Per-request timeout. Default 120 000 (2 min). See decisions/01. */ + requestTimeoutMs?: number; +} + +export interface ExecutionClient { + readonly taskInstanceId: string; + /** Transition queued → running. */ + enterRunning(body: EnterRunningArgs): Promise; + markSuccess(body: MarkSuccessArgs): Promise; + markFailed(body: MarkFailedArgs): Promise; +} + +export function makeExecutionClient(cfg: ExecutionClientConfig): ExecutionClient { + const baseUrl = cfg.baseUrl.replace(/\/$/, ""); + const apiVersion = cfg.apiVersion ?? "2025-08-10"; + const requestTimeoutMs = cfg.requestTimeoutMs ?? 120_000; + + // Mutable: the server reissues via `Refreshed-API-Token` (workload→execution + // swap on /run, near-expiry refresh on any endpoint). + let currentToken = cfg.token; + + const bearerMiddleware: Middleware = { + async onRequest({ request }) { + request.headers.set("Authorization", `Bearer ${currentToken}`); + request.headers.set("Airflow-API-Version", apiVersion); + return request; + }, + async onResponse({ response }) { + const refreshed = response.headers.get("Refreshed-API-Token"); + if (refreshed) currentToken = refreshed; + return response; + }, + }; + + const baseFetch = cfg.fetchImpl ?? fetch; + const timedFetch: typeof fetch = (input, init) => + baseFetch(input, { ...init, signal: AbortSignal.timeout(requestTimeoutMs) }); + + const client = createClient({ + baseUrl: `${baseUrl}/execution`, + fetch: timedFetch, + }); + client.use(bearerMiddleware); + + /** Run an openapi-fetch call under the configured retry policy. Converts + * the `{data, error}` shape into a thrown `ExecutionApiError` on failure. + * Returns `data` only (errors throw, callers don't have to discriminate). */ + function call( + method: string, + path: string, + fn: () => Promise<{ data?: T; error?: unknown; response: Response }>, + ): Promise> { + return withRetry(async () => { + const result = await fn(); + if (result.error !== undefined) { + const body = + typeof result.error === "string" + ? result.error + : JSON.stringify(result.error); + throw new ExecutionApiError(method, path, result.response.status, body); + } + return result.data as NonNullable; + }, cfg.retry); + } + + return { + taskInstanceId: cfg.taskInstanceId, + + async enterRunning(body) { + await call("PATCH", "/task-instances/{id}/run", () => + client.PATCH("/task-instances/{task_instance_id}/run", { + params: { path: { task_instance_id: cfg.taskInstanceId } }, + body: { + state: TI_STATE.RUNNING, + hostname: body.hostname, + unixname: body.unixname, + pid: body.pid, + start_date: body.start_date, + }, + }), + ); + }, + + async markSuccess(body) { + await call("PATCH", "/task-instances/{id}/state", () => + client.PATCH("/task-instances/{task_instance_id}/state", { + params: { path: { task_instance_id: cfg.taskInstanceId } }, + body: { + state: TI_STATE.SUCCESS, + end_date: body.end_date, + task_outlets: [], + outlet_events: [], + }, + }), + ); + }, + + async markFailed(body) { + await call("PATCH", "/task-instances/{id}/state", () => + client.PATCH("/task-instances/{task_instance_id}/state", { + params: { path: { task_instance_id: cfg.taskInstanceId } }, + body: { + state: TI_STATE.FAILED, + end_date: body.end_date, + }, + }), + ); + }, + }; +} diff --git a/ts-sdk/src/generated/edge.ts b/ts-sdk/src/generated/edge.ts new file mode 100644 index 0000000000000..c4471b0e32098 --- /dev/null +++ b/ts-sdk/src/generated/edge.ts @@ -0,0 +1,1556 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/edge_worker/v1/jobs/fetch/{worker_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch + * @description Fetch a job to execute on the edge worker. + */ + post: operations["fetch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/v1/jobs/state/{dag_id}/{task_id}/{run_id}/{try_number}/{map_index}/{state}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * State + * @description Update the state of a job running on the edge worker. + */ + patch: operations["state"]; + trace?: never; + }; + "/edge_worker/v1/logs/logfile_path/{dag_id}/{task_id}/{run_id}/{try_number}/{map_index}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Logfile Path + * @description Elaborate the path and filename to expect from task execution. + */ + get: operations["logfile_path"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/v1/logs/push/{dag_id}/{task_id}/{run_id}/{try_number}/{map_index}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Push Logs + * @description Push an incremental log chunk from Edge Worker to central site. + */ + post: operations["push_logs"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/v1/worker/{worker_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register + * @description Register a new worker to the backend. + */ + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + /** + * Set State + * @description Set state of worker and returns the current assigned queues. + */ + patch: operations["set_state"]; + trace?: never; + }; + "/edge_worker/v1/worker/queues/{worker_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Queues */ + patch: operations["update_queues"]; + trace?: never; + }; + "/edge_worker/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health + * @description Report API Health. + */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/worker": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Worker + * @description Return Edge Workers. + */ + get: operations["worker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/jobs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Jobs + * @description Return Edge Jobs. + */ + get: operations["jobs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/worker/{worker_name}/maintenance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request Worker Maintenance + * @description Put a worker into maintenance mode. + */ + post: operations["request_worker_maintenance"]; + /** + * Exit Worker Maintenance + * @description Exit a worker from maintenance mode. + */ + delete: operations["exit_worker_maintenance"]; + options?: never; + head?: never; + /** + * Update Worker Maintenance + * @description Update maintenance comments for a worker. + */ + patch: operations["update_worker_maintenance"]; + trace?: never; + }; + "/edge_worker/ui/worker/{worker_name}/shutdown": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request Worker Shutdown + * @description Request shutdown of a worker. + */ + post: operations["request_worker_shutdown"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/worker/{worker_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Worker + * @description Delete a worker record from the system. + */ + delete: operations["delete_worker"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/worker/{worker_name}/queues/{queue_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Add Worker Queue + * @description Add a queue to a worker. + */ + put: operations["add_worker_queue"]; + post?: never; + /** + * Remove Worker Queue + * @description Remove a queue from a worker. + */ + delete: operations["remove_worker_queue"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/edge_worker/ui/worker/{worker_name}/concurrency": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Set Worker Concurrency Limit + * @description Set the concurrency limit for an edge worker. + */ + patch: operations["set_worker_concurrency_limit"]; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * BundleInfo + * @description Schema for telling task which bundle to run with. + */ + BundleInfo: { + /** Name */ + name: string; + /** Version */ + version?: string | null; + }; + /** + * ConcurrencyRequest + * @description Request body for worker concurrency update. + */ + ConcurrencyRequest: { + /** + * Concurrency + * @description New concurrency limit for the worker. + */ + concurrency: number; + }; + /** + * EdgeJobFetched + * @description Job that is to be executed on the edge worker. + */ + EdgeJobFetched: { + /** + * Dag ID + * @description Identifier of the DAG to which the task belongs. + */ + dag_id: string; + /** + * Task ID + * @description Task name in the DAG. + */ + task_id: string; + /** + * Run ID + * @description Run ID of the DAG execution. + */ + run_id: string; + /** + * Map Index + * @description For dynamically mapped tasks the mapping number, -1 if the task is not mapped. + */ + map_index: number; + /** + * Try Number + * @description The number of attempt to execute this task. + */ + try_number: number; + /** + * Command + * @description Command line to use to execute the job in Airflow + */ + command: components["schemas"]["ExecuteTask"]; + /** + * Concurrency Slots + * @description Number of concurrency slots the job requires. + */ + concurrency_slots: number; + }; + /** + * EdgeWorkerState + * @description Status of a Edge Worker instance. + * @enum {string} + */ + EdgeWorkerState: "starting" | "running" | "idle" | "shutdown request" | "terminating" | "offline" | "unknown" | "maintenance request" | "maintenance pending" | "maintenance mode" | "maintenance exit" | "offline maintenance"; + /** + * ExecuteTask + * @description Execute the given Task. + */ + ExecuteTask: { + /** Token */ + token: string; + /** + * Dag Rel Path + * Format: path + */ + dag_rel_path: string; + bundle_info: components["schemas"]["BundleInfo"]; + /** Log Path */ + log_path: string | null; + ti: components["schemas"]["TaskInstanceDTO"]; + /** + * Sentry Integration + * @default + */ + sentry_integration: string; + /** + * Type + * @default ExecuteTask + * @constant + */ + type: "ExecuteTask"; + }; + /** + * HTTPExceptionResponse + * @description HTTPException Model used for error response. + */ + HTTPExceptionResponse: { + /** Detail */ + detail: string | { + [key: string]: unknown; + }; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * Job + * @description Details of the job sent to the scheduler. + */ + Job: { + /** + * Dag ID + * @description Identifier of the DAG to which the task belongs. + */ + dag_id: string; + /** + * Task ID + * @description Task name in the DAG. + */ + task_id: string; + /** + * Run ID + * @description Run ID of the DAG execution. + */ + run_id: string; + /** + * Map Index + * @description For dynamically mapped tasks the mapping number, -1 if the task is not mapped. + */ + map_index: number; + /** + * Try Number + * @description The number of attempt to execute this task. + */ + try_number: number; + /** @description State of the job from the view of the executor. */ + state: components["schemas"]["TaskInstanceState"]; + /** + * Queue + * @description Queue for which the task is scheduled/running. + */ + queue: string; + /** + * Queued Dttm + * @description When the job was queued. + */ + queued_dttm?: string | null; + /** + * Edge Worker + * @description The worker processing the job during execution. + */ + edge_worker?: string | null; + /** + * Last Update + * @description Last heartbeat of the job. + */ + last_update?: string | null; + }; + /** + * JobCollectionResponse + * @description Job Collection serializer. + */ + JobCollectionResponse: { + /** Jobs */ + jobs: components["schemas"]["Job"][]; + /** Total Entries */ + total_entries: number; + }; + /** + * MaintenanceRequest + * @description Request body for maintenance operations. + */ + MaintenanceRequest: { + /** + * Maintenance Comment + * @description Comment describing the maintenance reason. + */ + maintenance_comment: string; + }; + /** + * PushLogsBody + * @description Incremental new log content from worker. + */ + PushLogsBody: { + /** + * Log Chunk Time + * Format: date-time + * @description Time of the log chunk at point of sending. + */ + log_chunk_time: string; + /** + * Log Chunk Data + * @description Log chunk data as incremental log text. + */ + log_chunk_data: string; + }; + /** + * TaskInstanceDTO + * @description Schema for TaskInstance with minimal required fields needed for Executors and Task SDK. + */ + TaskInstanceDTO: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Dag Version Id + * Format: uuid + */ + dag_version_id: string; + /** Task Id */ + task_id: string; + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Try Number */ + try_number: number; + /** + * Map Index + * @default -1 + */ + map_index: number; + /** Pool Slots */ + pool_slots: number; + /** Queue */ + queue: string; + /** Priority Weight */ + priority_weight: number; + /** Parent Context Carrier */ + parent_context_carrier?: { + [key: string]: unknown; + } | null; + /** Context Carrier */ + context_carrier?: { + [key: string]: unknown; + } | null; + }; + /** + * TaskInstanceState + * @description All possible states that a Task Instance can be in. + * + * Note that None is also allowed, so always use this in a type hint with Optional. + * @enum {string} + */ + TaskInstanceState: "removed" | "scheduled" | "queued" | "running" | "success" | "restarting" | "failed" | "up_for_retry" | "up_for_reschedule" | "upstream_failed" | "skipped" | "deferred"; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** + * Worker + * @description Details of the worker state sent to the scheduler. + */ + Worker: { + /** + * Queues + * @description List of queues the worker is pulling jobs from. If not provided, worker pulls from all queues. + */ + queues?: string[] | null; + /** + * Team Name + * @description Team name for multi-team setups. If not provided, worker operates without team isolation. + */ + team_name?: string | null; + /** @description State of the worker from the view of the worker. */ + state: components["schemas"]["EdgeWorkerState"]; + /** + * Jobs Active + * @description Number of active jobs the worker is running. + * @default 0 + */ + jobs_active: number; + /** + * Sysinfo + * @description System information of the worker. + * @example { + * "airflow_version": "2.0.0", + * "concurrency": 4, + * "edge_provider_version": "1.0.0", + * "free_concurrency": 3 + * } + */ + sysinfo: { + [key: string]: string | number; + }; + /** + * Maintenance Comments + * @description Comments about the maintenance state of the worker. + */ + maintenance_comments?: string | null; + /** + * Worker Name + * @description Name of the worker. + */ + worker_name: string; + /** + * First Online + * @description When the worker was first online. + */ + first_online?: string | null; + /** + * Last Heartbeat + * @description When the worker last sent a heartbeat. + */ + last_heartbeat?: string | null; + }; + /** + * WorkerCollectionResponse + * @description Worker Collection serializer. + */ + WorkerCollectionResponse: { + /** Workers */ + workers: components["schemas"]["Worker"][]; + /** Total Entries */ + total_entries: number; + }; + /** + * WorkerQueueUpdateBody + * @description Changed queues for the worker. + */ + WorkerQueueUpdateBody: { + /** + * New Queues + * @description Additional queues to be added to worker. + */ + new_queues: string[] | null; + /** + * Remove Queues + * @description Queues to remove from worker. + */ + remove_queues: string[] | null; + }; + /** + * WorkerQueuesBody + * @description Queues that a worker supports to run jobs on. + */ + WorkerQueuesBody: { + /** + * Queues + * @description List of queues the worker is pulling jobs from. If not provided, worker pulls from all queues. + */ + queues?: string[] | null; + /** + * Team Name + * @description Team name for multi-team setups. If not provided, worker operates without team isolation. + */ + team_name?: string | null; + /** + * Free Concurrency + * @description Number of free concurrency slots on the worker. + */ + free_concurrency: number; + }; + /** + * WorkerRegistrationReturn + * @description The return class for the worker registration. + */ + WorkerRegistrationReturn: { + /** + * Last Update + * Format: date-time + * @description Time of the last update of the worker. + */ + last_update: string; + }; + /** + * WorkerSetStateReturn + * @description The return class for the worker set state. + */ + WorkerSetStateReturn: { + /** @description State of the worker from the view of the server. */ + state: components["schemas"]["EdgeWorkerState"]; + /** + * Queues + * @description List of queues the worker is pulling jobs from. If not provided, worker pulls from all queues. + */ + queues: string[] | null; + /** + * Maintenance Comments + * @description Comments about the maintenance state of the worker. + */ + maintenance_comments?: string | null; + /** + * Concurrency + * @description Desired concurrency for the worker set by an administrator. None means no remote override; the worker uses its startup value. + */ + concurrency?: number | null; + }; + /** + * WorkerStateBody + * @description Details of the worker state sent to the scheduler. + */ + WorkerStateBody: { + /** + * Queues + * @description List of queues the worker is pulling jobs from. If not provided, worker pulls from all queues. + */ + queues?: string[] | null; + /** + * Team Name + * @description Team name for multi-team setups. If not provided, worker operates without team isolation. + */ + team_name?: string | null; + /** @description State of the worker from the view of the worker. */ + state: components["schemas"]["EdgeWorkerState"]; + /** + * Jobs Active + * @description Number of active jobs the worker is running. + * @default 0 + */ + jobs_active: number; + /** + * Sysinfo + * @description System information of the worker. + * @example { + * "airflow_version": "2.0.0", + * "concurrency": 4, + * "edge_provider_version": "1.0.0", + * "free_concurrency": 3 + * } + */ + sysinfo: { + [key: string]: string | number; + }; + /** + * Maintenance Comments + * @description Comments about the maintenance state of the worker. + */ + maintenance_comments?: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + fetch: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkerQueuesBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EdgeJobFetched"] | null; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + state: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Identifier of the DAG to which the task belongs. */ + dag_id: string; + /** @description Task name in the DAG. */ + task_id: string; + /** @description Run ID of the DAG execution. */ + run_id: string; + /** @description The number of attempt to execute this task. */ + try_number: number; + /** @description For dynamically mapped tasks the mapping number, -1 if the task is not mapped. */ + map_index: number; + /** @description State of the assigned task under execution. */ + state: components["schemas"]["TaskInstanceState"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logfile_path: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Identifier of the DAG to which the task belongs. */ + dag_id: string; + /** @description Task name in the DAG. */ + task_id: string; + /** @description Run ID of the DAG execution. */ + run_id: string; + /** @description The number of attempt to execute this task. */ + try_number: number; + /** @description For dynamically mapped tasks the mapping number, -1 if the task is not mapped. */ + map_index: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + push_logs: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Identifier of the DAG to which the task belongs. */ + dag_id: string; + /** @description Task name in the DAG. */ + task_id: string; + /** @description Run ID of the DAG execution. */ + run_id: string; + /** @description The number of attempt to execute this task. */ + try_number: number; + /** @description For dynamically mapped tasks the mapping number, -1 if the task is not mapped. */ + map_index: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PushLogsBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + register: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Hostname or instance name of the worker */ + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkerStateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerRegistrationReturn"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_state: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Hostname or instance name of the worker */ + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkerStateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerSetStateReturn"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_queues: { + parameters: { + query?: never; + header: { + /** @description JWT Authorization Token */ + authorization: string; + }; + path: { + /** @description Hostname or instance name of the worker */ + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkerQueueUpdateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPExceptionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + worker: { + parameters: { + query?: { + worker_name_pattern?: string | null; + queue_name_pattern?: string | null; + state?: components["schemas"]["EdgeWorkerState"][] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerCollectionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + jobs: { + parameters: { + query?: { + dag_id_pattern?: string | null; + run_id_pattern?: string | null; + task_id_pattern?: string | null; + state?: components["schemas"]["TaskInstanceState"][] | null; + queue_pattern?: string | null; + worker_name_pattern?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobCollectionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + request_worker_maintenance: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MaintenanceRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + exit_worker_maintenance: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_worker_maintenance: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MaintenanceRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + request_worker_shutdown: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_worker: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add_worker_queue: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + queue_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + remove_worker_queue: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + queue_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_worker_concurrency_limit: { + parameters: { + query?: never; + header?: never; + path: { + worker_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConcurrencyRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/ts-sdk/src/generated/execution.ts b/ts-sdk/src/generated/execution.ts new file mode 100644 index 0000000000000..d9d5417928e5c --- /dev/null +++ b/ts-sdk/src/generated/execution.ts @@ -0,0 +1,4470 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/changelog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Generate Changelog */ + get: operations["generate_changelog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ping */ + get: operations["ping"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/by-name": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Asset By Name + * @description Get an Airflow Asset by `name`. + */ + get: operations["get_asset_by_name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/by-uri": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Asset By Uri + * @description Get an Airflow Asset by `uri`. + */ + get: operations["get_asset_by_uri"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/asset-events/by-asset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Asset Event By Asset Name Uri */ + get: operations["get_asset_event_by_asset_name_uri"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/asset-events/by-asset-alias": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Asset Event By Asset Alias */ + get: operations["get_asset_event_by_asset_alias"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/connections/{connection_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Connection + * @description Get an Airflow connection. + */ + get: operations["get_connection"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dag-runs/{dag_id}/{run_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Dag Run + * @description Get detail of a Dag run. + */ + get: operations["get_dag_run"]; + put?: never; + /** + * Trigger Dag Run + * @description Trigger a Dag run. + */ + post: operations["trigger_dag_run"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dag-runs/{dag_id}/{run_id}/clear": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Clear Dag Run + * @description Clear a Dag run. + */ + post: operations["clear_dag_run"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dag-runs/{dag_id}/{run_id}/state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Dagrun State + * @description Get a Dag run State. + */ + get: operations["get_dagrun_state"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dag-runs/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Dr Count + * @description Get the count of Dag runs matching the given criteria. + */ + get: operations["get_dr_count"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dag-runs/previous": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Previous Dagrun + * @description Get the previous Dag run before the given logical date, optionally filtered by state. + */ + get: operations["get_previous_dagrun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dags/{dag_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Dag + * @description Get a DAG. + */ + get: operations["get_dag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Task Instance Count + * @description Get the count of task instances matching the given criteria. + */ + get: operations["get_task_instance_count"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/previous/{dag_id}/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Previous Task Instance + * @description Get the previous task instance matching the given criteria. + * + * :param dag_id: DAG ID (from path) + * :param task_id: Task ID (from path) + * :param logical_date: If provided, finds TI with logical_date < this value (before filter) + * :param map_index: Map index to filter by (defaults to -1 for non-mapped tasks) + * :param state: If provided, filters by TaskInstance state + */ + get: operations["get_previous_task_instance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/states": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Task Instance States + * @description Get the states for Task Instances with the given criteria. + */ + get: operations["get_task_instance_states"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/breadcrumbs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Task Instance Breadcrumbs */ + get: operations["get_task_instance_breadcrumbs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/{task_instance_id}/run": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Ti Run + * @description Run a TaskInstance. + * + * This endpoint is used to start a TaskInstance that is in the QUEUED state. + */ + patch: operations["ti_run"]; + trace?: never; + }; + "/task-instances/{task_instance_id}/state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Ti Update State + * @description Update the state of a TaskInstance. + * + * Not all state transitions are valid, and transitioning to some states requires extra information to be + * passed along. (Check out the datamodels for details, the rendered docs might not reflect this accurately) + */ + patch: operations["ti_update_state"]; + trace?: never; + }; + "/task-instances/{task_instance_id}/skip-downstream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Ti Skip Downstream */ + patch: operations["ti_skip_downstream"]; + trace?: never; + }; + "/task-instances/{task_instance_id}/heartbeat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Ti Heartbeat + * @description Update the heartbeat of a TaskInstance to mark it as alive & still running. + */ + put: operations["ti_heartbeat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/{task_instance_id}/rtif": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set Rendered Task Instance Fields + * @description Store the rendered task instance fields (RTIF) for a task instance. These are the template fields after Jinja rendering has been applied. Called by the worker after task execution begins. + */ + put: operations["put_rtif"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/{task_instance_id}/rendered-map-index": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Ti Patch Rendered Map Index + * @description Update rendered_map_index for a task instance, sent by the worker during task execution. + */ + patch: operations["ti_patch_rendered_map_index"]; + trace?: never; + }; + "/task-instances/{task_instance_id}/previous-successful-dagrun": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Previous Successful Dagrun + * @description Get the previous successful DagRun for a TaskInstance. + * + * The data from this endpoint is used to get values for Task Context. + */ + get: operations["get_previous_successful_dagrun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-instances/{task_instance_id}/validate-inlets-and-outlets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Validate Inlets And Outlets + * @description Validate whether there're inactive assets in inlets and outlets of a given task instance. + */ + get: operations["validate_inlets_and_outlets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/task-reschedules/{task_instance_id}/start_date": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Start Date + * @description Get the first reschedule date if found, None if no records exist. + */ + get: operations["get_start_date"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/variables/{variable_key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Variable + * @description Get an Airflow Variable. + */ + get: operations["get_variable"]; + /** + * Put Variable + * @description Set an Airflow Variable. + */ + put: operations["put_variable"]; + post?: never; + /** + * Delete Variable + * @description Delete an Airflow Variable. + */ + delete: operations["delete_variable"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/xcoms/{dag_id}/{run_id}/{task_id}/{key}/item/{offset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Mapped Xcom By Index + * @description Get a single XCom value from a mapped task by sequence index + */ + get: operations["get_mapped_xcom_by_index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/xcoms/{dag_id}/{run_id}/{task_id}/{key}/slice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Mapped Xcom By Slice + * @description Get XCom values from a mapped task by sequence slice + */ + get: operations["get_mapped_xcom_by_slice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/xcoms/{dag_id}/{run_id}/{task_id}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Xcom + * @description Get a single XCom Value + */ + get: operations["get_xcom"]; + put?: never; + /** + * Set Xcom + * @description Set an Airflow XCom. + */ + post: operations["set_xcom"]; + /** + * Delete Xcom + * @description Delete a single XCom Value + */ + delete: operations["delete_xcom"]; + options?: never; + /** + * Head Xcom + * @description Returns the count of mapped XCom values found in the `Content-Range` response header + */ + head: operations["head_xcom"]; + patch?: never; + trace?: never; + }; + "/hitlDetails/{task_instance_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Hitl Detail + * @description Get Human-in-the-loop detail for a specific Task Instance. + */ + get: operations["get_hitl_detail"]; + put?: never; + /** + * Upsert Hitl Detail + * @description Create a Human-in-the-loop detail for a specific Task Instance. + * + * There're 3 cases handled here. + * + * 1. If a HITLOperator task instance does not have a HITLDetail, + * a new HITLDetail is created without a response section. + * 2. If a HITLOperator task instance has a HITLDetail but lacks a response, + * the existing HITLDetail is returned. + * This situation occurs when a task instance is cleared before a response is received. + * 3. If a HITLOperator task instance has both a HITLDetail and a response section, + * the existing response is removed, and the HITLDetail is returned. + * This happens when a task instance is cleared after a response has been received. + * This design ensures that each task instance has only one HITLDetail. + */ + post: operations["upsert_hitl_detail"]; + delete?: never; + options?: never; + head?: never; + /** + * Update Hitl Detail + * @description Update the response part of a Human-in-the-loop detail for a specific Task Instance. + */ + patch: operations["update_hitl_detail"]; + trace?: never; + }; + "/dag-runs/{dag_id}/previous": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Previous Dagrun Compat + * @description Redirect old previous dag run request to the new endpoint. + * + * This endpoint must be put before ``get_dag_run`` so not to be shadowed. + * Newer client versions would not see this endpoint, and be routed to + * ``get_dag_run`` below instead. + */ + get: operations["get_previous_dagrun_compat"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * AssetAliasReferenceAssetEventDagRun + * @description Schema for AssetAliasModel used in AssetEventDagRunReference. + */ + AssetAliasReferenceAssetEventDagRun: { + /** Name */ + name: string; + }; + /** + * AssetEventDagRunReference + * @description Schema for AssetEvent model used in DagRun. + */ + AssetEventDagRunReference: { + asset: components["schemas"]["AssetReferenceAssetEventDagRun"]; + /** Extra */ + extra: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Source Task Id */ + source_task_id: string | null; + /** Source Dag Id */ + source_dag_id: string | null; + /** Source Run Id */ + source_run_id: string | null; + /** Source Map Index */ + source_map_index: number | null; + /** Source Aliases */ + source_aliases: components["schemas"]["AssetAliasReferenceAssetEventDagRun"][]; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + }; + /** + * AssetProfile + * @description Profile of an asset-like object. + * + * Asset will have name, uri defined, with type set to 'Asset'. + * AssetNameRef will have name defined, type set to 'AssetNameRef'. + * AssetUriRef will have uri defined, type set to 'AssetUriRef'. + * AssetAlias will have name defined, type set to 'AssetAlias'. + * + * Note that 'type' here is distinct from 'asset_type' the user declares on an + * Asset (or subclass). This field is for distinguishing between different + * asset-related types (Asset, AssetRef, or AssetAlias). + */ + AssetProfile: { + /** Name */ + name?: string | null; + /** Uri */ + uri?: string | null; + /** Type */ + type: string; + }; + /** + * AssetReferenceAssetEventDagRun + * @description Schema for AssetModel used in AssetEventDagRunReference. + */ + AssetReferenceAssetEventDagRun: { + /** Name */ + name: string; + /** Uri */ + uri: string; + /** Extra */ + extra: { + [key: string]: components["schemas"]["JsonValue"]; + }; + }; + /** + * AssetResponse + * @description Asset schema for responses with fields that are needed for Runtime. + */ + AssetResponse: { + /** Name */ + name: string; + /** Uri */ + uri: string; + /** Group */ + group: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + }; + /** CadwynAttributeChange */ + CadwynAttributeChange: { + /** Name */ + name: string; + status: components["schemas"]["CadwynAttributeChangeStatus"]; + /** Old Value */ + old_value: unknown; + /** New Value */ + new_value: unknown; + }; + /** + * CadwynAttributeChangeStatus + * @enum {string} + */ + CadwynAttributeChangeStatus: "added" | "changed" | "removed"; + /** CadwynChangelogResource */ + CadwynChangelogResource: { + /** Versions */ + versions?: components["schemas"]["CadwynVersion"][]; + }; + /** CadwynEndpointAttributeChange */ + CadwynEndpointAttributeChange: { + /** Name */ + name: string; + /** New Value */ + new_value: unknown; + }; + /** CadwynEndpointHadChangelogEntry */ + CadwynEndpointHadChangelogEntry: { + /** + * Type + * @default endpoint.changed + * @constant + */ + type: "endpoint.changed"; + /** Path */ + path: string; + /** Methods */ + methods: components["schemas"]["HTTPMethod"][]; + /** Changes */ + changes: components["schemas"]["CadwynEndpointAttributeChange"][]; + }; + /** CadwynEndpointWasAddedChangelogEntry */ + CadwynEndpointWasAddedChangelogEntry: { + /** + * Type + * @default endpoint.added + * @constant + */ + type: "endpoint.added"; + /** Path */ + path: string; + /** Methods */ + methods: components["schemas"]["HTTPMethod"][]; + }; + /** CadwynEndpointWasRemovedChangelogEntry */ + CadwynEndpointWasRemovedChangelogEntry: { + /** + * Type + * @default endpoint.removed + * @constant + */ + type: "endpoint.removed"; + /** Path */ + path: string; + /** Methods */ + methods: components["schemas"]["HTTPMethod"][]; + }; + /** CadwynEnumMember */ + CadwynEnumMember: { + /** Name */ + name: string; + /** Value */ + value: unknown; + }; + /** CadwynEnumMembersWereAddedChangelogEntry */ + CadwynEnumMembersWereAddedChangelogEntry: { + /** + * Type + * @default enum.members.added + * @constant + */ + type: "enum.members.added"; + /** Enum */ + enum: string; + /** Members */ + members: components["schemas"]["CadwynEnumMember"][]; + }; + /** CadwynEnumMembersWereChangedChangelogEntry */ + CadwynEnumMembersWereChangedChangelogEntry: { + /** + * Type + * @default enum.members.removed + * @constant + */ + type: "enum.members.removed"; + /** Enum */ + enum: string; + /** Member Changes */ + member_changes: components["schemas"]["CadwynAttributeChange"][]; + }; + /** CadwynFieldAttributesWereChangedChangelogEntry */ + CadwynFieldAttributesWereChangedChangelogEntry: { + /** + * Type + * @default schema.field.attributes.changed + * @constant + */ + type: "schema.field.attributes.changed"; + /** Models */ + models: string[]; + /** Field */ + field: string; + /** Attribute Changes */ + attribute_changes: components["schemas"]["CadwynAttributeChange"][]; + }; + /** CadwynModelModifiedAttributes */ + CadwynModelModifiedAttributes: { + /** Name */ + name: string | null; + }; + /** CadwynSchemaFieldWasAddedChangelogEntry */ + CadwynSchemaFieldWasAddedChangelogEntry: { + /** + * Type + * @default schema.field.added + * @constant + */ + type: "schema.field.added"; + /** Models */ + models: string[]; + /** Field */ + field: string; + /** Field Info */ + field_info: { + [key: string]: unknown; + }; + }; + /** CadwynSchemaFieldWasRemovedChangelogEntry */ + CadwynSchemaFieldWasRemovedChangelogEntry: { + /** + * Type + * @default schema.field.removed + * @constant + */ + type: "schema.field.removed"; + /** Models */ + models: string[]; + /** Field */ + field: string; + }; + /** CadwynSchemaWasChangedChangelogEntry */ + CadwynSchemaWasChangedChangelogEntry: { + /** + * Type + * @default schema.changed + * @constant + */ + type: "schema.changed"; + /** Model */ + model: string; + modified_attributes: components["schemas"]["CadwynModelModifiedAttributes"]; + }; + /** CadwynVersion */ + CadwynVersion: { + /** Value */ + value: string; + /** Changes */ + changes?: components["schemas"]["CadwynVersionChange"][]; + }; + /** CadwynVersionChange */ + CadwynVersionChange: { + /** Description */ + description: string; + /** Side Effects */ + side_effects: boolean; + /** Instructions */ + instructions?: components["schemas"]["RootModel_Union_CadwynEnumMembersWereAddedChangelogEntry__CadwynEnumMembersWereChangedChangelogEntry__CadwynEndpointWasAddedChangelogEntry__CadwynEndpointWasRemovedChangelogEntry__CadwynSchemaFieldWasRemovedChangelogEntry__CadwynSchemaFieldWasAddedChangelogEntry__CadwynFieldAttributesWereChangedChangelogEntry__CadwynEndpointHadChangelogEntry__CadwynSchemaWasChangedChangelogEntry__"][]; + }; + /** + * ConnectionResponse + * @description Connection schema for responses with fields that are needed for Runtime. + */ + ConnectionResponse: { + /** Conn Id */ + conn_id: string; + /** Conn Type */ + conn_type: string; + /** Host */ + host: string | null; + /** Schema */ + schema: string | null; + /** Login */ + login: string | null; + /** Password */ + password: string | null; + /** Port */ + port: number | null; + /** Extra */ + extra: string | null; + }; + /** + * DagResponse + * @description Schema for DAG response. + */ + DagResponse: { + /** Dag Id */ + dag_id: string; + /** Is Paused */ + is_paused: boolean; + /** Bundle Name */ + bundle_name: string | null; + /** Bundle Version */ + bundle_version: string | null; + /** Relative Fileloc */ + relative_fileloc: string | null; + /** Owners */ + owners: string | null; + /** Tags */ + tags: string[]; + /** Next Dagrun */ + next_dagrun: string | null; + }; + /** + * DagRunState + * @description All possible states that a DagRun can be in. + * + * These are "shared" with TaskInstanceState in some parts of the code, + * so please ensure that their values always match the ones with the + * same name in TaskInstanceState. + * @enum {string} + */ + DagRunState: "queued" | "running" | "success" | "failed"; + /** + * DagRunStateResponse + * @description Schema for DAG Run State response. + */ + DagRunStateResponse: { + state: components["schemas"]["DagRunState"]; + }; + /** + * DagRunType + * @description Class with DagRun types. + * @enum {string} + */ + DagRunType: "backfill" | "scheduled" | "manual" | "operator_triggered" | "asset_triggered" | "asset_materialization"; + /** + * HITLDetailRequest + * @description Schema for the request part of a Human-in-the-loop detail for a specific task instance. + */ + HITLDetailRequest: { + /** + * Ti Id + * Format: uuid + */ + ti_id: string; + /** Options */ + options: string[]; + /** Subject */ + subject: string; + /** Body */ + body?: string | null; + /** Defaults */ + defaults?: string[] | null; + /** + * Multiple + * @default false + */ + multiple: boolean; + /** Params */ + params?: { + [key: string]: unknown; + }; + /** Assigned Users */ + assigned_users?: components["schemas"]["HITLUser"][]; + }; + /** + * HITLDetailResponse + * @description Schema for the response part of a Human-in-the-loop detail for a specific task instance. + */ + HITLDetailResponse: { + /** Response Received */ + response_received: boolean; + responded_by_user?: components["schemas"]["HITLUser"] | null; + /** Responded At */ + responded_at: string | null; + /** Chosen Options */ + chosen_options: string[] | null; + /** Params Input */ + params_input?: { + [key: string]: unknown; + }; + }; + /** + * HITLUser + * @description Schema for a Human-in-the-loop users. + */ + HITLUser: { + /** Id */ + id: string; + /** Name */ + name: string; + }; + /** + * HTTPMethod + * @enum {string} + */ + HTTPMethod: "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE"; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * InactiveAssetsResponse + * @description Response for inactive assets. + */ + InactiveAssetsResponse: { + /** Inactive Assets */ + inactive_assets?: components["schemas"]["AssetProfile"][]; + }; + /** + * IntermediateTIState + * @description States that a Task Instance can be in that indicate it is not yet in a terminal or running state. + * @enum {string} + */ + IntermediateTIState: "scheduled" | "queued" | "restarting" | "up_for_retry" | "up_for_reschedule" | "deferred"; + JsonValue: unknown; + /** + * PrevSuccessfulDagRunResponse + * @description Schema for response with previous successful DagRun information for Task Template Context. + */ + PrevSuccessfulDagRunResponse: { + /** Data Interval Start */ + data_interval_start?: string | null; + /** Data Interval End */ + data_interval_end?: string | null; + /** Start Date */ + start_date?: string | null; + /** End Date */ + end_date?: string | null; + }; + /** + * PreviousTIResponse + * @description Schema for response with previous TaskInstance information. + */ + PreviousTIResponse: { + /** Task Id */ + task_id: string; + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date?: string | null; + /** Start Date */ + start_date?: string | null; + /** End Date */ + end_date?: string | null; + /** State */ + state?: string | null; + /** Try Number */ + try_number: number; + /** + * Map Index + * @default -1 + */ + map_index: number | null; + /** Duration */ + duration?: number | null; + }; + /** RootModel[Union[CadwynEnumMembersWereAddedChangelogEntry, CadwynEnumMembersWereChangedChangelogEntry, CadwynEndpointWasAddedChangelogEntry, CadwynEndpointWasRemovedChangelogEntry, CadwynSchemaFieldWasRemovedChangelogEntry, CadwynSchemaFieldWasAddedChangelogEntry, CadwynFieldAttributesWereChangedChangelogEntry, CadwynEndpointHadChangelogEntry, CadwynSchemaWasChangedChangelogEntry]] */ + RootModel_Union_CadwynEnumMembersWereAddedChangelogEntry__CadwynEnumMembersWereChangedChangelogEntry__CadwynEndpointWasAddedChangelogEntry__CadwynEndpointWasRemovedChangelogEntry__CadwynSchemaFieldWasRemovedChangelogEntry__CadwynSchemaFieldWasAddedChangelogEntry__CadwynFieldAttributesWereChangedChangelogEntry__CadwynEndpointHadChangelogEntry__CadwynSchemaWasChangedChangelogEntry__: components["schemas"]["CadwynEnumMembersWereAddedChangelogEntry"] | components["schemas"]["CadwynEnumMembersWereChangedChangelogEntry"] | components["schemas"]["CadwynEndpointWasAddedChangelogEntry"] | components["schemas"]["CadwynEndpointWasRemovedChangelogEntry"] | components["schemas"]["CadwynSchemaFieldWasRemovedChangelogEntry"] | components["schemas"]["CadwynSchemaFieldWasAddedChangelogEntry"] | components["schemas"]["CadwynFieldAttributesWereChangedChangelogEntry"] | components["schemas"]["CadwynEndpointHadChangelogEntry"] | components["schemas"]["CadwynSchemaWasChangedChangelogEntry"]; + /** + * TIDeferredStatePayload + * @description Schema for updating TaskInstance to a deferred state. + */ + TIDeferredStatePayload: { + /** + * State + * @default deferred + * @enum {string} + */ + state: "deferred"; + /** Classpath */ + classpath: string; + /** Trigger Kwargs */ + trigger_kwargs?: { + [key: string]: components["schemas"]["JsonValue"]; + } | string; + /** Trigger Timeout */ + trigger_timeout?: string | null; + /** Queue */ + queue?: string | null; + /** Next Method */ + next_method: string; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Rendered Map Index */ + rendered_map_index?: string | null; + }; + /** + * TIEnterRunningPayload + * @description Schema for updating TaskInstance to 'RUNNING' state with minimal required fields. + */ + TIEnterRunningPayload: { + /** + * State + * @default running + * @enum {string} + */ + state: "running"; + /** Hostname */ + hostname: string; + /** Unixname */ + unixname: string; + /** Pid */ + pid: number; + /** + * Start Date + * Format: date-time + */ + start_date: string; + }; + /** + * TIHeartbeatInfo + * @description Schema for TaskInstance heartbeat endpoint. + */ + TIHeartbeatInfo: { + /** Hostname */ + hostname: string; + /** Pid */ + pid: number; + }; + /** + * TIRescheduleStatePayload + * @description Schema for updating TaskInstance to a up_for_reschedule state. + */ + TIRescheduleStatePayload: { + /** + * State + * @default up_for_reschedule + * @enum {string} + */ + state: "up_for_reschedule"; + /** + * Reschedule Date + * Format: date-time + */ + reschedule_date: string; + /** + * End Date + * Format: date-time + */ + end_date: string; + }; + /** + * TIRetryStatePayload + * @description Schema for updating TaskInstance to up_for_retry. + */ + TIRetryStatePayload: { + /** + * State + * @default up_for_retry + * @enum {string} + */ + state: "up_for_retry"; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** Rendered Map Index */ + rendered_map_index?: string | null; + }; + /** + * TISkippedDownstreamTasksStatePayload + * @description Schema for updating downstream tasks to a skipped state. + */ + TISkippedDownstreamTasksStatePayload: { + /** Tasks */ + tasks: (string | [ + string, + number + ])[]; + }; + /** + * TISuccessStatePayload + * @description Schema for updating TaskInstance to success state. + */ + TISuccessStatePayload: { + /** + * State + * @default success + * @enum {string} + */ + state: "success"; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** Task Outlets */ + task_outlets?: components["schemas"]["AssetProfile"][]; + /** Outlet Events */ + outlet_events?: { + [key: string]: unknown; + }[]; + /** Rendered Map Index */ + rendered_map_index?: string | null; + }; + /** + * TITargetStatePayload + * @description Schema for updating TaskInstance to a target state, excluding terminal and running states. + */ + TITargetStatePayload: { + state: components["schemas"]["IntermediateTIState"]; + }; + /** + * TITerminalStatePayload + * @description Schema for updating TaskInstance to a terminal state except SUCCESS state. + */ + TITerminalStatePayload: { + state: components["schemas"]["TerminalStateNonSuccess"]; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** Rendered Map Index */ + rendered_map_index?: string | null; + }; + /** + * TaskBreadcrumbsResponse + * @description Response for task breadcrumbs. + */ + TaskBreadcrumbsResponse: { + /** Breadcrumbs */ + breadcrumbs: { + [key: string]: unknown; + }[]; + }; + /** + * TaskInstanceState + * @description All possible states that a Task Instance can be in. + * + * Note that None is also allowed, so always use this in a type hint with Optional. + * @enum {string} + */ + TaskInstanceState: "removed" | "scheduled" | "queued" | "running" | "success" | "restarting" | "failed" | "up_for_retry" | "up_for_reschedule" | "upstream_failed" | "skipped" | "deferred"; + /** + * TaskStatesResponse + * @description Response for task states with run_id, task and state. + */ + TaskStatesResponse: { + /** Task States */ + task_states: { + [key: string]: unknown; + }; + }; + /** + * TerminalStateNonSuccess + * @description TaskInstance states that can be reported without extra information. + * @enum {string} + */ + TerminalStateNonSuccess: "failed" | "skipped" | "removed" | "upstream_failed"; + /** + * TriggerDAGRunPayload + * @description Schema for Trigger DAG Run API request. + */ + TriggerDAGRunPayload: { + /** Logical Date */ + logical_date?: string | null; + /** Conf */ + conf?: { + [key: string]: unknown; + }; + /** + * Reset Dag Run + * @default false + */ + reset_dag_run: boolean; + /** Partition Key */ + partition_key?: string | null; + /** Note */ + note?: string | null; + }; + /** + * UpdateHITLDetailPayload + * @description Schema for writing the response part of a Human-in-the-loop detail for a specific task instance. + */ + UpdateHITLDetailPayload: { + /** + * Ti Id + * Format: uuid + */ + ti_id: string; + /** Chosen Options */ + chosen_options: string[]; + /** Params Input */ + params_input?: { + [key: string]: unknown; + }; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** + * VariablePostBody + * @description Request body schema for creating variables. + */ + VariablePostBody: { + /** Val */ + val: string | null; + /** Description */ + description?: string | null; + }; + /** + * VariableResponse + * @description Variable schema for responses with fields that are needed for Runtime. + */ + VariableResponse: { + /** Key */ + key: string; + /** Value */ + value: string | null; + }; + /** + * XComResponse + * @description XCom schema for responses with fields that are needed for Runtime. + */ + XComResponse: { + /** Key */ + key: string; + value: components["schemas"]["JsonValue"]; + }; + /** + * XComSequenceIndexResponse + * @description XCom schema with minimal structure for index-based access. + */ + XComSequenceIndexResponse: components["schemas"]["JsonValue"]; + /** + * XComSequenceSliceResponse + * @description XCom schema with minimal structure for slice-based access. + */ + XComSequenceSliceResponse: components["schemas"]["JsonValue"][]; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__1: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__1"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + /** Partition Key */ + partition_key?: string | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__2: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__2"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__3: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__3"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__4: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__4"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__5: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__5"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__6: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__6"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__7: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__7"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventResponse + * @description Asset event schema with fields that are needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__8: { + /** Id */ + id: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Extra */ + extra?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + asset: components["schemas"]["AssetResponse"]; + /** Created Dagruns */ + created_dagruns: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__8"][]; + /** Source Task Id */ + source_task_id?: string | null; + /** Source Dag Id */ + source_dag_id?: string | null; + /** Source Run Id */ + source_run_id?: string | null; + /** Source Map Index */ + source_map_index?: number | null; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__1: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__1"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__2: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__2"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__3: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__3"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__4: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__4"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__5: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__5"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__6: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__6"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__7: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__7"][]; + }; + /** + * AssetEventsResponse + * @description Collection of AssetEventResponse. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__8: { + /** Asset Events */ + asset_events: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventResponse__8"][]; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__1: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** Partition Key */ + partition_key: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__2: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__3: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__4: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__5: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__6: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__7: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRunAssetReference + * @description DagRun serializer for asset responses. + */ + airflow__api_fastapi__execution_api__datamodels__asset_event__DagRunAssetReference__8: { + /** Run Id */ + run_id: string; + /** Dag Id */ + dag_id: string; + /** Logical Date */ + logical_date: string | null; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** State */ + state: string; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__1: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** Start Date */ + start_date: string | null; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Triggering User Name */ + triggering_user_name?: string | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + /** Partition Key */ + partition_key: string | null; + /** Note */ + note?: string | null; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__2: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** Start Date */ + start_date: string | null; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Triggering User Name */ + triggering_user_name?: string | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + /** Partition Key */ + partition_key: string | null; + /** Note */ + note?: string | null; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__3: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Triggering User Name */ + triggering_user_name?: string | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__4: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__5: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__6: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + state: components["schemas"]["DagRunState"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__7: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__8: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * DagRun + * @description Schema for DagRun model with minimal required fields needed for Runtime. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__9: { + /** Dag Id */ + dag_id: string; + /** Run Id */ + run_id: string; + /** Logical Date */ + logical_date: string | null; + /** Data Interval Start */ + data_interval_start: string | null; + /** Data Interval End */ + data_interval_end: string | null; + /** + * Run After + * Format: date-time + */ + run_after: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + /** + * Clear Number + * @default 0 + */ + clear_number: number; + run_type: components["schemas"]["DagRunType"]; + /** Conf */ + conf?: { + [key: string]: unknown; + } | null; + /** Consumed Asset Events */ + consumed_asset_events: components["schemas"]["AssetEventDagRunReference"][]; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__1: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__2"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__2: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__3"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | number[] | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__3: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__4"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | number[] | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__4: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__5"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | number[] | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__5: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__6"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | number[] | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__6: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__7"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | number[] | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__7: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__8"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | null; + } | null; + }; + /** + * TIRunContext + * @description Response schema for TaskInstance run context. + */ + airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__8: { + dag_run: components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__9"]; + /** + * Task Reschedule Count + * @default 0 + */ + task_reschedule_count: number; + /** Max Tries */ + max_tries: number; + /** Variables */ + variables?: components["schemas"]["VariableResponse"][]; + /** Connections */ + connections?: components["schemas"]["ConnectionResponse"][]; + /** Next Method */ + next_method?: string | null; + /** Next Kwargs */ + next_kwargs?: { + [key: string]: unknown; + } | string | null; + /** Xcom Keys To Clear */ + xcom_keys_to_clear?: string[]; + /** + * Should Retry + * @default false + */ + should_retry: boolean; + /** Upstream Map Indexes */ + upstream_map_indexes: { + [key: string]: number | null; + } | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + generate_changelog: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CadwynChangelogResource"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + ping: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_asset_by_name: { + parameters: { + query: { + /** @description The name of the Asset */ + name: string; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Asset not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_asset_by_uri: { + parameters: { + query: { + /** @description The URI of the Asset */ + uri: string; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Asset not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_asset_event_by_asset_name_uri: { + parameters: { + query: { + /** @description The name of the Asset */ + name: string | null; + /** @description The URI of the Asset */ + uri: string | null; + /** @description The start of the time range */ + after?: string | null; + /** @description The end of the time range */ + before?: string | null; + /** @description Whether to sort results in ascending order */ + ascending?: boolean; + /** @description The maximum number of results to return */ + limit?: number | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__8"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Asset not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_asset_event_by_asset_alias: { + parameters: { + query: { + /** @description The name of the Asset Alias */ + name: string; + /** @description The start of the time range */ + after?: string | null; + /** @description The end of the time range */ + before?: string | null; + /** @description Whether to sort results in ascending order */ + ascending?: boolean; + /** @description The maximum number of results to return */ + limit?: number | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["airflow__api_fastapi__execution_api__datamodels__asset_event__AssetEventsResponse__8"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Asset not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_connection: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + connection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectionResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the connection */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Connection not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_dag_run: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__1"]; + }; + }; + /** @description Dag run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + trigger_dag_run: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TriggerDAGRunPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Dag has import errors or the run type is not allowed */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Dag not found for the given dag_id */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Dag run already exists for the given dag_id */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + clear_dag_run: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Dag has import errors and cannot be triggered */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Dag not found for the given dag_id */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_dagrun_state: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DagRunStateResponse"]; + }; + }; + /** @description Dag run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_dr_count: { + parameters: { + query: { + dag_id: string; + logical_dates?: string[] | null; + run_ids?: string[] | null; + states?: string[] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": number; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_previous_dagrun: { + parameters: { + query: { + dag_id: string; + logical_date: string; + state?: components["schemas"]["DagRunState"] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__DagRun__1"] | null; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_dag: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DagResponse"]; + }; + }; + /** @description DAG not found for the given dag_id */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_task_instance_count: { + parameters: { + query: { + dag_id: string; + map_index?: number | null; + task_ids?: string[] | null; + task_group_id?: string | null; + logical_dates?: string[] | null; + run_ids?: string[] | null; + states?: string[] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": number; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_previous_task_instance: { + parameters: { + query?: { + logical_date?: string | null; + map_index?: number; + state?: components["schemas"]["TaskInstanceState"] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreviousTIResponse"] | null; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_task_instance_states: { + parameters: { + query: { + dag_id: string; + map_index?: number | null; + task_ids?: string[] | null; + task_group_id?: string | null; + logical_dates?: string[] | null; + run_ids?: string[] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskStatesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_task_instance_breadcrumbs: { + parameters: { + query: { + dag_id: string; + run_id: string; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskBreadcrumbsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + ti_run: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TIEnterRunningPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["airflow__api_fastapi__execution_api__datamodels__taskinstance__TIRunContext__8"]; + }; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The TI is already in the requested state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload for the state transition */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ti_update_state: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TITerminalStatePayload"] | components["schemas"]["TISuccessStatePayload"] | components["schemas"]["TITargetStatePayload"] | components["schemas"]["TIDeferredStatePayload"] | components["schemas"]["TIRescheduleStatePayload"] | components["schemas"]["TIRetryStatePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The TI is already in the requested state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload for the state transition */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ti_skip_downstream: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TISkippedDownstreamTasksStatePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload for the state transition */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ti_heartbeat: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TIHeartbeatInfo"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The TI attempting to heartbeat should be terminated for the given reason */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found in the TI table but exists in the Task Instance History table */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload for the state transition */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put_rtif: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: components["schemas"]["JsonValue"]; + }; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid payload for the setting rendered task instance fields */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ti_patch_rendered_map_index: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid rendered_map_index value */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_previous_successful_dagrun: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PrevSuccessfulDagRunResponse"]; + }; + }; + /** @description Task Instance or Dag Run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + validate_inlets_and_outlets: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InactiveAssetsResponse"]; + }; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_start_date: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string | null; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_variable: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + variable_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VariableResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the variable */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Variable not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + put_variable: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + variable_key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VariablePostBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the variable */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Variable not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_variable: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + variable_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the variable */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Variable not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_mapped_xcom_by_index: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + offset: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["XComSequenceIndexResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_mapped_xcom_by_slice: { + parameters: { + query?: { + start?: number | null; + stop?: number | null; + step?: number | null; + include_prior_dates?: boolean; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["XComSequenceSliceResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_xcom: { + parameters: { + query?: { + map_index?: number; + include_prior_dates?: boolean; + offset?: number | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["XComResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_xcom: { + parameters: { + query?: { + map_index?: number; + /** @description Whether this XCom is a dag result */ + dag_result?: boolean; + /** @description Number of mapped tasks this value expands into */ + mapped_length?: number | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["JsonValue"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_xcom: { + parameters: { + query?: { + map_index?: number; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + head_xcom: { + parameters: { + query?: { + map_index?: number | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + run_id: string; + task_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Metadata about the number of matching XCom values */ + 200: { + headers: { + /** @description The number of (mapped) XCom values found for this task. */ + "Content-Range"?: unknown; + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task does not have access to the XCom */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description XCom not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_hitl_detail: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HITLDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upsert_hitl_detail: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["HITLDetailRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HITLDetailRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_hitl_detail: { + parameters: { + query?: never; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + task_instance_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateHITLDetailPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HITLDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_previous_dagrun_compat: { + parameters: { + query: { + logical_date: string; + state?: components["schemas"]["DagRunState"] | null; + }; + header?: { + "Airflow-API-Version"?: string; + }; + path: { + dag_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts new file mode 100644 index 0000000000000..edcf265681440 --- /dev/null +++ b/ts-sdk/src/index.ts @@ -0,0 +1,25 @@ +/*! + * 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. + */ + +// Public API surface — @apache-airflow/ts-sdk (covered by SemVer). + +export { registerTask, listRegisteredTasks } from "./registry.js"; +export { startWorker } from "./worker.js"; +export type { TaskContext, TaskHandler, TaskHandlerArgs, StartWorkerOptions } from "./types.js"; +export { EdgeApiError, ExecutionApiError } from "./errors.js"; diff --git a/ts-sdk/src/registry.ts b/ts-sdk/src/registry.ts new file mode 100644 index 0000000000000..7fc679657f4e7 --- /dev/null +++ b/ts-sdk/src/registry.ts @@ -0,0 +1,58 @@ +/*! + * 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. + */ + +// Task registry — string task_id → async handler. +// TaskGroup tasks use the full dotted form (e.g. "transforms.normalize"). + +import type { TaskHandler } from "./types.js"; + +const registry = new Map(); + +/** Register a task handler. `taskId` must match the DAG-side operator's + * task_id exactly, including any TaskGroup prefix. */ +export function registerTask( + taskId: string, + handler: TaskHandler, +): void { + if (!taskId || typeof taskId !== "string") { + throw new Error("registerTask: taskId must be a non-empty string"); + } + if (typeof handler !== "function") { + throw new Error(`registerTask: handler for "${taskId}" must be a function`); + } + if (registry.has(taskId)) { + throw new Error(`Task "${taskId}" is already registered`); + } + registry.set(taskId, handler as TaskHandler); +} + +/** Look up a registered handler. Returns undefined if not registered. */ +export function getRegisteredTask(taskId: string): TaskHandler | undefined { + return registry.get(taskId); +} + +/** List all registered task_ids. */ +export function listRegisteredTasks(): string[] { + return [...registry.keys()]; +} + +/** Clear the registry. Primarily for testing. */ +export function clearRegistry(): void { + registry.clear(); +} diff --git a/ts-sdk/src/types.ts b/ts-sdk/src/types.ts new file mode 100644 index 0000000000000..733e54e095ddd --- /dev/null +++ b/ts-sdk/src/types.ts @@ -0,0 +1,86 @@ +/*! + * 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. + */ + +// Public types exposed to user task code. +// TODO(pr2): extend TaskContext with TIRunContext fields (`dagRunConf`, +// `maxTries`, `taskRescheduleCount`, etc.) — requires `enterRunning` +// to return the response body instead of `Promise`. +// TODO(pr3): add `client` to TaskHandlerArgs with sub-clients +// (`client.xcom.{push,pull}`, `client.variables.get`, +// `client.connections.get`). +// TODO(pr4): add `log` (forwarded to Edge API) to TaskHandlerArgs. + +/** Per-task context delivered to every task handler invocation. + * + * Minimal in PR #1 — see TODOs above for the APIs user tasks will need + * (XCom, Variables, Connections, dag_run.conf, etc.). */ + +import type { EdgeJobFetched } from "./edge-client.js"; + +export interface TaskContext { + readonly dagId: string; + readonly taskId: string; + readonly runId: string; + readonly tryNumber: number; + /** -1 for non-mapped tasks, 0..N-1 for mapped instances. */ + readonly mapIndex: number; + readonly taskInstanceId: string; + /** AbortSignal that fires when the task should stop (SIGTERM drain or + * execution_timeout). User tasks should pass this to `fetch()`, + * `setTimeout` helpers, or any abortable API. */ + readonly signal: AbortSignal; +} + +/** Arguments passed to every task handler. Adding fields is non-breaking + * for consumers that destructure by name. */ +export interface TaskHandlerArgs { + ctx: TaskContext; + readonly job: EdgeJobFetched; +} + +// TODO(pr3): capture handler return value and push to XCom (currently +// discarded — generic exists so user types stay stable +// when XCom lands). +/** User task handler function signature. */ +export type TaskHandler = (args: TaskHandlerArgs) => Promise; + +/** Configuration for `startWorker()`. */ +export interface StartWorkerOptions { + /** Airflow api-server base URL, e.g. `"http://localhost:8080"`. + * Falls back to `process.env.AIRFLOW__EDGE__API_URL`. */ + baseUrl?: string; + /** Secret for Edge API JWT signing. + * Falls back to `process.env.AIRFLOW__API_AUTH__JWT_SECRET`. */ + secret?: string; + /** Edge queue name(s) this worker listens on. At least one required. */ + queues: string[]; + /** Unique worker name. Defaults to `${hostname}-${pid}-${random}`. */ + workerName?: string; + /** Milliseconds between poll attempts when idle. Default 5000. */ + pollIntervalInMs?: number; + /** Milliseconds between worker-level heartbeats. Default 30000. */ + heartbeatIntervalInMs?: number; + /** Consecutive heartbeat failures before the worker self-terminates. + * Default 10. */ + heartbeatFailureThreshold?: number; + /** JWT `iss` claim sent on Edge API requests. Must match the server's + * `[api_auth] jwt_issuer` config. Falls back to + * `process.env.AIRFLOW__API_AUTH__JWT_ISSUER`, then `"airflow"`. */ + jwtIssuer?: string; +} diff --git a/ts-sdk/src/utils/jwt.ts b/ts-sdk/src/utils/jwt.ts new file mode 100644 index 0000000000000..638761a4751f9 --- /dev/null +++ b/ts-sdk/src/utils/jwt.ts @@ -0,0 +1,89 @@ +/*! + * 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. + */ + +// JWT primitives for the Edge API. Signed per request with the shared +// secret; short TTL; `method` claim is the path minus `/edge_worker/v1/`. +// +// TODO(pr2): add Execution-API token refresh (self-sign + decode + near-expiry +// check). Server-issued tokens come in `command.token` and have a +// 10-min TTL — long tasks need to refresh before the token ages out. + +import { createHmac } from "node:crypto"; + +function b64urlEncode(input: Buffer | string): string { + return Buffer.from(input) + .toString("base64") + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +interface EdgeJwtOptions { + /** Request path with `/edge_worker/v1/` prefix stripped. Must match the + * server-side `method_path` computed in + * `providers/edge3/worker_api/auth.py` from `request.url.path`. */ + method: string; + /** JWT `iss` claim. Must match the server's `[api_auth] jwt_issuer` + * config. Required: the resolved value comes from + * `worker-options.resolveWorkerOptions`, which applies the project-wide + * default ("airflow"). */ + issuer: string; + /** Time-to-live in seconds. Default 30 (Python SDK default). Go SDK uses 5. */ + ttlSeconds?: number; +} + +/** Sign an HS512 JWT for an Edge API request. The returned token goes in the + * Authorization header AS-IS — no `Bearer ` prefix (FastAPI's raw Header + * dependency rejects it). Contrast: Execution API wants `Bearer `. */ +export function signEdgeJwt(secret: string, opts: EdgeJwtOptions): string { + const now = Math.floor(Date.now() / 1000); + const ttl = opts.ttlSeconds ?? 30; + const header = { alg: "HS512", typ: "JWT" } as const; + const payload = { + method: opts.method, + iss: opts.issuer, + aud: "api", + iat: now, + nbf: now, + exp: now + ttl, + }; + return signHs512(secret, header, payload); +} + +/** Derive the JWT `method` claim from a URL path. The server matches the + * claim against the DECODED path with `/edge_worker/v1/` stripped — encoded + * segments (e.g. `:` → `%3A`) must be decoded first or the JWT check 403s. */ +export function pathToEdgeMethod(path: string): string { + const PREFIX = "/edge_worker/v1/"; + const decoded = decodeURIComponent(path); + const idx = decoded.indexOf(PREFIX); + return idx >= 0 ? decoded.slice(idx + PREFIX.length) : decoded; +} + +function signHs512( + secret: string, + header: Record, + payload: Record, +): string { + const h = b64urlEncode(JSON.stringify(header)); + const p = b64urlEncode(JSON.stringify(payload)); + const signingInput = `${h}.${p}`; + const signature = createHmac("sha512", secret).update(signingInput).digest(); + return `${signingInput}.${b64urlEncode(signature)}`; +} diff --git a/ts-sdk/src/utils/retry.ts b/ts-sdk/src/utils/retry.ts new file mode 100644 index 0000000000000..a3c9262837a5b --- /dev/null +++ b/ts-sdk/src/utils/retry.ts @@ -0,0 +1,88 @@ +/*! + * 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. + */ + +// Shared retry helper for Edge + Execution clients. Exponential backoff +// (100ms × 3^attempt). Transient errors retry, permanent fail fast. +// TODO(pr2): jitter, per-attempt timeout, full state-transition retry budget. + +export interface RetryConfig { + maxAttempts?: number; + baseBackoffMs?: number; +} + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_BACKOFF_MS = 100; + +/** Does this error warrant a retry? Falls through to false for non-typed errors. */ +interface RetriableError { + isTransient: boolean; +} + +function isRetriable(err: unknown): err is RetriableError { + return ( + typeof err === "object" && + err !== null && + "isTransient" in err && + typeof (err as RetriableError).isTransient === "boolean" && + (err as RetriableError).isTransient + ); +} + +/** Invoke `fn` with retry on transient errors. Permanent errors fail fast. + * Honors the provided AbortSignal between attempts (aborts during sleep). */ +export async function withRetry( + fn: () => Promise, + config: RetryConfig = {}, + signal?: AbortSignal, +): Promise { + const maxAttempts = config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const baseBackoffMs = config.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS; + let lastErr: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (signal?.aborted) throw signal.reason ?? new Error("aborted"); + try { + return await fn(); + } catch (err) { + if (!isRetriable(err)) throw err; + lastErr = err; + } + const delayMs = baseBackoffMs * 3 ** attempt; + await sleepAbortable(delayMs, signal); + } + throw lastErr; +} + +export function sleepAbortable(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const t = setTimeout(() => { + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(t); + reject(signal?.reason ?? new Error("aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/ts-sdk/src/worker-options.ts b/ts-sdk/src/worker-options.ts new file mode 100644 index 0000000000000..b7f583d62c0c1 --- /dev/null +++ b/ts-sdk/src/worker-options.ts @@ -0,0 +1,77 @@ +/*! + * 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. + */ + +// Worker option resolution: env-var fallbacks, validation, default merging. + +import { hostname } from "node:os"; +import { randomBytes } from "node:crypto"; +import type { StartWorkerOptions } from "./types.js"; + +export const DEFAULTS = { + pollIntervalInMs: 5_000, + heartbeatIntervalInMs: 30_000, + heartbeatFailureThreshold: 10, +} as const; + +export interface ResolvedWorkerOptions { + baseUrl: string; + secret: string; + workerName: string; + queues: string[]; + pollIntervalInMs: number; + heartbeatIntervalInMs: number; + heartbeatFailureThreshold: number; + jwtIssuer: string; +} + +export function resolveWorkerOptions(opts: StartWorkerOptions): ResolvedWorkerOptions { + // Normalize to the host root — SDK builds its own paths. + const rawBaseUrl = opts.baseUrl ?? process.env.AIRFLOW__EDGE__API_URL; + if (!rawBaseUrl) { + throw new Error("startWorker: baseUrl is required (pass in options or set AIRFLOW__EDGE__API_URL)"); + } + const baseUrl = rawBaseUrl.replace(/\/edge_worker\/v1.*$/, "").replace(/\/$/, ""); + + const secret = opts.secret ?? process.env.AIRFLOW__API_AUTH__JWT_SECRET; + if (!secret) { + throw new Error("startWorker: secret is required (pass in options or set AIRFLOW__API_AUTH__JWT_SECRET)"); + } + + if (!Array.isArray(opts.queues) || opts.queues.length === 0) { + throw new Error("startWorker: queues must be a non-empty string[]"); + } + + const workerName = opts.workerName ?? generateWorkerName(); + + return { + baseUrl, + secret, + workerName, + queues: opts.queues, + pollIntervalInMs: opts.pollIntervalInMs ?? DEFAULTS.pollIntervalInMs, + heartbeatIntervalInMs: opts.heartbeatIntervalInMs ?? DEFAULTS.heartbeatIntervalInMs, + heartbeatFailureThreshold: opts.heartbeatFailureThreshold ?? DEFAULTS.heartbeatFailureThreshold, + jwtIssuer: opts.jwtIssuer ?? process.env.AIRFLOW__API_AUTH__JWT_ISSUER ?? "airflow", + }; +} + +function generateWorkerName(): string { + const suffix = randomBytes(4).toString("hex"); + return `${hostname()}-${process.pid}-${suffix}`; +} diff --git a/ts-sdk/src/worker.ts b/ts-sdk/src/worker.ts new file mode 100644 index 0000000000000..4e3c32ad3e2a1 --- /dev/null +++ b/ts-sdk/src/worker.ts @@ -0,0 +1,252 @@ +/*! + * 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. + */ + +// Worker entry point. Composes clients + registry + poll loop. +// TODO(pr2): per-TI heartbeat, token refresh, state-transition retry loop. +// TODO(pr3): XCom / Variables / Connections in TaskHandlerArgs. +// TODO(pr4): log forwarding to Edge API. +// TODO(pr5): subprocess isolation per task. + +import { userInfo } from "node:os"; +import { + makeEdgeClient, + WORKER_STATE, + JOB_STATE, + type EdgeClient, + type WorkerStateBody, + type EdgeJobFetched, +} from "./edge-client.js"; +import { makeExecutionClient } from "./execution-client.js"; +import { ExecutionApiError, formatError } from "./errors.js"; +import { getRegisteredTask } from "./registry.js"; +import type { StartWorkerOptions, TaskContext } from "./types.js"; +import { sleepAbortable } from "./utils/retry.js"; +import { resolveWorkerOptions } from "./worker-options.js"; + +const AIRFLOW_VERSION = process.env.AIRFLOW__EDGE__AIRFLOW_VERSION ?? "3.3.0"; +const EDGE_PROVIDER_VERSION = process.env.AIRFLOW__EDGE__PROVIDER_VERSION ?? "3.5.0"; + +/** Start the worker. Resolves when the worker has shut down cleanly */ +export async function startWorker(opts: StartWorkerOptions): Promise { + const cfg = resolveWorkerOptions(opts); + const edge = makeEdgeClient({ + baseUrl: cfg.baseUrl, + secret: cfg.secret, + workerName: cfg.workerName, + jwtIssuer: cfg.jwtIssuer, + }); + + // Worker state shared across heartbeatLoop + pollLoop. + let jobsActive = 0; + let heartbeatFailures = 0; + const ac = new AbortController(); + + const makeWorkerState = (state: WorkerStateBody["state"]): WorkerStateBody => ({ + state, + jobs_active: jobsActive, + queues: cfg.queues, + sysinfo: { + airflow_version: AIRFLOW_VERSION, + edge_provider_version: EDGE_PROVIDER_VERSION, + // Single-task worker. Concurrent dispatch deferred (see TODOs at top of file). + concurrency: 1, + free_concurrency: jobsActive > 0 ? 0 : 1, + }, + }); + + await edge.register(makeWorkerState(WORKER_STATE.STARTING)); + console.log(`[worker] ${cfg.workerName} registered on queues=${cfg.queues.join(",")}`); + + const onSignal = () => { + if (!ac.signal.aborted) { + console.log("[worker] signal received, initiating drain"); + ac.abort(); + } + }; + process.on("SIGTERM", onSignal); + process.on("SIGINT", onSignal); + + const heartbeatLoop = async (): Promise => { + while (!ac.signal.aborted) { + await sleepAbortable(cfg.heartbeatIntervalInMs, ac.signal).catch(() => undefined); + if (ac.signal.aborted) break; + try { + // RUNNING if busy with a job; IDLE if waiting (Go SDK _currentState pattern). + const state = jobsActive > 0 ? WORKER_STATE.RUNNING : WORKER_STATE.IDLE; + const resp = await edge.updateState(makeWorkerState(state)); + heartbeatFailures = 0; // reset on any success + if (resp?.state === WORKER_STATE.SHUTDOWN_REQUEST) { + console.log("[worker] server requested shutdown"); + ac.abort(); + break; + } + } catch (err) { + heartbeatFailures += 1; + console.error( + `[worker] heartbeat failed (${heartbeatFailures}/${cfg.heartbeatFailureThreshold}): ${formatError(err)}`, + ); + if (heartbeatFailures >= cfg.heartbeatFailureThreshold) { + console.error("[worker] circuit breaker tripped — aborting"); + ac.abort(); + break; + } + } + } + }; + + const pollLoop = async (): Promise => { + while (!ac.signal.aborted) { + await sleepAbortable(cfg.pollIntervalInMs, ac.signal).catch(() => undefined); + if (ac.signal.aborted) break; + + // Single-task: executeJob is awaited, so jobsActive is binary 0/1. + let job: EdgeJobFetched | null; + try { + job = await edge.fetchJob({ + queues: cfg.queues, + free_concurrency: jobsActive > 0 ? 0 : 1, + }); + } catch (err) { + console.error(`[worker] poll failed: ${formatError(err)}`); + continue; + } + if (!job) continue; // queue empty + + jobsActive += 1; + try { + await executeJob(job, { + baseUrl: cfg.baseUrl, + workerName: cfg.workerName, + edge, + signal: ac.signal, + }); + } catch (err) { + if (err instanceof ExecutionApiError && (err.isNotFound || err.status === 409)) { + console.log(`[worker] TI not runnable (${err.status}); ack failed`); + } else { + console.error(`[worker] executeJob crashed: ${formatError(err)}`); + } + try { + await ackEdgeJob(edge, job, JOB_STATE.FAILED); // every fetched job must be acked + } catch (ackErr) { + console.error(`[worker] failsafe ack failed: ${formatError(ackErr)}`); + } + } finally { + jobsActive -= 1; + } + } + }; + + await Promise.all([heartbeatLoop(), pollLoop()]); + + // Drain: final offline PATCH — best-effort; don't block exit on failure. + try { + await edge.updateState(makeWorkerState(WORKER_STATE.TERMINATING)); + } catch (err) { + console.error(`[worker] terminating PATCH failed: ${formatError(err)}`); + } + try { + await edge.updateState(makeWorkerState(WORKER_STATE.OFFLINE)); + console.log("[worker] offline, bye"); + } catch (err) { + console.error(`[worker] offline PATCH failed: ${formatError(err)}`); + } +} + +interface ExecuteJobContext { + baseUrl: string; + workerName: string; + edge: EdgeClient; + signal: AbortSignal; +} + +async function executeJob(job: EdgeJobFetched, ctx: ExecuteJobContext): Promise { + const tiId = job.command.ti.id; + const label = `${job.dag_id}/${job.task_id}/${job.run_id} try=${job.try_number}`; + console.log(`[executeJob] ${label} ti=${tiId}`); + + const exec = makeExecutionClient({ + baseUrl: ctx.baseUrl, + taskInstanceId: tiId, + token: job.command.token, + }); + + // 1. Transition TI to running. Any error propagates to pollLoop's + // failsafe so the edge job is always acked, even on crash. + await exec.enterRunning({ + hostname: ctx.workerName, + unixname: userInfo().username, + pid: process.pid, + start_date: new Date().toISOString(), + }); + + // 2. Look up the handler. + const handler = getRegisteredTask(job.task_id); + if (!handler) { + console.error(`[executeJob] no handler registered for task_id="${job.task_id}"`); + await exec.markFailed({ end_date: new Date().toISOString() }); + await ackEdgeJob(ctx.edge, job, JOB_STATE.FAILED); + return; + } + + // 3. Invoke handler. + const taskContext: TaskContext = { + dagId: job.dag_id, + taskId: job.task_id, + runId: job.run_id, + tryNumber: job.try_number, + mapIndex: job.map_index, + taskInstanceId: tiId, + signal: ctx.signal, + }; + // 4. Run handler, mark TI, ack edge. Mark/ack failures propagate to the + // pollLoop failsafe — handler-success + mark-failure naturally + // downgrades to ack-failed (so edge state always converges). + try { + await handler({ ctx: taskContext, job }); + } catch (err) { + console.error(`[executeJob] ${label} handler threw: ${formatError(err)}`); + await exec.markFailed({ end_date: new Date().toISOString() }); + await ackEdgeJob(ctx.edge, job, JOB_STATE.FAILED); + console.log(`[executeJob] ${label} → failed`); + return; + } + + await exec.markSuccess({ end_date: new Date().toISOString() }); + await ackEdgeJob(ctx.edge, job, JOB_STATE.SUCCESS); + console.log(`[executeJob] ${label} → success`); +} + +/** Ack the edge job's terminal state — pure path-param translation around + * `edge.reportJobState`. Errors propagate so the pollLoop failsafe knows + * the regular ack failed and can attempt a failsafe ack. */ +async function ackEdgeJob( + edge: EdgeClient, + job: EdgeJobFetched, + state: "success" | "failed", +): Promise { + await edge.reportJobState({ + dagId: job.dag_id, + taskId: job.task_id, + runId: job.run_id, + tryNumber: job.try_number, + mapIndex: job.map_index, + state, + }); +} diff --git a/ts-sdk/tests/edge-client.test.ts b/ts-sdk/tests/edge-client.test.ts new file mode 100644 index 0000000000000..d93b08b9336fc --- /dev/null +++ b/ts-sdk/tests/edge-client.test.ts @@ -0,0 +1,318 @@ +/*! + * 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, it, expect } from "vitest"; +import { makeEdgeClient, type WorkerStateBody } from "../src/edge-client.js"; +import { EdgeApiError } from "../src/errors.js"; + +const SECRET = "test-secret-not-for-prod"; +const BASE_URL = "http://airflow.test"; +const WORKER = "alpha"; +const ISSUER = "airflow"; + +function stateBody(state: WorkerStateBody["state"]): WorkerStateBody { + return { + state, + jobs_active: 0, + queues: ["ts-tasks"], + sysinfo: { + airflow_version: "3.2.0", + edge_provider_version: "3.3.0", + concurrency: 1, + free_concurrency: 1, + }, + }; +} + +/** Record-shape for an observed call. openapi-fetch passes a `Request` object, + * so we extract URL/method/headers/body into a flat shape the tests can inspect. */ +interface CallRecord { + url: string; + method: string; + headers: Record; + body: string | null; + init: { method: string; headers: Record; body: string | null }; +} + +/** Build a fake fetch that records calls and returns a canned response. */ +function mockFetch( + responder: (call: CallRecord) => Response | Promise, +): { fetch: typeof fetch; calls: CallRecord[] } { + const calls: CallRecord[] = []; + const impl = async (input: Request | URL | string, init?: RequestInit) => { + let url: string; + let method: string; + let headers: Record; + let body: string | null; + if (input instanceof Request) { + url = input.url; + method = input.method; + headers = Object.fromEntries(input.headers.entries()); + body = input.body ? await input.clone().text() : null; + } else { + url = typeof input === "string" ? input : input.toString(); + method = init?.method ?? "GET"; + headers = toHeaderRecord( + init?.headers as Headers | Record | string[][] | undefined, + ); + body = typeof init?.body === "string" ? init.body : null; + } + const call: CallRecord = { + url, + method, + headers, + body, + init: { method, headers, body }, + }; + calls.push(call); + return responder(call); + }; + return { fetch: impl as typeof fetch, calls }; +} + +function toHeaderRecord( + headers: Headers | Record | string[][] | undefined, +): Record { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + if (Array.isArray(headers)) return Object.fromEntries(headers); + return headers; +} + +describe("makeEdgeClient — auth + path shape", () => { + it("sends Authorization header as raw JWT (no Bearer prefix)", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const client = makeEdgeClient({ + baseUrl: BASE_URL, + secret: SECRET, + workerName: WORKER, + jwtIssuer: ISSUER, + fetchImpl, + }); + await client.register(stateBody("starting")); + expect(calls.length).toBe(1); + const auth = calls[0]!.headers["authorization"] ?? calls[0]!.headers["Authorization"]; + expect(auth).toBeTruthy(); + expect(auth).not.toMatch(/^Bearer /); + expect(auth!.split(".").length).toBe(3); // JWT + }); + + it("builds the register URL with the right path", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + await client.register(stateBody("starting")); + expect(calls[0]!.url).toBe(`${BASE_URL}/edge_worker/v1/worker/${WORKER}`); + expect(calls[0]!.method).toBe("POST"); + }); + + it("plumbs jwtIssuer through to the JWT iss claim", async () => { + function decodeIss(jwt: string): string { + const payload = jwt.split(".")[1]!; + const padded = payload + "=".repeat((4 - payload.length % 4) % 4); + const std = padded.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(std, "base64").toString("utf8")).iss; + } + + const m = mockFetch(() => new Response("null", { status: 200 })); + const client = makeEdgeClient({ + baseUrl: BASE_URL, + secret: SECRET, + workerName: WORKER, + fetchImpl: m.fetch, + jwtIssuer: "my-deployment", + }); + await client.register(stateBody("starting")); + const auth = m.calls[0]!.headers["authorization"] ?? m.calls[0]!.headers["Authorization"]; + expect(decodeIss(auth!)).toBe("my-deployment"); + }); + + it("exposes workerName", () => { + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER }); + expect(client.workerName).toBe(WORKER); + }); + + it("trims trailing slash from baseUrl", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const client = makeEdgeClient({ + baseUrl: `${BASE_URL}/`, + secret: SECRET, + workerName: WORKER, + jwtIssuer: ISSUER, + fetchImpl, + }); + await client.register(stateBody("starting")); + expect(calls[0]!.url).toBe(`${BASE_URL}/edge_worker/v1/worker/${WORKER}`); + }); +}); + +describe("makeEdgeClient — updateState", () => { + it("returns parsed WorkerSetStateReturn payload", async () => { + const payload = { state: "running", queues: ["ts-tasks"] }; + const { fetch: fetchImpl } = mockFetch( + () => new Response(JSON.stringify(payload), { status: 200 }), + ); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + const resp = await client.updateState(stateBody("running")); + expect(resp.state).toBe("running"); + }); + + it("surfaces shutdown request from server", async () => { + const { fetch: fetchImpl } = mockFetch( + () => + new Response(JSON.stringify({ state: "shutdown request", queues: ["ts-tasks"] }), { + status: 200, + }), + ); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + const resp = await client.updateState(stateBody("running")); + expect(resp.state).toBe("shutdown request"); + }); +}); + +describe("makeEdgeClient — fetchJob", () => { + it("returns null when response is an empty object (no job)", async () => { + const { fetch: fetchImpl } = mockFetch(() => new Response("{}", { status: 200 })); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + const job = await client.fetchJob({ queues: ["q"], free_concurrency: 1 }); + expect(job).toBeNull(); + }); + + it("returns the full payload when a job is available", async () => { + const jobPayload = { + dag_id: "d", + task_id: "t", + run_id: "r", + try_number: 1, + map_index: -1, + command: { + token: "tok", + ti: { id: "tiid" }, + dag_rel_path: "dag.py", + bundle_info: { name: "b" }, + log_path: null, + sentry_integration: "none", + type: "ExecuteTask", + }, + concurrency_slots: 1, + }; + const { fetch: fetchImpl } = mockFetch( + () => new Response(JSON.stringify(jobPayload), { status: 200 }), + ); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + const job = await client.fetchJob({ queues: ["q"], free_concurrency: 1 }); + expect(job).not.toBeNull(); + expect(job?.dag_id).toBe("d"); + expect(job?.command.token).toBe("tok"); + }); +}); + +describe("makeEdgeClient — reportJobState", () => { + it("builds the correct path with all 6 segments", async () => { + const { fetch: fetchImpl, calls } = mockFetch( + () => new Response("null", { status: 200 }), + ); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + await client.reportJobState({ + dagId: "d", + taskId: "t", + runId: "r", + tryNumber: 1, + mapIndex: -1, + state: "success", + }); + expect(calls[0]!.url).toBe( + `${BASE_URL}/edge_worker/v1/jobs/state/d/t/r/1/-1/success`, + ); + expect(calls[0]!.method).toBe("PATCH"); + }); +}); + +describe("makeEdgeClient — error handling", () => { + it("throws EdgeApiError on 4xx with fail-fast (no retry)", async () => { + let calls = 0; + const { fetch: fetchImpl } = mockFetch(() => { + calls += 1; + return new Response("{\"detail\":\"bad request\"}", { status: 400 }); + }); + const client = makeEdgeClient({ + baseUrl: BASE_URL, + secret: SECRET, + workerName: WORKER, + jwtIssuer: ISSUER, + fetchImpl, + retry: { maxAttempts: 3, baseBackoffMs: 1 }, + }); + await expect(client.register(stateBody("starting"))).rejects.toBeInstanceOf(EdgeApiError); + expect(calls).toBe(1); // no retry on 4xx + }); + + it("retries on 5xx, succeeds on third attempt", async () => { + let calls = 0; + const { fetch: fetchImpl } = mockFetch(() => { + calls += 1; + if (calls < 3) return new Response("server error", { status: 503 }); + return new Response("null", { status: 200 }); + }); + const client = makeEdgeClient({ + baseUrl: BASE_URL, + secret: SECRET, + workerName: WORKER, + jwtIssuer: ISSUER, + fetchImpl, + retry: { maxAttempts: 3, baseBackoffMs: 1 }, + }); + await client.register(stateBody("starting")); + expect(calls).toBe(3); + }); + + it("throws EdgeApiError after max attempts on persistent 5xx", async () => { + let calls = 0; + const { fetch: fetchImpl } = mockFetch(() => { + calls += 1; + return new Response("server down", { status: 503 }); + }); + const client = makeEdgeClient({ + baseUrl: BASE_URL, + secret: SECRET, + workerName: WORKER, + jwtIssuer: ISSUER, + fetchImpl, + retry: { maxAttempts: 2, baseBackoffMs: 1 }, + }); + await expect(client.register(stateBody("starting"))).rejects.toBeInstanceOf(EdgeApiError); + expect(calls).toBe(2); + }); + + it("EdgeApiError includes status, method, and path", async () => { + const { fetch: fetchImpl } = mockFetch( + () => new Response("bad", { status: 400 }), + ); + const client = makeEdgeClient({ baseUrl: BASE_URL, secret: SECRET, workerName: WORKER, jwtIssuer: ISSUER, fetchImpl }); + try { + await client.register(stateBody("starting")); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(EdgeApiError); + const e = err as EdgeApiError; + expect(e.status).toBe(400); + expect(e.method).toBe("POST"); + expect(e.path).toContain("worker"); + } + }); +}); diff --git a/ts-sdk/tests/errors.test.ts b/ts-sdk/tests/errors.test.ts new file mode 100644 index 0000000000000..4d55233abd311 --- /dev/null +++ b/ts-sdk/tests/errors.test.ts @@ -0,0 +1,141 @@ +/*! + * 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, it, expect } from "vitest"; +import { EdgeApiError, ExecutionApiError, formatError } from "../src/errors.js"; + +describe("formatError", () => { + it("returns stack trace for Error instances", () => { + const err = new Error("boom"); + const out = formatError(err); + expect(out).toContain("Error: boom"); + }); + + it("returns the string as-is for string errors", () => { + expect(formatError("naked string")).toBe("naked string"); + }); + + it("JSON-stringifies object-shaped errors", () => { + expect(formatError({ code: 42 })).toBe('{"code":42}'); + }); + + it("falls back to String() for circular objects", () => { + const obj: Record = {}; + obj.self = obj; + expect(formatError(obj)).toBe("[object Object]"); + }); + + it("handles null/undefined", () => { + expect(formatError(null)).toBe("null"); + expect(formatError(undefined)).toBe("undefined"); + }); +}); + +describe("EdgeApiError", () => { + it("builds a readable message with method, path, status, and truncated body", () => { + const err = new EdgeApiError("GET", "/jobs/fetch/w", 500, "a".repeat(500)); + expect(err.message).toMatch(/Edge API GET \/jobs\/fetch\/w → 500/); + // Body is truncated to 200 chars + expect(err.message.length).toBeLessThan(300); + }); + + it("isTransient is true for 5xx", () => { + expect(new EdgeApiError("GET", "/p", 500, "").isTransient).toBe(true); + expect(new EdgeApiError("GET", "/p", 503, "").isTransient).toBe(true); + }); + + it("isTransient is true for status 0 (network)", () => { + expect(new EdgeApiError("GET", "/p", 0, "ECONNREFUSED").isTransient).toBe(true); + }); + + it("isTransient is false for 4xx", () => { + expect(new EdgeApiError("GET", "/p", 400, "").isTransient).toBe(false); + expect(new EdgeApiError("GET", "/p", 404, "").isTransient).toBe(false); + expect(new EdgeApiError("GET", "/p", 409, "").isTransient).toBe(false); + }); + + it("isNotFound is true only for 404", () => { + expect(new EdgeApiError("GET", "/p", 404, "").isNotFound).toBe(true); + expect(new EdgeApiError("GET", "/p", 400, "").isNotFound).toBe(false); + expect(new EdgeApiError("GET", "/p", 500, "").isNotFound).toBe(false); + }); + + it("is an instanceof Error", () => { + expect(new EdgeApiError("GET", "/p", 500, "")).toBeInstanceOf(Error); + }); + + it("preserves fields readably", () => { + const err = new EdgeApiError("PATCH", "/worker/alpha", 409, "conflict"); + expect(err.method).toBe("PATCH"); + expect(err.path).toBe("/worker/alpha"); + expect(err.status).toBe(409); + expect(err.body).toBe("conflict"); + expect(err.name).toBe("EdgeApiError"); + }); +}); + +describe("ExecutionApiError", () => { + it("isTransient mirrors EdgeApiError semantics", () => { + expect(new ExecutionApiError("GET", "/p", 500, "").isTransient).toBe(true); + expect(new ExecutionApiError("GET", "/p", 404, "").isTransient).toBe(false); + }); + + it("isInvalidState: 409 + body contains 'invalid_state'", () => { + const err = new ExecutionApiError( + "PATCH", + "/task-instances/123/state", + 409, + '{"detail":"invalid_state"}', + ); + expect(err.isInvalidState).toBe(true); + }); + + it("isInvalidState is false for 409 without the marker", () => { + const err = new ExecutionApiError("PATCH", "/p", 409, "some other 409"); + expect(err.isInvalidState).toBe(false); + }); + + it("isTokenExpired: 403 + 'Signature has expired'", () => { + const err = new ExecutionApiError( + "GET", + "/variables/foo", + 403, + "Signature has expired.", + ); + expect(err.isTokenExpired).toBe(true); + }); + + it("isTokenExpired is false for non-403s or different body", () => { + expect( + new ExecutionApiError("GET", "/p", 403, "other reason").isTokenExpired, + ).toBe(false); + expect( + new ExecutionApiError("GET", "/p", 401, "Signature has expired").isTokenExpired, + ).toBe(false); + }); + + it("isNotFound", () => { + expect(new ExecutionApiError("GET", "/p", 404, "").isNotFound).toBe(true); + expect(new ExecutionApiError("GET", "/p", 500, "").isNotFound).toBe(false); + }); + + it("name is set correctly", () => { + expect(new ExecutionApiError("GET", "/p", 500, "").name).toBe("ExecutionApiError"); + }); +}); diff --git a/ts-sdk/tests/execution-client.test.ts b/ts-sdk/tests/execution-client.test.ts new file mode 100644 index 0000000000000..e68358b3dbfcb --- /dev/null +++ b/ts-sdk/tests/execution-client.test.ts @@ -0,0 +1,302 @@ +/*! + * 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, it, expect } from "vitest"; +import { makeExecutionClient } from "../src/execution-client.js"; +import { ExecutionApiError } from "../src/errors.js"; + +const BASE_URL = "http://airflow.test"; +const TI_ID = "01920cbd-deed-7feb-a6be-abcdef012345"; +const TOKEN = "server.issued.bearer"; + +interface CallRecord { + url: string; + method: string; + headers: Record; + body: string | null; +} + +function mockFetch( + responder: (call: CallRecord) => Response | Promise, +): { fetch: typeof fetch; calls: CallRecord[] } { + const calls: CallRecord[] = []; + const impl = async (input: Request | URL | string, init?: RequestInit) => { + let url: string; + let method: string; + let headers: Record; + let body: string | null; + if (input instanceof Request) { + url = input.url; + method = input.method; + headers = Object.fromEntries(input.headers.entries()); + body = input.body ? await input.clone().text() : null; + } else { + url = typeof input === "string" ? input : input.toString(); + method = init?.method ?? "GET"; + headers = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record) ?? {}); + body = typeof init?.body === "string" ? init.body : null; + } + const call = { url, method, headers, body }; + calls.push(call); + return responder(call); + }; + return { fetch: impl as typeof fetch, calls }; +} + +describe("makeExecutionClient — auth", () => { + it("sends Authorization: Bearer ", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + await exec.markSuccess({ end_date: new Date().toISOString() }); + const authHeader = calls[0]!.headers["authorization"] ?? calls[0]!.headers["Authorization"]; + expect(authHeader).toBe(`Bearer ${TOKEN}`); + }); + + it("sends Airflow-API-Version header", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + await exec.markSuccess({ end_date: new Date().toISOString() }); + const versionHeader = + calls[0]!.headers["airflow-api-version"] ?? calls[0]!.headers["Airflow-API-Version"]; + expect(versionHeader).toBe("2025-08-10"); + }); + + it("respects custom apiVersion", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + apiVersion: "2026-01-01", + }); + await exec.markSuccess({ end_date: new Date().toISOString() }); + const versionHeader = + calls[0]!.headers["airflow-api-version"] ?? calls[0]!.headers["Airflow-API-Version"]; + expect(versionHeader).toBe("2026-01-01"); + }); + + it("exposes taskInstanceId", () => { + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + }); + expect(exec.taskInstanceId).toBe(TI_ID); + }); +}); + +describe("makeExecutionClient — Refreshed-API-Token swap", () => { + it("uses the refreshed token from the previous response on the next request", async () => { + // First call (e.g. /run): server hands back a fresh execution-scoped token. + // Second call (e.g. /state): SDK should send the refreshed value, not the + // original workload token. + const REFRESHED = "execution.scoped.bearer"; + let callIndex = 0; + const { fetch: fetchImpl, calls } = mockFetch(() => { + callIndex += 1; + const headers: Record = callIndex === 1 + ? { "Refreshed-API-Token": REFRESHED } + : {}; + return new Response("null", { status: 200, headers }); + }); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + + await exec.enterRunning({ hostname: "h", unixname: "u", pid: 1, start_date: "2026-04-30T00:00:00Z" }); + await exec.markSuccess({ end_date: "2026-04-30T00:00:01Z" }); + + const auth1 = calls[0]!.headers["authorization"] ?? calls[0]!.headers["Authorization"]; + const auth2 = calls[1]!.headers["authorization"] ?? calls[1]!.headers["Authorization"]; + expect(auth1).toBe(`Bearer ${TOKEN}`); + expect(auth2).toBe(`Bearer ${REFRESHED}`); + }); + + it("keeps the existing token when the response has no Refreshed-API-Token header", async () => { + const { fetch: fetchImpl, calls } = mockFetch( + () => new Response("null", { status: 200 }), + ); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + + await exec.enterRunning({ hostname: "h", unixname: "u", pid: 1, start_date: "2026-04-30T00:00:00Z" }); + await exec.markSuccess({ end_date: "2026-04-30T00:00:01Z" }); + + const auth1 = calls[0]!.headers["authorization"] ?? calls[0]!.headers["Authorization"]; + const auth2 = calls[1]!.headers["authorization"] ?? calls[1]!.headers["Authorization"]; + expect(auth1).toBe(`Bearer ${TOKEN}`); + expect(auth2).toBe(`Bearer ${TOKEN}`); + }); +}); + +describe("makeExecutionClient — state transitions", () => { + it("enterRunning posts to /task-instances/{id}/run with state=running and user fields", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + await exec.enterRunning({ + hostname: "myhost", + unixname: "me", + pid: 12345, + start_date: "2026-04-19T00:00:00Z", + }); + expect(calls[0]!.url).toBe(`${BASE_URL}/execution/task-instances/${TI_ID}/run`); + expect(calls[0]!.method).toBe("PATCH"); + const body = JSON.parse(calls[0]!.body as string); + expect(body.state).toBe("running"); + expect(body.hostname).toBe("myhost"); + expect(body.pid).toBe(12345); + }); + + it("markSuccess posts state=success + empty outlet arrays", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + await exec.markSuccess({ end_date: "2026-04-19T00:00:05Z" }); + expect(calls[0]!.url).toBe(`${BASE_URL}/execution/task-instances/${TI_ID}/state`); + const body = JSON.parse(calls[0]!.body as string); + expect(body.state).toBe("success"); + expect(body.end_date).toBe("2026-04-19T00:00:05Z"); + expect(body.task_outlets).toEqual([]); + expect(body.outlet_events).toEqual([]); + }); + + it("markFailed posts state=failed", async () => { + const { fetch: fetchImpl, calls } = mockFetch(() => new Response("null", { status: 200 })); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + await exec.markFailed({ end_date: "2026-04-19T00:00:05Z" }); + const body = JSON.parse(calls[0]!.body as string); + expect(body.state).toBe("failed"); + }); +}); + +describe("makeExecutionClient — error handling", () => { + it("throws ExecutionApiError with isNotFound=true on 404", async () => { + const { fetch: fetchImpl } = mockFetch( + () => new Response("not found", { status: 404 }), + ); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + try { + await exec.markSuccess({ end_date: "2026-04-19T00:00:05Z" }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ExecutionApiError); + const e = err as ExecutionApiError; + expect(e.isNotFound).toBe(true); + expect(e.status).toBe(404); + } + }); + + it("throws ExecutionApiError with isInvalidState=true on 409 'invalid_state'", async () => { + const { fetch: fetchImpl } = mockFetch( + () => + new Response(JSON.stringify({ detail: "invalid_state" }), { status: 409 }), + ); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + }); + try { + await exec.markSuccess({ end_date: "2026-04-19T00:00:05Z" }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ExecutionApiError); + const e = err as ExecutionApiError; + expect(e.isInvalidState).toBe(true); + } + }); + + it("retries on 5xx (transient)", async () => { + let calls = 0; + const { fetch: fetchImpl } = mockFetch(() => { + calls += 1; + if (calls < 2) return new Response("server error", { status: 500 }); + return new Response("null", { status: 200 }); + }); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + retry: { maxAttempts: 3, baseBackoffMs: 1 }, + }); + await exec.markSuccess({ end_date: "2026-04-19T00:00:05Z" }); + expect(calls).toBe(2); + }); + + it("does not retry on 404", async () => { + let calls = 0; + const { fetch: fetchImpl } = mockFetch(() => { + calls += 1; + return new Response("not found", { status: 404 }); + }); + const exec = makeExecutionClient({ + baseUrl: BASE_URL, + taskInstanceId: TI_ID, + token: TOKEN, + fetchImpl, + retry: { maxAttempts: 3, baseBackoffMs: 1 }, + }); + await expect(exec.markSuccess({ end_date: "2026-04-19T00:00:05Z" })).rejects.toBeInstanceOf( + ExecutionApiError, + ); + expect(calls).toBe(1); + }); +}); diff --git a/ts-sdk/tests/registry.test.ts b/ts-sdk/tests/registry.test.ts new file mode 100644 index 0000000000000..ccd0f6f0d0733 --- /dev/null +++ b/ts-sdk/tests/registry.test.ts @@ -0,0 +1,84 @@ +/*! + * 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, it, expect, beforeEach } from "vitest"; +import { + registerTask, + getRegisteredTask, + listRegisteredTasks, + clearRegistry, +} from "../src/registry.js"; + +describe("registry", () => { + beforeEach(() => { + clearRegistry(); + }); + + it("registers and retrieves a handler", async () => { + const handler = async () => "hello"; + registerTask("my_task", handler); + const got = getRegisteredTask("my_task"); + expect(got).toBe(handler); + }); + + it("returns undefined for unknown task_ids", () => { + expect(getRegisteredTask("nope")).toBeUndefined(); + }); + + it("lists registered task_ids", () => { + registerTask("a", async () => undefined); + registerTask("b", async () => undefined); + expect(listRegisteredTasks().sort()).toEqual(["a", "b"]); + }); + + it("rejects duplicate registration", () => { + registerTask("dup", async () => undefined); + expect(() => registerTask("dup", async () => undefined)).toThrowError( + /already registered/, + ); + }); + + it("rejects empty string task_id", () => { + expect(() => registerTask("", async () => undefined)).toThrowError( + /non-empty string/, + ); + }); + + it("rejects non-function handlers", () => { + expect(() => + registerTask("x", "not a function" as unknown as () => Promise), + ).toThrowError(/must be a function/); + }); + + it("handles dotted TaskGroup task_ids (group.task)", () => { + registerTask("transforms.normalize", async () => "ok"); + expect(getRegisteredTask("transforms.normalize")).toBeDefined(); + // Should NOT accidentally match the prefix alone + expect(getRegisteredTask("transforms")).toBeUndefined(); + expect(getRegisteredTask("normalize")).toBeUndefined(); + }); + + it("clearRegistry empties everything", () => { + registerTask("a", async () => undefined); + registerTask("b", async () => undefined); + expect(listRegisteredTasks().length).toBe(2); + clearRegistry(); + expect(listRegisteredTasks()).toEqual([]); + }); +}); diff --git a/ts-sdk/tests/utils/jwt.test.ts b/ts-sdk/tests/utils/jwt.test.ts new file mode 100644 index 0000000000000..f074811d1653b --- /dev/null +++ b/ts-sdk/tests/utils/jwt.test.ts @@ -0,0 +1,104 @@ +/*! + * 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, it, expect } from "vitest"; +import { createHmac } from "node:crypto"; +import { signEdgeJwt, pathToEdgeMethod } from "../../src/utils/jwt.js"; + +function b64urlDecode(input: string): string { + const pad = input.length % 4; + const padded = pad ? input + "=".repeat(4 - pad) : input; + const std = padded.replace(/-/g, "+").replace(/_/g, "/"); + return Buffer.from(std, "base64").toString("utf8"); +} + +describe("signEdgeJwt", () => { + const SECRET = "test-secret-not-for-prod"; + const ISSUER = "test-issuer"; + + it("produces a 3-part compact JWT", () => { + const jwt = signEdgeJwt(SECRET, { method: "worker/alpha", issuer: ISSUER }); + expect(jwt.split(".").length).toBe(3); + }); + + it("sets the HS512 header alg", () => { + const jwt = signEdgeJwt(SECRET, { method: "worker/alpha", issuer: ISSUER }); + const header = JSON.parse(b64urlDecode(jwt.split(".")[0]!)) as Record; + expect(header.alg).toBe("HS512"); + expect(header.typ).toBe("JWT"); + }); + + it("embeds the method claim and standard fields", () => { + const jwt = signEdgeJwt(SECRET, { method: "jobs/fetch/w", issuer: ISSUER, ttlSeconds: 60 }); + const payload = JSON.parse(b64urlDecode(jwt.split(".")[1]!)) as Record; + expect(payload.method).toBe("jobs/fetch/w"); + expect(payload.aud).toBe("api"); + expect(payload.iss).toBe(ISSUER); + expect(typeof payload.iat).toBe("number"); + expect(typeof payload.exp).toBe("number"); + expect((payload.exp as number) - (payload.iat as number)).toBe(60); + }); + + it("signature verifies against the shared secret (HS512)", () => { + const jwt = signEdgeJwt(SECRET, { method: "worker/alpha", issuer: ISSUER }); + const [h, p, sig] = jwt.split("."); + const expected = createHmac("sha512", SECRET) + .update(`${h}.${p}`) + .digest("base64") + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + expect(sig).toBe(expected); + }); + + it("respects custom issuer and TTL", () => { + const jwt = signEdgeJwt(SECRET, { + method: "m", + issuer: "my-worker", + ttlSeconds: 5, + }); + const payload = JSON.parse(b64urlDecode(jwt.split(".")[1]!)) as Record; + expect(payload.iss).toBe("my-worker"); + expect((payload.exp as number) - (payload.iat as number)).toBe(5); + }); +}); + +describe("pathToEdgeMethod", () => { + it("strips the /edge_worker/v1/ prefix", () => { + expect(pathToEdgeMethod("/edge_worker/v1/worker/alpha")).toBe("worker/alpha"); + }); + + it("returns a decoded path (Spike 11 bug fix)", () => { + // run_id with `:` and `+` is URL-encoded by fetch clients; + // the server verifies the DECODED path, so we must decode before signing. + const encoded = "/edge_worker/v1/jobs/state/dag_x/task_y/manual__2026-04-19T00%3A00%3A00%2B00%3A00/1/-1/success"; + const method = pathToEdgeMethod(encoded); + expect(method).toContain("manual__2026-04-19T00:00:00+00:00"); + expect(method).not.toContain("%3A"); + expect(method).not.toContain("%2B"); + }); + + it("handles paths already without the prefix", () => { + expect(pathToEdgeMethod("/something-else")).toBe("/something-else"); + }); + + it("handles paths that include but don't start with the prefix", () => { + expect(pathToEdgeMethod("http://h/edge_worker/v1/a")).toBe("a"); + }); +}); diff --git a/ts-sdk/tests/utils/retry.test.ts b/ts-sdk/tests/utils/retry.test.ts new file mode 100644 index 0000000000000..f6439610c58c8 --- /dev/null +++ b/ts-sdk/tests/utils/retry.test.ts @@ -0,0 +1,133 @@ +/*! + * 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, it, expect } from "vitest"; +import { withRetry } from "../../src/utils/retry.js"; + +describe("withRetry", () => { + it("returns on first success", async () => { + let calls = 0; + const result = await withRetry(async () => { + calls += 1; + return "ok"; + }); + expect(result).toBe("ok"); + expect(calls).toBe(1); + }); + + it("retries transient errors and eventually succeeds", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls += 1; + if (calls < 3) { + throw { isTransient: true, message: "try again" }; + } + return "ok"; + }, + { baseBackoffMs: 1 }, + ); + expect(result).toBe("ok"); + expect(calls).toBe(3); + }); + + it("fails fast on permanent errors (isTransient=false)", async () => { + let calls = 0; + await expect( + withRetry(async () => { + calls += 1; + throw { isTransient: false, message: "permanent" }; + }), + ).rejects.toEqual({ isTransient: false, message: "permanent" }); + expect(calls).toBe(1); + }); + + it("fails fast on errors without isTransient classification", async () => { + let calls = 0; + await expect( + withRetry(async () => { + calls += 1; + throw new Error("naked error"); + }), + ).rejects.toThrow("naked error"); + expect(calls).toBe(1); + }); + + it("throws the last transient error if all attempts fail", async () => { + let calls = 0; + await expect( + withRetry( + async () => { + calls += 1; + throw { isTransient: true, attempt: calls }; + }, + { maxAttempts: 3, baseBackoffMs: 1 }, + ), + ).rejects.toMatchObject({ isTransient: true, attempt: 3 }); + expect(calls).toBe(3); + }); + + it("respects AbortSignal — fails before any attempt if already aborted", async () => { + const ac = new AbortController(); + ac.abort(new Error("pre-abort")); + await expect( + withRetry( + async () => { + throw new Error("should not run"); + }, + {}, + ac.signal, + ), + ).rejects.toThrow("pre-abort"); + }); + + it("aborts during backoff sleep", async () => { + const ac = new AbortController(); + let calls = 0; + const start = Date.now(); + const promise = withRetry( + async () => { + calls += 1; + throw { isTransient: true }; + }, + { maxAttempts: 10, baseBackoffMs: 1000 }, + ac.signal, + ); + // Abort shortly after first attempt + setTimeout(() => ac.abort(new Error("cancel")), 20); + await expect(promise).rejects.toThrow("cancel"); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(500); // didn't sleep full 1000ms + expect(calls).toBe(1); + }); + + it("default maxAttempts = 3", async () => { + let calls = 0; + await expect( + withRetry( + async () => { + calls += 1; + throw { isTransient: true }; + }, + { baseBackoffMs: 1 }, + ), + ).rejects.toBeDefined(); + expect(calls).toBe(3); + }); +}); diff --git a/ts-sdk/tsconfig.build.json b/ts-sdk/tsconfig.build.json new file mode 100644 index 0000000000000..7685d3fb6268f --- /dev/null +++ b/ts-sdk/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "tests/**/*"] +} diff --git a/ts-sdk/tsconfig.json b/ts-sdk/tsconfig.json new file mode 100644 index 0000000000000..d1c294a5f23f0 --- /dev/null +++ b/ts-sdk/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "lib": ["es2023"], + "types": ["node"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + + "noEmit": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} From 490cf7d1eb5cd27e31a6faade352fa9d4db11011 Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Sat, 2 May 2026 13:22:34 -0700 Subject: [PATCH 2/2] ts-sdk: address CodeQL ReDoS warnings on jwt and worker-options regexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regexes flagged by GitHub Advanced Security as potentially polynomial on adversarial input. Real exposure is bounded (base64 padding is at most 2 chars, AIRFLOW__EDGE__API_URL is operator-controlled config), but the safer alternatives are also simpler: - src/utils/jwt.ts: replace the manual base64 → base64url conversion (`/=+\$/`, `/+/g`, `/\//g`) with Node's built-in `.toString("base64url")`. - src/worker-options.ts: replace the `/\/edge_worker\/v1.*\$/` prefix strip with indexOf/slice. No regex backtracking either way. Behavior preserved. 71/71 unit tests still pass; verified end-to-end against breeze (happy path / failure / missing-handler all green). --- ts-sdk/src/utils/jwt.ts | 8 +++----- ts-sdk/src/worker-options.ts | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/ts-sdk/src/utils/jwt.ts b/ts-sdk/src/utils/jwt.ts index 638761a4751f9..89f1d9034e62c 100644 --- a/ts-sdk/src/utils/jwt.ts +++ b/ts-sdk/src/utils/jwt.ts @@ -27,11 +27,9 @@ import { createHmac } from "node:crypto"; function b64urlEncode(input: Buffer | string): string { - return Buffer.from(input) - .toString("base64") - .replace(/=+$/, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); + // Node's built-in base64url encoding strips padding and uses URL-safe + // characters — no manual char replacement needed. + return Buffer.from(input).toString("base64url"); } interface EdgeJwtOptions { diff --git a/ts-sdk/src/worker-options.ts b/ts-sdk/src/worker-options.ts index b7f583d62c0c1..56258a18c341a 100644 --- a/ts-sdk/src/worker-options.ts +++ b/ts-sdk/src/worker-options.ts @@ -46,7 +46,12 @@ export function resolveWorkerOptions(opts: StartWorkerOptions): ResolvedWorkerOp if (!rawBaseUrl) { throw new Error("startWorker: baseUrl is required (pass in options or set AIRFLOW__EDGE__API_URL)"); } - const baseUrl = rawBaseUrl.replace(/\/edge_worker\/v1.*$/, "").replace(/\/$/, ""); + // Strip an optional `/edge_worker/v1[/...]` suffix and any trailing slash, + // leaving the host root that the SDK appends its own paths onto. Uses + // indexOf/slice rather than regex to keep ReDoS analyzers happy. + const edgeIdx = rawBaseUrl.indexOf("/edge_worker/v1"); + const root = edgeIdx >= 0 ? rawBaseUrl.slice(0, edgeIdx) : rawBaseUrl; + const baseUrl = root.endsWith("/") ? root.slice(0, -1) : root; const secret = opts.secret ?? process.env.AIRFLOW__API_AUTH__JWT_SECRET; if (!secret) {