From 5023f6c5140920eed7e8d336f80814a5719b5d0f Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 18:50:07 -0700 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20v1.1=20rollout=20=E2=80=94=20web?= =?UTF-8?q?=20UI,=20CI,=20SDK,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 4 +- .gitattributes | 3 + .github/PULL_REQUEST_TEMPLATE.md | 20 +- .github/workflows/ci.yml | 48 +- .github/workflows/release-pypi.yml | 24 + .gitignore | 14 + AGENTS.md | 16 +- CHANGELOG.md | 11 +- CLAUDE.md | 16 +- CONTRIBUTING.md | 10 +- DEVELOPMENT.md | 26 +- README.md | 35 +- RELEASE_NOTES.md | 4 +- ROADMAP.md | 50 +- SECURITY.md | 6 +- VERSIONING.md | 4 +- examples/quickstart/README.md | 10 +- pyproject.toml | 1 + scripts/quickstart_smoke.py | 85 +- src/flightdeck/cli/main.py | 366 +--- src/flightdeck/operations.py | 433 ++++ src/flightdeck/quickstart_smoke.py | 93 + src/flightdeck/sdk/__init__.py | 4 + src/flightdeck/sdk/client.py | 320 ++- src/flightdeck/server/app.py | 56 +- src/flightdeck/server/routes/__init__.py | 13 + src/flightdeck/server/routes/actions.py | 164 ++ src/flightdeck/server/routes/common.py | 24 + src/flightdeck/server/routes/ingest.py | 36 + src/flightdeck/server/routes/read.py | 34 + .../server/static/assets/index-DOd77gwY.css | 1 + .../server/static/assets/index-DzFoZmnS.js | 9 + src/flightdeck/server/static/index.html | 13 + .../policy_invalid_error_rate_gt_1_v1.json | 4 + ...icing_table_invalid_negative_price_v1.json | 11 + ...elease_artifact_invalid_wrong_kind_v1.json | 24 + .../run_event_invalid_api_version_v0.json | 28 + ...n_event_invalid_missing_release_id_v1.json | 26 + tests/test_cli_contract.py | 252 +++ tests/test_doctor.py | 55 + tests/test_quickstart_smoke.py | 3 +- tests/test_schemas.py | 18 +- tests/test_sdk_client.py | 118 +- tests/test_server_actions.py | 172 ++ web/.gitignore | 17 + web/README.md | 70 + web/e2e/smoke.spec.ts | 18 + web/index.html | 12 + web/package-lock.json | 1882 +++++++++++++++++ web/package.json | 23 + web/playwright.config.ts | 28 + web/scripts/e2e-server.mjs | 66 + web/src/App.tsx | 206 ++ web/src/index.css | 62 + web/src/main.tsx | 15 + web/src/vite-env.d.ts | 10 + web/tsconfig.json | 19 + web/tsconfig.node.json | 12 + web/vite.config.ts | 34 + 59 files changed, 4641 insertions(+), 497 deletions(-) create mode 100644 src/flightdeck/operations.py create mode 100644 src/flightdeck/quickstart_smoke.py create mode 100644 src/flightdeck/server/routes/actions.py create mode 100644 src/flightdeck/server/routes/common.py create mode 100644 src/flightdeck/server/routes/ingest.py create mode 100644 src/flightdeck/server/routes/read.py create mode 100644 src/flightdeck/server/static/assets/index-DOd77gwY.css create mode 100644 src/flightdeck/server/static/assets/index-DzFoZmnS.js create mode 100644 src/flightdeck/server/static/index.html create mode 100644 tests/fixtures/json/policy_invalid_error_rate_gt_1_v1.json create mode 100644 tests/fixtures/json/pricing_table_invalid_negative_price_v1.json create mode 100644 tests/fixtures/json/release_artifact_invalid_wrong_kind_v1.json create mode 100644 tests/fixtures/json/run_event_invalid_api_version_v0.json create mode 100644 tests/fixtures/json/run_event_invalid_missing_release_id_v1.json create mode 100644 tests/test_cli_contract.py create mode 100644 tests/test_server_actions.py create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/e2e/smoke.spec.ts create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/playwright.config.ts create mode 100644 web/scripts/e2e-server.mjs create mode 100644 web/src/App.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.cursorrules b/.cursorrules index c09eb0e..724e523 100644 --- a/.cursorrules +++ b/.cursorrules @@ -2,6 +2,6 @@ Read **`AGENTS.md`** for mission, non-goals, public contracts, engineering rules, verification, and docs policy. **`CLAUDE.md`** is a short index for Claude Code / Cursor. -Quick verify (uv): `uv sync --frozen --extra dev`, then `uv run python -m ruff check src tests`, `uv run python -m pytest`, `uv run python scripts/quickstart_smoke.py` (pip/venv equivalents in `DEVELOPMENT.md`; on Windows, `py -3` if needed). +Quick verify (uv): `uv sync --frozen --extra dev`, then `uv run python -m ruff check src tests`, `uv run python -m pytest`, `uv run flightdeck-quickstart-verify` (pip/venv equivalents in `DEVELOPMENT.md`; on Windows, `py -3` if needed). After **`web/src/`** edits: `cd web && npm ci && npm run build`, commit **`src/flightdeck/server/static/`** if it changes; optionally `npm run test:e2e` (see **`web/README.md`**). -Normative v1 direction: https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md · backlog: https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md · CLI: https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md +Normative v1 direction: https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md · backlog: https://github.com/flightdeckdev/flightdeck/blob/main/ROADMAP.md · CLI: https://github.com/flightdeckdev/flightdeck/blob/main/README.md diff --git a/.gitattributes b/.gitattributes index 79bbdf7..9d31dd4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Golden bundle checksum is sensitive to line endings on checkout (see CHANGELOG 0.7.0). tests/fixtures/golden_bundle/** text eol=lf + +# Vite build output must stay LF so `git diff --exit-code` matches on Windows CI/workstations. +src/flightdeck/server/static/** text eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4127a89..d141c28 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,9 +6,18 @@ ## Validation -- [ ] `python -m ruff check src tests` -- [ ] `python -m pytest` -- [ ] CLI smoke test, if relevant +Run the same checks as **CI** (see **`.github/workflows/ci.yml`**) before opening / updating the PR: + +- [ ] `uv sync --frozen --extra dev` +- [ ] `uv run python -m ruff check src tests` +- [ ] `uv run python -m pytest` +- [ ] `uv run python scripts/generate_schemas.py` then `git diff --exit-code schemas/` (if models/schemas touched) +- [ ] `cd web && npm ci && npm run build && cd .. && git diff --exit-code src/flightdeck/server/static/` (if **`web/src/`** or deps changed) +- [ ] `cd web && npx playwright install chromium && npm run test:e2e` (if **`web/`** changed) +- [ ] `uv run flightdeck-quickstart-verify` +- [ ] `uv run flightdeck --help` + +With **pip** / venv only, use **`python -m …`** equivalents from **`DEVELOPMENT.md`**. ## Schema / Storage Impact @@ -18,4 +27,9 @@ ## Risk +## Review + +- [ ] **Requested review** from maintainers (**[CODEOWNERS](.github/CODEOWNERS)** → **`@flightdeckdev/maintainers`** on the org repo). On a **fork**, GitHub may not auto-request; use **Reviewers** on the PR. +- [ ] PR is **small and scoped** (see **`AGENTS.md`**); linked issue or release note intent noted if helpful. + ## Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603b62..63d84cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Set up uv uses: astral-sh/setup-uv@v5 with: @@ -26,6 +33,20 @@ jobs: - name: Sync dependencies run: uv sync --frozen --extra dev + - name: Build web UI + run: | + cd web + npm ci + npm run build + cd .. + git diff --exit-code src/flightdeck/server/static/ + + - name: Playwright E2E (served UI) + run: | + cd web + npx playwright install chromium + npm run test:e2e + - name: Lint run: uv run python -m ruff check src tests @@ -38,7 +59,7 @@ jobs: git diff --exit-code schemas/ - name: Quickstart smoke (cross-platform) - run: uv run python scripts/quickstart_smoke.py + run: uv run flightdeck-quickstart-verify - name: CLI smoke run: uv run flightdeck --help @@ -54,6 +75,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Set up uv uses: astral-sh/setup-uv@v5 with: @@ -63,6 +91,22 @@ jobs: - name: Sync dependencies run: uv sync --frozen --extra dev + - name: Build web UI + shell: bash + run: | + cd web + npm ci + npm run build + cd .. + git diff --exit-code src/flightdeck/server/static/ + + - name: Playwright E2E (served UI) + shell: bash + run: | + cd web + npx playwright install chromium + npm run test:e2e + - name: Lint run: uv run python -m ruff check src tests @@ -75,7 +119,7 @@ jobs: git diff --exit-code schemas/ - name: Quickstart smoke (cross-platform) - run: uv run python scripts/quickstart_smoke.py + run: uv run flightdeck-quickstart-verify - name: CLI smoke run: uv run flightdeck --help diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index 88f6a5c..ae843ee 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -23,6 +23,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Set up uv uses: astral-sh/setup-uv@v5 with: @@ -32,6 +39,20 @@ jobs: - name: Sync locked dev environment run: uv sync --frozen --extra dev + - name: Build web UI + run: | + cd web + npm ci + npm run build + cd .. + git diff --exit-code src/flightdeck/server/static/ + + - name: Playwright E2E (served UI) + run: | + cd web + npx playwright install chromium + npm run test:e2e + - name: Verify tag matches declared package versions shell: bash env: @@ -74,6 +95,9 @@ jobs: uv run python scripts/generate_schemas.py git diff --exit-code schemas/ + - name: Quickstart smoke + run: uv run flightdeck-quickstart-verify + - name: Build distributions run: uv build diff --git a/.gitignore b/.gitignore index e6e6c91..0a70fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ pytest-cache-files-*/ .ruff_cache/ .tmp/ .venv/ +web/node_modules/ +.tools/ build/ dist/ *.egg-info/ @@ -12,6 +14,18 @@ dist/ htmlcov/ .DS_Store Thumbs.db +desktop.ini + +# npm / package managers (noise at repo root or under web/) +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# TypeScript / editor noise +*.tsbuildinfo +*.swp +*~ # Local FlightDeck workspace (SQLite ledger + local config); never commit. .flightdeck/ diff --git a/AGENTS.md b/AGENTS.md index 69d6788..67f2858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,11 @@ This tree is usually a **personal-account research repo** (`origin` → your GitHub user): local experimentation, WIP, and broad refactors are acceptable. -**Organization repos** — canonical product: **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** under [flightdeckdev](https://github.com/flightdeckdev). Only **relevant**, standards-meeting changes (tests, ruff, no secrets, changelog/version when releasing) should be pushed or PR’d there—typically via a second remote **`org`** (workflow: [git remotes](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md)). +**Organization repos** — canonical product: **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** under [flightdeckdev](https://github.com/flightdeckdev). Only **relevant**, standards-meeting changes (tests, ruff, no secrets, changelog/version when releasing) should be pushed or PR’d there—typically via a second remote **`org`** (workflow notes live in **`CONTRIBUTING.md`** in this clone). When implementing features, prefer **small, PR-shaped slices** that could ship to an org repo without extra cleanup. Do not conflate “saved in research” with “ready for org push.” -Extended maintainer docs (research workflow, org checklist, canonical publish) live on **`main`** in that repository (for example [research workflow](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md), [GitHub organization](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)). This clone may omit those paths to stay small. Claude Code / short entrypoint: **`CLAUDE.md`**. +Extended maintainer docs (research workflow, org checklist, canonical publish) live on **`main`** in that repository. This clone stays compact and keeps practical contributor guidance in **`CONTRIBUTING.md`**. Claude Code / short entrypoint: **`CLAUDE.md`**. ## Mission @@ -36,10 +36,10 @@ Do not add: Treat these as **stable API** unless a change explicitly marks an experimental path: -- **CLI:** synopsis, flags, exit codes — canonical reference: **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** (normative for scripting). -- **On-disk / wire:** `release.yaml`, run events, pricing imports, policy shape — **`schemas/`** (generated; drift caught in CI), **[docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md)** (0.x snapshot of what the code does today). -- **v1 / GA direction** (migrations, checksums, trust boundaries, defaults): **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. Prefer updating the forward spec for new v1 contracts; avoid expanding **`docs/spec.md`** as the rolling GA target. -- **Backlog and review status:** **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. +- **CLI:** synopsis, flags, exit codes — `flightdeck --help` plus command help output (normative for scripting in this slim clone). +- **On-disk / wire:** `release.yaml`, run events, pricing imports, policy shape — **`schemas/`** (generated; drift caught in CI) plus compatibility notes in **`RELEASE_NOTES.md`**. +- **v1 / GA direction** (migrations, checksums, trust boundaries, defaults): **`RELEASE_NOTES.md`** and shipped CLI/schema behavior. +- **Backlog and milestone status:** **`ROADMAP.md`**. ## Engineering rules @@ -67,7 +67,7 @@ Recommended (**[uv](https://docs.astral.sh/uv/)** — see **`DEVELOPMENT.md`**): uv sync --frozen --extra dev uv run python -m ruff check src tests uv run python -m pytest -uv run python scripts/quickstart_smoke.py +uv run flightdeck-quickstart-verify ``` After editing Pydantic models, regenerate schemas and ensure a clean diff: @@ -81,7 +81,7 @@ Fallback (activated **venv** or global tools): the same steps with **`python -m On **Windows**, use `py -3` in place of `python` if that is how your environment is set up. If pytest temp dirs fail with permissions, see **`DEVELOPMENT.md`** / **`tests/conftest.py`**. -**CI bar** (mirrors **`.github/workflows/ci.yml`** on **CPython 3.14**): **`uv sync --frozen --extra dev`**, **`uv run python -m ruff check src tests`**, **`uv run python -m pytest`**, **`uv run python scripts/generate_schemas.py`** + no **`schemas/`** diff, **`uv run python scripts/quickstart_smoke.py`**, **`uv run flightdeck --help`**. +**CI bar** (mirrors **`.github/workflows/ci.yml`** on **CPython 3.14**): see the workflow for the exact sequence; includes **`uv sync --frozen --extra dev`**, **`web/`** **`npm ci`** + **`npm run build`** + **`git diff --exit-code`** on **`static/`**, Playwright **`npm run test:e2e`**, **ruff**, **pytest**, schema drift check, **`flightdeck-quickstart-verify`**, **`flightdeck --help`**. Use a repo-local temp directory if the OS temp directory is restricted. diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4d0d8..c0e85a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,17 @@ All notable changes to FlightDeck will be documented in this file. -This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0**, documented CLI behavior (**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** on the canonical **`main`** branch), committed **`schemas/v1/`**, and **`POST /v1/events`** payloads with **`api_version` `v1`** are treated as stable public contracts unless a release notes a semver-major bump. +This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0**, documented CLI behavior (**[README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md)** on the canonical **`main`** branch), committed **`schemas/v1/`**, and **`POST /v1/events`** payloads with **`api_version` `v1`** are treated as stable public contracts unless a release notes a semver-major bump. ## Unreleased ### Added -- **`uv.lock`** and **[uv](https://docs.astral.sh/uv/)**-based workflow: **`uv sync --extra dev`** / **`uv sync --frozen --extra dev`** for reproducible installs; **`uv run …`** for commands (see **`DEVELOPMENT.md`**). -- **CI:** **`astral-sh/setup-uv`** with **`uv sync --frozen --extra dev`** and **`uv run python -m …`** (avoids **`uv run pytest`** path quirks with **`from tests.…`** imports). -- **`.github/workflows/release-pypi.yml`:** on push of **`vMAJOR.MINOR.PATCH`**, verify tag matches **`pyproject.toml`** and **`src/flightdeck/__init__.py`**, run **ruff** / **pytest** / schema drift, **`uv build`**, publish to **PyPI** via **OIDC** trusted publishing (**publish attestations**), and create a **GitHub Release** with **`dist/*`** assets (**`softprops/action-gh-release`**). -- **`tests/test_version_consistency.py`:** assert **`pyproject.toml`** **`version`** matches **`flightdeck.__version__`** (same invariant as the release workflow). +- **Quickstart:** **`flightdeck-quickstart-verify`**, **`flightdeck.quickstart_smoke`**, **`scripts/quickstart_smoke.py`**; **CI** and **PyPI release** run **`uv run flightdeck-quickstart-verify`** (release: after schema drift check). +- **HTTP SDK:** **`FlightdeckClient`** / **`AsyncFlightdeckClient`** — optional **`api_token`**, **`health`**, **`list_releases`** / **`list_promoted`** / **`list_actions`**, **`post_diff`** / **`post_promote`** / **`post_rollback`**, plus ingest batching and retries. +- **Web + E2E:** React/Vite **`web/`**, committed **`src/flightdeck/server/static/`**, FastAPI **`/assets`**; Vite dev proxy **`/v1`** / **`/health`**, optional **`VITE_FLIGHTDECK_LOCAL_API_TOKEN`**; **`.gitattributes`** LF on **`static/`**; **`web/e2e/`**, **`web/playwright.config.ts`**, **`web/scripts/e2e-server.mjs`**, **`@playwright/test`**; **CI** / **PyPI release**: **`npm ci`**, **`npm run build`**, **`git diff --exit-code static/`**, **`npx playwright install chromium`**, **`npm run test:e2e`**. +- **Tests:** CLI contracts (**`release verify`**, **`diff`**, **`history`**, **`rollback`**); invalid JSON fixtures (**`PricingTable`**, **`RunEvent`**, **`ReleaseArtifact`**). +- **Tooling:** **`uv.lock`** / **`uv sync --frozen --extra dev`** / **`astral-sh/setup-uv`**; **`.github/workflows/release-pypi.yml`** (tag ↔ **`pyproject.toml`** / **`__init__.py`**, ruff, pytest, schemas, **`uv build`**, OIDC **PyPI**, GitHub Release); **`tests/test_version_consistency.py`**. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d95a696..9610fda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Short entry for **Claude Code**, **Cursor**, and similar agents. **Authoritative policy:** root **`AGENTS.md`** (mission, non-goals, contracts, verification, doctrine). -Canonical repository (full **`docs/`** tree and org workflows): **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** (`main`). +Canonical repository (full history and maintainer workflows): **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** (`main`). ## Read first @@ -10,13 +10,13 @@ Canonical repository (full **`docs/`** tree and org workflows): **[github.com/fl |--------|------| | Agent / contributor rules | `AGENTS.md` | | Setup and local demo | `DEVELOPMENT.md` | -| CLI flags and exit codes | [docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md) (canonical repo) | -| v1 direction | [docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) | -| Shipped 0.x behavior snapshot | [docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md) | -| Backlog and milestone status | [docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md) | +| CLI flags and exit codes | [README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md) (canonical repo) | +| v1 direction | [RELEASE_NOTES.md](https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md) | +| Shipped 0.x behavior snapshot | [RELEASE_NOTES.md](https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md) | +| Backlog and milestone status | [ROADMAP.md](https://github.com/flightdeckdev/flightdeck/blob/main/ROADMAP.md) | | GA / release notes | `RELEASE_NOTES.md`, `CHANGELOG.md` | -| Org publish & staging (maintainer) | [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) | -| Repo layout & CODEOWNERS | `.github/CODEOWNERS`, [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) | +| Org publish & staging (maintainer) | [CONTRIBUTING.md](https://github.com/flightdeckdev/flightdeck/blob/main/CONTRIBUTING.md) | +| Repo layout & CODEOWNERS | `.github/CODEOWNERS`, [CONTRIBUTING.md](https://github.com/flightdeckdev/flightdeck/blob/main/CONTRIBUTING.md) | ## Verify before you finish @@ -26,7 +26,7 @@ With **uv** (recommended): uv sync --frozen --extra dev uv run python -m ruff check src tests uv run python -m pytest -uv run python scripts/quickstart_smoke.py +uv run flightdeck-quickstart-verify ``` With **pip** + venv: use **`python -m …`** equivalents in **`DEVELOPMENT.md`**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa1a394..3390807 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ With **uv** (matches CI): ```bash uv run python -m ruff check src tests uv run python -m pytest -uv run python scripts/quickstart_smoke.py +uv run flightdeck-quickstart-verify ``` With an activated **venv**: @@ -36,7 +36,7 @@ With an activated **venv**: ```bash python -m ruff check src tests python -m pytest -python scripts/quickstart_smoke.py +flightdeck-quickstart-verify ``` If you change **`pyproject.toml`** dependencies, run **`uv lock`** and commit **`uv.lock`**. Use the same checks as **CI** (see **`AGENTS.md`**) before opening a PR. @@ -45,15 +45,17 @@ If you change **`pyproject.toml`** dependencies, run **`uv lock`** and commit ** Do not commit credentials, customer data, internal strategy docs, or local ledger data. The repo ignores **`.flightdeck/`**, **`.env*`**, optional **`private/`** / **`secrets/`**, and common key/credential patterns—see **`.gitignore`** and **[SECURITY.md](SECURITY.md)**. -If this clone is your **research repo** (personal `origin`), treat **[docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md)** and **[docs/git-remotes.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md)** as the source of truth for what may go to org remotes vs what stays local. +If this clone is your **research repo** (personal `origin`), use this file as the source of truth for what may go to org remotes vs what stays local. -Before the **first push** to an org remote (or any push that should represent “org standards”), follow **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)**. The **[flightdeckdev](https://github.com/flightdeckdev)** org is for planned repos when you are ready; prefer **one solid default repo** until a real split is needed. +Before the **first push** to an org remote (or any push that should represent “org standards”), follow the pre-push checklist in this file. The **[flightdeckdev](https://github.com/flightdeckdev)** org is for planned repos when you are ready; prefer **one solid default repo** until a real split is needed. ## Pull Requests Keep PRs small and focused. Include tests for behavior changes and docs for user-facing CLI, schema, or workflow changes. +Use the **[pull request template](.github/PULL_REQUEST_TEMPLATE.md)** checklist (same bar as **`.github/workflows/ci.yml`**). **Request review** from **[CODEOWNERS](.github/CODEOWNERS)** (`@flightdeckdev/maintainers` on the org repo); on a fork, add reviewers manually so the change gets eyes before merge. + ## Commit Style Use Conventional Commits: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 558fbfe..6169f10 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,7 +38,7 @@ uv run python -m ruff check src tests uv run python -m pytest uv run flightdeck --help uv run flightdeck doctor -uv run python scripts/quickstart_smoke.py +uv run flightdeck-quickstart-verify ``` With an **activated venv** (pip or after `uv sync`): @@ -48,14 +48,32 @@ python -m ruff check src tests python -m pytest flightdeck --help flightdeck doctor -python scripts/quickstart_smoke.py +flightdeck-quickstart-verify ``` -Full command flags and exit codes: [docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md). Cross-platform quickstart parity: **`scripts/quickstart_smoke.py`** (also run in CI). +Full command flags and exit codes: [README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md). Cross-platform quickstart parity: **`flightdeck-quickstart-verify`** / **`python -m flightdeck.quickstart_smoke`** (also run in CI). **Lockfile:** when you change **`pyproject.toml`** dependencies or extras, run **`uv lock`** and commit **`uv.lock`** so CI stays **`--frozen`**-reproducible. -Before pushing to an **org** remote, follow the maintainer checklist in [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) and [docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md) ([git remotes](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md): `origin` = personal research, `org` = flightdeckdev). +## Web UI (React + Vite) + +The browser UI under **`flightdeck serve`** `/` is built from **`web/`** into **`src/flightdeck/server/static/`** (committed artifacts). After changing UI source, rebuild and commit the static output so CI passes: + +```bash +cd web +npm ci +npm run build +cd .. +git status src/flightdeck/server/static/ +``` + +**Playwright:** from **`web/`**, **`npx playwright install chromium`** once, then **`npm run test:e2e`** (matches CI after the **`static/`** diff gate; see **`web/README.md`**). + +**`npm run dev`:** proxies **`/v1`** to **`flightdeck serve`** on **`127.0.0.1:8765`** by default; copy **`web/.env.example`** to **`web/.env.local`** to set **`VITE_FLIGHTDECK_LOCAL_API_TOKEN`** when testing mutations against a token-protected server. + +See **`web/README.md`** for PR-split guidance when iterating with agents. + +Before pushing to an **org** remote, follow the maintainer checklist in **`CONTRIBUTING.md`** (`origin` = personal research, `org` = flightdeckdev). ## PyPI release (maintainers) diff --git a/README.md b/README.md index ef6f50c..08383dd 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ Current local spine: FlightDeck is **local-first** and ships as a Python CLI backed by SQLite. **v1.0.0** establishes **SemVer-stable public contracts** for the documented CLI -(**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** on `main`), +(**[README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md)** on `main`), committed **`schemas/v1/`**, and **`POST /v1/events`** with **`api_version` `v1`**. See **[RELEASE_NOTES.md](RELEASE_NOTES.md)** and -**[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. +**[RELEASE_NOTES.md](https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md)**. The product scope is still intentionally narrow (release governance, not a hosted agent platform). Not implemented yet: @@ -43,10 +43,14 @@ Not implemented yet: Shipped locally: -- `flightdeck serve` + `POST /v1/events` +- `flightdeck serve` + JSON routes under `/v1/*` (read + diff/promote/rollback + event ingest); see **Local HTTP API** below - minimal Python SDK (`flightdeck.sdk.client`) - `flightdeck release rollback` (policy-gated, audited) +### Local HTTP API + +With **`flightdeck serve`** (default bind **127.0.0.1**), the app exposes **`GET /health`**, **`GET /v1/releases`**, **`GET /v1/promoted`**, **`GET /v1/actions`**, **`POST /v1/events`**, **`POST /v1/diff`**, **`POST /v1/promote`**, and **`POST /v1/rollback`**. **`POST /v1/promote`** and **`POST /v1/rollback`** accept requests only from loopback clients unless **`FLIGHTDECK_LOCAL_API_TOKEN`** is set, in which case callers must send **`Authorization: Bearer `** (same behavior as the **`web/`** dev UI via **`VITE_FLIGHTDECK_LOCAL_API_TOKEN`**). See **[SECURITY.md](SECURITY.md)**. + ## Quickstart Install **[uv](https://docs.astral.sh/uv/getting-started/installation/)**, then from the repo root: @@ -67,10 +71,10 @@ flightdeck --help Run the cross-platform quickstart smoke (same as CI): ```bash -uv run python scripts/quickstart_smoke.py +uv run flightdeck-quickstart-verify ``` -(or **`python scripts/quickstart_smoke.py`** inside an activated venv) +(or **`python -m flightdeck.quickstart_smoke`** / **`python scripts/quickstart_smoke.py`** inside an activated venv) Or use the bash wrapper (Git Bash / WSL on Windows): @@ -101,29 +105,20 @@ flightdeck release history --agent agent_support --env local ``` The static event files in `examples/quickstart` use placeholder release IDs so the repo can ship stable examples. -Substitute them before ingestion, or run **`uv run python scripts/quickstart_smoke.py`** / **`python scripts/quickstart_smoke.py`** (venv) or **`./scripts/smoke.sh`** from Git Bash/WSL on Windows. +Substitute them before ingestion, or run **`uv run flightdeck-quickstart-verify`** / **`python -m flightdeck.quickstart_smoke`** (venv) or **`./scripts/smoke.sh`** from Git Bash/WSL on Windows. ## Documentation -This tree stays small; narrative docs live on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** (`main`): +This clone keeps docs lightweight. Core references: -- [Quickstart](https://github.com/flightdeckdev/flightdeck/blob/main/docs/quickstart.md) -- [CLI reference](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md) -- [Architecture](https://github.com/flightdeckdev/flightdeck/blob/main/docs/architecture.md) -- [Specification (0.x snapshot)](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md) -- [Forward spec — v1 GA track](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) -- [v1 next steps (backlog)](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md) -- [JSON Schemas](schemas/v1/) (in this repo) -- [Changelog](CHANGELOG.md) +- [JSON Schemas](schemas/v1/) - [Release notes (maintainer)](RELEASE_NOTES.md) - [Roadmap](ROADMAP.md) -- [Contributing](CONTRIBUTING.md) -- [CLAUDE.md](CLAUDE.md) (short agent entry; see [AGENTS.md](AGENTS.md) for full rules) +- [Versioning](VERSIONING.md) - [Development](DEVELOPMENT.md) +- [Contributing](CONTRIBUTING.md) - [Security](SECURITY.md) -- [Research clone vs org repos](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md) -- [Git remotes: personal vs org](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md) -- [GitHub org & push gate](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) +- [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md) ## Development diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bbe3a1d..0ec5df3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ # Release notes (maintainer) -High-level notes for **shipping FlightDeck**. Detailed history: **[CHANGELOG.md](CHANGELOG.md)**. Contract direction: **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. Backlog and milestone status: **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. +High-level notes for **shipping FlightDeck**. Detailed history: **[CHANGELOG.md](CHANGELOG.md)**. Backlog and milestone status: **[ROADMAP.md](ROADMAP.md)**. Narrative docs (including the CLI reference) are maintained on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** `main`; this file and **`schemas/`** ship in minimal clones. @@ -21,7 +21,7 @@ Details: **`DEVELOPMENT.md`** (PyPI release section). **v1.0.0** freezes the following unless a future **major** release says otherwise: -- **CLI** — commands, flags, and exit codes as documented in **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** (including **`release verify`** exit **2** on checksum mismatch). +- **CLI** — commands, flags, and exit codes as documented in **[README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md)** (including **`release verify`** exit **2** on checksum mismatch). - **JSON Schemas** — committed **`schemas/v1/`** for **`api_version` `v1`** payloads; regenerate via **`python scripts/generate_schemas.py`**; CI guards drift. - **HTTP** — **`POST /v1/events`**, envelope **`{ "events": [ … ] }`**, per-event **`api_version`** omitted or **`"v1"`**; rejects other values with **HTTP 400** and the stable **`detail`** format covered by tests. - **SQLite** — forward-only numbered migrations; **`flightdeck doctor`** checks migrations and **`audit_seq`** continuity as shipped. diff --git a/ROADMAP.md b/ROADMAP.md index 732e03d..32f6194 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,14 +11,52 @@ - Local HTTP ingestion (`flightdeck serve`) - Rollback command (`flightdeck release rollback`) -## Next (post-1.0 — see **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**) +## Next Steps (Execution Plan) -- **v1.0.0 — shipped:** SemVer-stable CLI + **`schemas/v1/`** + **`POST /v1/events`** **`v1`**; **[RELEASE_NOTES.md](RELEASE_NOTES.md)**; **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. -- **Prior milestones — shipped:** **0.6** (**`audit_seq`**, **`doctor`**), **0.7** (**`release verify`**, golden bundle, CI schema drift), **0.8** ([docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md), **`quickstart_smoke.py`**), **0.9** (HTTP **`api_version`** tests, JSON fixtures, maintainer release notes). -- JSON Schemas: optional expansion of golden fixtures + explicit **schema compatibility** policy text as the surface grows. -- Expand SDK: retries, batching, optional async client ([spec-v1-forward](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) §6). +### Target next release: v1.1.0 (UI-assisted local operations) -Normative direction: **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)** · backlog: **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)** · **canonical publish / staging** (full maintainer runbook): **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** · **[docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md)** remains the 0.x implementation narrative snapshot. +- Deliver a minimal local UI that complements the CLI, not replaces it. +- Keep local-first trust boundaries: no hosted control plane dependency and no default remote services. +- Ship UI only for workflows already stable in CLI to avoid contract drift. + +### Phase 1: Stabilize v1 operations (next 2-4 weeks) + +- Finalize CLI contract coverage around `release diff`, `release promote`, `release rollback`, and `doctor`. +- Expand schema fixture coverage in `tests/fixtures/json/` for edge-case payloads and error paths. +- Tighten release audit tests for promotion and rollback history ordering (`audit_seq` continuity). +- Keep local-first reliability high on Windows by maintaining temp-dir and SQLite lock regression tests. + +### Phase 2: UI MVP + developer ergonomics (next 1-2 months) + +UI MVP scope (ship fast): + +- Read-only release timeline view (registered releases, promoted pointer, recent actions). +- Diff runner form (`baseline`, `candidate`, `window`, filters) with confidence and policy output. +- Promotion history panel with reasons and PASS/FAIL status. +- Safe action guardrails in UI: require reason text for promote/rollback and show confirmation prompts. + +Implementation constraints for UI MVP: + +- UI reads/writes through existing local HTTP/CLI contracts only. +- Reuse existing validation and policy paths; no separate business logic stack in UI. +- Include parity tests for one end-to-end path (CLI vs UI-triggered operation producing the same outcome). + +- **Shipped (slim repo):** Python SDK retries, batching, **`AsyncFlightdeckClient`**, HTTP read/mutate helpers + optional **`api_token`**; **`flightdeck-quickstart-verify`**; Playwright smoke under **`web/e2e/`** (see **`web/README.md`**). +- Refine actionable CLI errors for pricing/model mismatches and unsupported policy states. + +### Phase 3: Hardening and scale signals (next 1-2 quarters) + +- Formalize schema compatibility guidance for additive vs breaking payload evolution. +- Add larger-window ledger test scenarios to validate confidence labels under sparse and bursty traffic. +- Expand policy evaluation coverage for mixed rollout conditions (cost, latency, and error-rate interactions). +- Continue publishing-only hardening (tag/version checks, schema drift checks, reproducible builds). + +## References + +- Contract and release posture: **[RELEASE_NOTES.md](RELEASE_NOTES.md)** +- Versioning policy: **[VERSIONING.md](VERSIONING.md)** +- Contributor and org-push guidance: **[CONTRIBUTING.md](CONTRIBUTING.md)** +- Canonical repository: **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** ## Later diff --git a/SECURITY.md b/SECURITY.md index 337fa12..08307ee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,4 +32,8 @@ company information in issues, discussions, examples, or tests. - **Optional local-only trees:** **`private/`** and **`secrets/`** are gitignored for material that must not ship with the public tree. - **Keys / certs:** patterns such as **`*.pem`**, **`*.p12`**, and common `*credentials*.json` names are ignored—still review `git status` before every commit. -See **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** for a pre-push checklist aligned with the **[flightdeckdev](https://github.com/flightdeckdev)** org. +See **[CONTRIBUTING.md](CONTRIBUTING.md)** for a pre-push checklist aligned with the **[flightdeckdev](https://github.com/flightdeckdev)** org. + +## Local HTTP API (`flightdeck serve`) + +The bundled server is intended for **local development and demos**. **`POST /v1/promote`** and **`POST /v1/rollback`** are gated so that, with no token configured, only **loopback** clients can invoke them. If you set **`FLIGHTDECK_LOCAL_API_TOKEN`**, every mutation request must include **`Authorization: Bearer `**; use a strong random value and treat it like a local secret. Do not expose **`flightdeck serve`** on untrusted networks without understanding that **`POST /v1/events`** and **`POST /v1/diff`** are not behind the same Bearer gate (ingest and diff are still local-trust assumptions). diff --git a/VERSIONING.md b/VERSIONING.md index 51f40c3..5ae29e2 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -6,7 +6,7 @@ FlightDeck uses package versions and schema versions separately. Package releases use [Semantic Versioning](https://semver.org/). -From **`v1.0.0`**, documented CLI behavior (**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)**), committed **`schemas/v1/`**, and +From **`v1.0.0`**, documented CLI behavior (**[README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md)**), committed **`schemas/v1/`**, and **`POST /v1/events`** **`api_version` `v1`** payloads are **stable public contracts** except when a **major** release explicitly documents a break. Breaking changes must appear in **[CHANGELOG.md](CHANGELOG.md)** and, when user-visible, **[RELEASE_NOTES.md](RELEASE_NOTES.md)**. @@ -31,7 +31,7 @@ are still the mechanism for evolving the on-disk schema safely. ## Stable contracts (v1.0.0) -The **v1.0.0** freeze is summarized in **[RELEASE_NOTES.md](RELEASE_NOTES.md)** and **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**; milestone status lives in **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. +The **v1.0.0** freeze is summarized in **[RELEASE_NOTES.md](RELEASE_NOTES.md)**; milestone status lives in **[ROADMAP.md](ROADMAP.md)**. ## PyPI packages diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 30e6b41..a3449d2 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -7,10 +7,12 @@ These files are meant to be copied or substituted locally: - `policy.yaml` is an example active policy used by `release diff` and `release promote`. - `*-events.jsonl` contain placeholder `release_id` values (`__BASELINE_RELEASE_ID__`, `__CANDIDATE_RELEASE_ID__`). -Fastest path: +Fastest path (from **repository root**, with **uv**): -- Run `../../scripts/smoke.sh` from a Unix shell (Git Bash/WSL on Windows). +```bash +uv run flightdeck-quickstart-verify +``` -Manual path: +Or **`python -m flightdeck.quickstart_smoke`** / **`py -3 -m flightdeck.quickstart_smoke`** in an activated venv. Unix shell alternative from this directory: **`../../scripts/smoke.sh`** (Git Bash / WSL on Windows). -- See [docs/quickstart.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/quickstart.md). +Manual step-by-step: root **[README.md](../../README.md)**. diff --git a/pyproject.toml b/pyproject.toml index e3d30a6..0057f57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ Changelog = "https://github.com/flightdeckdev/flightdeck/blob/main/CHANGELOG.md" [project.scripts] flightdeck = "flightdeck.cli.main:cli" +flightdeck-quickstart-verify = "flightdeck.quickstart_smoke:quickstart_verify_main" [tool.hatch.build.targets.wheel] packages = ["src/flightdeck"] diff --git a/scripts/quickstart_smoke.py b/scripts/quickstart_smoke.py index a8b9fc7..2ba4b97 100644 --- a/scripts/quickstart_smoke.py +++ b/scripts/quickstart_smoke.py @@ -1,89 +1,10 @@ #!/usr/bin/env python3 -"""Cross-platform quickstart smoke (no bash): mirrors examples/quickstart + canonical quickstart flow.""" +"""Compatibility wrapper; prefer ``uv run flightdeck-quickstart-verify`` or ``python -m flightdeck.quickstart_smoke``.""" from __future__ import annotations -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -REPO = Path(__file__).resolve().parents[1] -QS = REPO / "examples" / "quickstart" -BASELINE_PH = "__BASELINE_RELEASE_ID__" -CANDIDATE_PH = "__CANDIDATE_RELEASE_ID__" - - -def _flightdeck_cmd() -> list[str]: - exe = shutil.which("flightdeck") - if exe: - return [exe] - return [sys.executable, "-m", "flightdeck.cli.main"] - - -def _run(fd: list[str], *args: str, cwd: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [*fd, *args], - cwd=cwd, - check=True, - text=True, - capture_output=True, - ) - - -def main() -> None: - fd = _flightdeck_cmd() - with tempfile.TemporaryDirectory(prefix="fd_qs_", ignore_cleanup_errors=True) as tmp_s: - tmp = Path(tmp_s) - baseline_events = (tmp / "baseline-events.jsonl") - candidate_events = (tmp / "candidate-events.jsonl") - - _run(fd, "init", cwd=tmp) - _run(fd, "pricing", "import", str(QS / "pricing-baseline.yaml"), cwd=tmp) - _run(fd, "pricing", "import", str(QS / "pricing-candidate.yaml"), cwd=tmp) - _run(fd, "policy", "set", str(QS / "policy.yaml"), cwd=tmp) - - reg_b = _run(fd, "release", "register", str(QS / "baseline-release"), cwd=tmp) - baseline_id = reg_b.stdout.strip() - reg_c = _run(fd, "release", "register", str(QS / "candidate-release"), cwd=tmp) - candidate_id = reg_c.stdout.strip() - - baseline_events.write_text( - (QS / "baseline-events.jsonl").read_text(encoding="utf-8").replace(BASELINE_PH, baseline_id), - encoding="utf-8", - ) - candidate_events.write_text( - (QS / "candidate-events.jsonl").read_text(encoding="utf-8").replace(CANDIDATE_PH, candidate_id), - encoding="utf-8", - ) - - _run(fd, "runs", "ingest", str(baseline_events), cwd=tmp) - _run(fd, "runs", "ingest", str(candidate_events), cwd=tmp) - _run(fd, "release", "diff", baseline_id, candidate_id, "--window", "7d", cwd=tmp) - _run( - fd, - "release", - "promote", - baseline_id, - "--env", - "local", - "--window", - "7d", - "--reason", - "quickstart smoke", - cwd=tmp, - ) - _run(fd, "release", "history", "--agent", "agent_support", "--env", "local", cwd=tmp) - _run(fd, "release", "verify", baseline_id, "--path", str(QS / "baseline-release"), cwd=tmp) - _run(fd, "doctor", cwd=tmp) - - print("quickstart_smoke: OK") +from flightdeck.quickstart_smoke import quickstart_verify_main if __name__ == "__main__": - try: - main() - except subprocess.CalledProcessError as e: - print(e.stderr or e.stdout or str(e), file=sys.stderr) - sys.exit(e.returncode or 1) + quickstart_verify_main() diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py index 4f2b23c..8c252a2 100644 --- a/src/flightdeck/cli/main.py +++ b/src/flightdeck/cli/main.py @@ -15,17 +15,21 @@ from flightdeck.bundle import bundle_checksum from flightdeck.config import DEFAULT_CONFIG_FILENAME, load_config, write_default_config from flightdeck.doctor import run_doctor -from flightdeck.ledger import diff_releases, parse_window from flightdeck.models import ( Policy, - PolicyResult, PricingTable, - PromotionRecord, ReleaseArtifact, ReleaseRecord, RunEvent, utc_now, ) +from flightdeck.operations import ( + OperationError, + compute_diff, + default_policy, + promote_release, + rollback_release, +) from flightdeck.storage import Storage @@ -35,16 +39,6 @@ def read_release_artifact(path: Path) -> ReleaseArtifact: return ReleaseArtifact.model_validate(data) -def default_policy() -> Policy: - # When no policy file is set, match Policy model defaults (strict diff confidence for v1 track). - # Demos and tests set an explicit policy YAML (e.g. require_high_diff_confidence: false) where needed. - return Policy( - max_cost_per_run_usd=None, - max_latency_ms=None, - max_error_rate=None, - ) - - def actor_name() -> str: return os.environ.get("USER") or os.environ.get("USERNAME") or "unknown" @@ -109,14 +103,14 @@ def doctor_cmd() -> None: @click.option("--port", default=8765, show_default=True) @click.option("--reload", is_flag=True, default=False) def serve(host: str, port: int, reload: bool) -> None: - """Start the local FlightDeck HTTP service (event ingestion).""" + """Start the local FlightDeck HTTP service (ingest + local release operations).""" import uvicorn loopback_hosts = {"127.0.0.1", "::1", "localhost"} if host.strip() not in loopback_hosts: click.echo( - f"Warning: binding to {host!r} exposes HTTP ingest without authentication; " - "use only on trusted networks (see https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md §4).", + f"Warning: binding to {host!r} may expose local ingest/action endpoints; " + "use trusted networks and set a local API token when needed.", err=True, ) @@ -339,124 +333,49 @@ def release_diff( cfg = load_config() storage = Storage(cfg.db_path) storage.migrate() - - base = storage.get_release(baseline_release_id) - cand = storage.get_release(candidate_release_id) - if not base: - raise click.ClickException(f"Unknown baseline release: {baseline_release_id}") - if not cand: - raise click.ClickException(f"Unknown candidate release: {candidate_release_id}") - - env = environment or cfg.default_environment - - # Load artifacts and enforce same agent_id unless an explicit escape hatch exists later. - base_artifact = ReleaseArtifact.model_validate(base.artifact_json) - cand_artifact = ReleaseArtifact.model_validate(cand.artifact_json) - if base_artifact.spec.agent.agent_id != cand_artifact.spec.agent.agent_id: - raise click.ClickException( - "Cross-agent diff is not allowed. " - f"Baseline agent_id={base_artifact.spec.agent.agent_id}, " - f"candidate agent_id={cand_artifact.spec.agent.agent_id}." - ) - - base_pricing_ref = base_artifact.spec.pricing_reference - cand_pricing_ref = cand_artifact.spec.pricing_reference - - base_table = storage.get_pricing_table(base_pricing_ref.provider, base_pricing_ref.pricing_version) - if not base_table: - raise click.ClickException( - f"Missing pricing table for baseline {base_pricing_ref.provider}/{base_pricing_ref.pricing_version}. " - f"Run `flightdeck pricing import ...`." - ) - - cand_table = storage.get_pricing_table(cand_pricing_ref.provider, cand_pricing_ref.pricing_version) - if not cand_table: - raise click.ClickException( - f"Missing pricing table for candidate {cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version}. " - f"Run `flightdeck pricing import ...`." - ) - try: - delta = parse_window(window) - except ValueError as e: - raise click.ClickException(str(e)) from e - until = utc_now() - since = until - delta - - baseline_events = storage.query_runs( - baseline_release_id, - since, - until, - tenant_id=tenant_id, - task_id=task_id, - environment=env, - ) - candidate_events = storage.query_runs( - candidate_release_id, - since, - until, - tenant_id=tenant_id, - task_id=task_id, - environment=env, - ) - - policy = storage.get_active_policy() or default_policy() - try: - result = diff_releases( + result = compute_diff( cfg=cfg, - policy=policy, - baseline_events=baseline_events, - candidate_events=candidate_events, - baseline_pricing_table=base_table, - candidate_pricing_table=cand_table, + storage=storage, + baseline_release_id=baseline_release_id, + candidate_release_id=candidate_release_id, window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, ) - except KeyError as e: - # Make missing-model pricing failures explicit and actionable. - raise click.ClickException( - f"Pricing table missing model entry. " - f"baseline_model={base_artifact.spec.runtime.model} " - f"candidate_model={cand_artifact.spec.runtime.model}. " - f"Check pricing tables: " - f"{base_pricing_ref.provider}/{base_pricing_ref.pricing_version} and " - f"{cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version}." - ) from e - except ValueError as e: + except OperationError as e: raise click.ClickException(str(e)) from e - click.echo(f"Window: {window} ({since.isoformat()} .. {until.isoformat()})") - click.echo(f"Filters: env={env} tenant={tenant_id or '*'} task={task_id or '*'}") + click.echo(f"Window: {window} ({result.since.isoformat()} .. {result.until.isoformat()})") + click.echo(f"Filters: env={result.environment} tenant={tenant_id or '*'} task={task_id or '*'}") click.echo( - f"Baseline pricing: {base_pricing_ref.provider}/{base_pricing_ref.pricing_version} " - f"(model={base_artifact.spec.runtime.model})" + f"Baseline pricing: {result.baseline_pricing_provider}/{result.baseline_pricing_version} " + f"(model={result.baseline_model})" ) click.echo( - f"Candidate pricing: {cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version} " - f"(model={cand_artifact.spec.runtime.model})" + f"Candidate pricing: {result.candidate_pricing_provider}/{result.candidate_pricing_version} " + f"(model={result.candidate_model})" ) - if ( - base_pricing_ref.provider != cand_pricing_ref.provider - or base_pricing_ref.pricing_version != cand_pricing_ref.pricing_version - or base_artifact.spec.runtime.model != cand_artifact.spec.runtime.model - ): - click.echo( - "NOTE: cost delta includes pricing/model assumption changes (pricing reference and/or model differ)." - ) + if result.pricing_or_model_changed: + click.echo("NOTE: cost delta includes pricing/model assumption changes (pricing reference and/or model differ).") click.echo(f"Samples: baseline={result.baseline_runs} candidate={result.candidate_runs}") - click.echo(f"Confidence: {result.confidence}" + (f" ({result.confidence_reason})" if result.confidence_reason else "")) + click.echo( + f"Confidence: {result.confidence}" + (f" ({result.confidence_reason})" if result.confidence_reason else "") + ) click.echo("") click.echo( - f"Estimated model token cost/run (USD): {result.baseline.cost_per_run_usd:.6f} -> {result.candidate.cost_per_run_usd:.6f} " - f"(delta {result.delta_cost_per_run_usd:+.6f}" + f"Estimated model token cost/run (USD): {result.baseline_cost_per_run_usd:.6f} -> " + f"{result.candidate_cost_per_run_usd:.6f} (delta {result.delta_cost_per_run_usd:+.6f}" + (f", {result.delta_cost_per_run_pct:+.2%})" if result.delta_cost_per_run_pct is not None else ")") ) - if result.baseline.latency_ms_avg is not None and result.candidate.latency_ms_avg is not None: + if result.baseline_latency_ms_avg is not None and result.candidate_latency_ms_avg is not None: click.echo( - f"Latency avg (ms): {result.baseline.latency_ms_avg:.2f} -> {result.candidate.latency_ms_avg:.2f} " + f"Latency avg (ms): {result.baseline_latency_ms_avg:.2f} -> {result.candidate_latency_ms_avg:.2f} " f"(delta {result.delta_latency_ms_avg:+.2f})" ) click.echo( - f"Error rate: {result.baseline.error_rate:.4f} -> {result.candidate.error_rate:.4f} " + f"Error rate: {result.baseline_error_rate:.4f} -> {result.candidate_error_rate:.4f} " f"(delta {result.delta_error_rate:+.4f})" ) click.echo("") @@ -475,116 +394,28 @@ def release_promote(release_id: str, environment: str, window: str, reason: str) cfg = load_config() storage = Storage(cfg.db_path) storage.migrate() - - target = storage.get_release(release_id) - if not target: - raise click.ClickException(f"Unknown release: {release_id}") - - target_artifact = ReleaseArtifact.model_validate(target.artifact_json) - agent_id = target_artifact.spec.agent.agent_id - active_policy = storage.get_active_policy() or default_policy() - current_release_id = storage.get_promoted_release_id(agent_id, environment) - - policy_result: PolicyResult - if not current_release_id: - policy_result = PolicyResult( - passed=True, - reasons=["first promotion: no promoted baseline for agent/environment"], - ) - else: - baseline = storage.get_release(current_release_id) - if not baseline: - raise click.ClickException(f"Promoted baseline release is missing: {current_release_id}") - - baseline_artifact = ReleaseArtifact.model_validate(baseline.artifact_json) - baseline_pricing_ref = baseline_artifact.spec.pricing_reference - candidate_pricing_ref = target_artifact.spec.pricing_reference - - baseline_table = storage.get_pricing_table( - baseline_pricing_ref.provider, - baseline_pricing_ref.pricing_version, - ) - if not baseline_table: - raise click.ClickException( - "Missing pricing table for promoted baseline " - f"{baseline_pricing_ref.provider}/{baseline_pricing_ref.pricing_version}. " - "Run `flightdeck pricing import ...`." - ) - - candidate_table = storage.get_pricing_table( - candidate_pricing_ref.provider, - candidate_pricing_ref.pricing_version, - ) - if not candidate_table: - raise click.ClickException( - "Missing pricing table for candidate " - f"{candidate_pricing_ref.provider}/{candidate_pricing_ref.pricing_version}. " - "Run `flightdeck pricing import ...`." - ) - - try: - delta = parse_window(window) - except ValueError as e: - raise click.ClickException(str(e)) from e - until = utc_now() - since = until - delta - baseline_events = storage.query_runs( - current_release_id, - since, - until, - environment=environment, - ) - candidate_events = storage.query_runs( - release_id, - since, - until, + try: + outcome = promote_release( + cfg=cfg, + storage=storage, + release_id=release_id, environment=environment, + window=window, + reason=reason, + actor=actor_name(), ) + except OperationError as e: + raise click.ClickException(str(e)) from e - try: - diff = diff_releases( - cfg=cfg, - policy=active_policy, - baseline_events=baseline_events, - candidate_events=candidate_events, - baseline_pricing_table=baseline_table, - candidate_pricing_table=candidate_table, - window=window, - ) - except KeyError as e: - raise click.ClickException( - "Pricing table missing model entry. " - f"baseline_model={baseline_artifact.spec.runtime.model} " - f"candidate_model={target_artifact.spec.runtime.model}." - ) from e - except ValueError as e: - raise click.ClickException(str(e)) from e - policy_result = diff.policy - - record = PromotionRecord( - action_id=f"act_{uuid4().hex[:12]}", - action="promote", - actor=actor_name(), - release_id=release_id, - agent_id=agent_id, - environment=environment, - reason=reason, - policy_result=policy_result, - baseline_release_id=current_release_id, - created_at=utc_now(), - ) - - if not policy_result.passed: - storage.insert_promotion_record(record) + if not outcome.policy.passed: click.echo("Policy: FAIL") - for r in policy_result.reasons: + for r in outcome.policy.reasons: click.echo(f"- {r}") raise click.ClickException("Promotion blocked by policy") - storage.commit_promotion(record, new_promoted_release_id=release_id) - click.echo(f"Promoted {release_id} for {agent_id}/{environment}") + click.echo(f"Promoted {release_id} for {outcome.agent_id}/{environment}") click.echo("Policy: PASS") - for r in policy_result.reasons: + for r in outcome.policy.reasons: click.echo(f"- {r}") @@ -598,107 +429,28 @@ def release_rollback(release_id: str, environment: str, window: str, reason: str cfg = load_config() storage = Storage(cfg.db_path) storage.migrate() - - target = storage.get_release(release_id) - if not target: - raise click.ClickException(f"Unknown release: {release_id}") - - target_artifact = ReleaseArtifact.model_validate(target.artifact_json) - agent_id = target_artifact.spec.agent.agent_id - active_policy = storage.get_active_policy() or default_policy() - current_release_id = storage.get_promoted_release_id(agent_id, environment) - if not current_release_id: - raise click.ClickException("No promoted release exists for this agent/environment; nothing to roll back to.") - - baseline = storage.get_release(current_release_id) - if not baseline: - raise click.ClickException(f"Promoted baseline release is missing: {current_release_id}") - - baseline_artifact = ReleaseArtifact.model_validate(baseline.artifact_json) - baseline_pricing_ref = baseline_artifact.spec.pricing_reference - candidate_pricing_ref = target_artifact.spec.pricing_reference - - baseline_table = storage.get_pricing_table(baseline_pricing_ref.provider, baseline_pricing_ref.pricing_version) - if not baseline_table: - raise click.ClickException( - "Missing pricing table for promoted baseline " - f"{baseline_pricing_ref.provider}/{baseline_pricing_ref.pricing_version}. " - "Run `flightdeck pricing import ...`." - ) - - candidate_table = storage.get_pricing_table(candidate_pricing_ref.provider, candidate_pricing_ref.pricing_version) - if not candidate_table: - raise click.ClickException( - "Missing pricing table for rollback target " - f"{candidate_pricing_ref.provider}/{candidate_pricing_ref.pricing_version}. " - "Run `flightdeck pricing import ...`." - ) - - try: - delta = parse_window(window) - except ValueError as e: - raise click.ClickException(str(e)) from e - until = utc_now() - since = until - delta - - baseline_events = storage.query_runs( - current_release_id, - since, - until, - environment=environment, - ) - candidate_events = storage.query_runs( - release_id, - since, - until, - environment=environment, - ) - try: - diff = diff_releases( + outcome = rollback_release( cfg=cfg, - policy=active_policy, - baseline_events=baseline_events, - candidate_events=candidate_events, - baseline_pricing_table=baseline_table, - candidate_pricing_table=candidate_table, + storage=storage, + release_id=release_id, + environment=environment, window=window, + reason=reason, + actor=actor_name(), ) - except KeyError as e: - raise click.ClickException( - "Pricing table missing model entry. " - f"baseline_model={baseline_artifact.spec.runtime.model} " - f"candidate_model={target_artifact.spec.runtime.model}." - ) from e - except ValueError as e: + except OperationError as e: raise click.ClickException(str(e)) from e - policy_result = diff.policy - - record = PromotionRecord( - action_id=f"act_{uuid4().hex[:12]}", - action="rollback", - actor=actor_name(), - release_id=release_id, - agent_id=agent_id, - environment=environment, - reason=reason, - policy_result=policy_result, - baseline_release_id=current_release_id, - created_at=utc_now(), - ) - - if not policy_result.passed: - storage.insert_promotion_record(record) + if not outcome.policy.passed: click.echo("Policy: FAIL") - for r in policy_result.reasons: + for r in outcome.policy.reasons: click.echo(f"- {r}") raise click.ClickException("Rollback blocked by policy") - storage.commit_promotion(record, new_promoted_release_id=release_id) - click.echo(f"Rolled back to {release_id} for {agent_id}/{environment}") + click.echo(f"Rolled back to {release_id} for {outcome.agent_id}/{environment}") click.echo("Policy: PASS") - for r in policy_result.reasons: + for r in outcome.policy.reasons: click.echo(f"- {r}") diff --git a/src/flightdeck/operations.py b/src/flightdeck/operations.py new file mode 100644 index 0000000..60e4145 --- /dev/null +++ b/src/flightdeck/operations.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal +from uuid import uuid4 + +from flightdeck.ledger import diff_releases, parse_window +from flightdeck.models import ( + Policy, + PolicyResult, + PromotionRecord, + ReleaseArtifact, + ReleaseRecord, + WorkspaceConfig, + utc_now, +) +from flightdeck.storage import Storage + + +class OperationError(ValueError): + """User-facing operation error that callers can map to CLI/HTTP surfaces.""" + + +@dataclass(frozen=True) +class DiffOutcome: + window: str + since: datetime + until: datetime + environment: str + tenant_id: str | None + task_id: str | None + baseline_pricing_provider: str + baseline_pricing_version: str + baseline_model: str + candidate_pricing_provider: str + candidate_pricing_version: str + candidate_model: str + pricing_or_model_changed: bool + baseline_runs: int + candidate_runs: int + confidence: str + confidence_reason: str | None + baseline_cost_per_run_usd: float + candidate_cost_per_run_usd: float + delta_cost_per_run_usd: float + delta_cost_per_run_pct: float | None + baseline_latency_ms_avg: float | None + candidate_latency_ms_avg: float | None + delta_latency_ms_avg: float | None + baseline_error_rate: float + candidate_error_rate: float + delta_error_rate: float + policy: PolicyResult + + +@dataclass(frozen=True) +class ActionOutcome: + action_id: str + action: str + release_id: str + agent_id: str + environment: str + baseline_release_id: str | None + promoted_pointer_changed: bool + policy: PolicyResult + + +@dataclass(frozen=True) +class TimelineOutcome: + releases: list[dict[str, object]] + promoted: list[dict[str, str]] + actions: list[dict[str, object]] + + +def default_policy() -> Policy: + return Policy( + max_cost_per_run_usd=None, + max_latency_ms=None, + max_error_rate=None, + ) + + +def _load_release_or_error(storage: Storage, release_id: str, *, role: str) -> tuple[ReleaseRecord, ReleaseArtifact]: + record = storage.get_release(release_id) + if not record: + if role == "baseline": + raise OperationError(f"Unknown baseline release: {release_id}") + if role == "candidate": + raise OperationError(f"Unknown candidate release: {release_id}") + raise OperationError(f"Unknown release: {release_id}") + return record, ReleaseArtifact.model_validate(record.artifact_json) + + +def _load_pricing_or_error(storage: Storage, *, provider: str, version: str, role: str): + table = storage.get_pricing_table(provider, version) + if table: + return table + if role == "baseline": + raise OperationError(f"Missing pricing table for baseline {provider}/{version}. Run `flightdeck pricing import ...`.") + if role == "candidate": + raise OperationError( + f"Missing pricing table for candidate {provider}/{version}. Run `flightdeck pricing import ...`." + ) + if role == "rollback": + raise OperationError( + f"Missing pricing table for rollback target {provider}/{version}. Run `flightdeck pricing import ...`." + ) + raise OperationError( + f"Missing pricing table for promoted baseline {provider}/{version}. Run `flightdeck pricing import ...`." + ) + + +def compute_diff( + *, + cfg: WorkspaceConfig, + storage: Storage, + baseline_release_id: str, + candidate_release_id: str, + window: str, + environment: str | None, + tenant_id: str | None, + task_id: str | None, +) -> DiffOutcome: + _, base_artifact = _load_release_or_error(storage, baseline_release_id, role="baseline") + _, cand_artifact = _load_release_or_error(storage, candidate_release_id, role="candidate") + + if base_artifact.spec.agent.agent_id != cand_artifact.spec.agent.agent_id: + raise OperationError( + "Cross-agent diff is not allowed. " + f"Baseline agent_id={base_artifact.spec.agent.agent_id}, " + f"candidate agent_id={cand_artifact.spec.agent.agent_id}." + ) + + env = environment or cfg.default_environment + base_ref = base_artifact.spec.pricing_reference + cand_ref = cand_artifact.spec.pricing_reference + base_table = _load_pricing_or_error(storage, provider=base_ref.provider, version=base_ref.pricing_version, role="baseline") + cand_table = _load_pricing_or_error(storage, provider=cand_ref.provider, version=cand_ref.pricing_version, role="candidate") + + try: + delta = parse_window(window) + except ValueError as e: + raise OperationError(str(e)) from e + + until = utc_now() + since = until - delta + baseline_events = storage.query_runs( + baseline_release_id, + since, + until, + tenant_id=tenant_id, + task_id=task_id, + environment=env, + ) + candidate_events = storage.query_runs( + candidate_release_id, + since, + until, + tenant_id=tenant_id, + task_id=task_id, + environment=env, + ) + + policy = storage.get_active_policy() or default_policy() + try: + diff = diff_releases( + cfg=cfg, + policy=policy, + baseline_events=baseline_events, + candidate_events=candidate_events, + baseline_pricing_table=base_table, + candidate_pricing_table=cand_table, + window=window, + ) + except KeyError as e: + raise OperationError( + "Pricing table missing model entry. " + f"baseline_model={base_artifact.spec.runtime.model} " + f"candidate_model={cand_artifact.spec.runtime.model}. " + f"Check pricing tables: {base_ref.provider}/{base_ref.pricing_version} and " + f"{cand_ref.provider}/{cand_ref.pricing_version}." + ) from e + except ValueError as e: + raise OperationError(str(e)) from e + + return DiffOutcome( + window=window, + since=since, + until=until, + environment=env, + tenant_id=tenant_id, + task_id=task_id, + baseline_pricing_provider=base_ref.provider, + baseline_pricing_version=base_ref.pricing_version, + baseline_model=base_artifact.spec.runtime.model, + candidate_pricing_provider=cand_ref.provider, + candidate_pricing_version=cand_ref.pricing_version, + candidate_model=cand_artifact.spec.runtime.model, + pricing_or_model_changed=( + base_ref.provider != cand_ref.provider + or base_ref.pricing_version != cand_ref.pricing_version + or base_artifact.spec.runtime.model != cand_artifact.spec.runtime.model + ), + baseline_runs=diff.baseline_runs, + candidate_runs=diff.candidate_runs, + confidence=diff.confidence, + confidence_reason=diff.confidence_reason, + baseline_cost_per_run_usd=diff.baseline.cost_per_run_usd, + candidate_cost_per_run_usd=diff.candidate.cost_per_run_usd, + delta_cost_per_run_usd=diff.delta_cost_per_run_usd, + delta_cost_per_run_pct=diff.delta_cost_per_run_pct, + baseline_latency_ms_avg=diff.baseline.latency_ms_avg, + candidate_latency_ms_avg=diff.candidate.latency_ms_avg, + delta_latency_ms_avg=diff.delta_latency_ms_avg, + baseline_error_rate=diff.baseline.error_rate, + candidate_error_rate=diff.candidate.error_rate, + delta_error_rate=diff.delta_error_rate, + policy=diff.policy, + ) + + +def _evaluate_promotion_or_rollback( + *, + cfg: WorkspaceConfig, + storage: Storage, + action: Literal["promote", "rollback"], + release_id: str, + environment: str, + window: str, + reason: str, + actor: str, +) -> ActionOutcome: + if not reason.strip(): + raise OperationError("Reason is required for promote/rollback actions.") + + _, target_artifact = _load_release_or_error(storage, release_id, role="target") + agent_id = target_artifact.spec.agent.agent_id + current_release_id = storage.get_promoted_release_id(agent_id, environment) + active_policy = storage.get_active_policy() or default_policy() + + if action == "promote" and not current_release_id: + policy_result = PolicyResult( + passed=True, + reasons=["first promotion: no promoted baseline for agent/environment"], + ) + else: + if not current_release_id: + raise OperationError("No promoted release exists for this agent/environment; nothing to roll back to.") + baseline_record = storage.get_release(current_release_id) + if not baseline_record: + raise OperationError(f"Promoted baseline release is missing: {current_release_id}") + + baseline_artifact = ReleaseArtifact.model_validate(baseline_record.artifact_json) + baseline_ref = baseline_artifact.spec.pricing_reference + candidate_ref = target_artifact.spec.pricing_reference + baseline_table = _load_pricing_or_error( + storage, + provider=baseline_ref.provider, + version=baseline_ref.pricing_version, + role="promoted_baseline", + ) + candidate_table = _load_pricing_or_error( + storage, + provider=candidate_ref.provider, + version=candidate_ref.pricing_version, + role="candidate" if action == "promote" else "rollback", + ) + + try: + delta = parse_window(window) + except ValueError as e: + raise OperationError(str(e)) from e + until = utc_now() + since = until - delta + + baseline_events = storage.query_runs( + current_release_id, + since, + until, + environment=environment, + ) + candidate_events = storage.query_runs( + release_id, + since, + until, + environment=environment, + ) + + try: + diff = diff_releases( + cfg=cfg, + policy=active_policy, + baseline_events=baseline_events, + candidate_events=candidate_events, + baseline_pricing_table=baseline_table, + candidate_pricing_table=candidate_table, + window=window, + ) + except KeyError as e: + raise OperationError( + "Pricing table missing model entry. " + f"baseline_model={baseline_artifact.spec.runtime.model} " + f"candidate_model={target_artifact.spec.runtime.model}." + ) from e + except ValueError as e: + raise OperationError(str(e)) from e + policy_result = diff.policy + + action_id = f"act_{uuid4().hex[:12]}" + record = PromotionRecord( + action_id=action_id, + action=action, + actor=actor, + release_id=release_id, + agent_id=agent_id, + environment=environment, + reason=reason, + policy_result=policy_result, + baseline_release_id=current_release_id, + created_at=utc_now(), + ) + + if not policy_result.passed: + storage.insert_promotion_record(record) + return ActionOutcome( + action_id=action_id, + action=action, + release_id=release_id, + agent_id=agent_id, + environment=environment, + baseline_release_id=current_release_id, + promoted_pointer_changed=False, + policy=policy_result, + ) + + storage.commit_promotion(record, new_promoted_release_id=release_id) + return ActionOutcome( + action_id=action_id, + action=action, + release_id=release_id, + agent_id=agent_id, + environment=environment, + baseline_release_id=current_release_id, + promoted_pointer_changed=True, + policy=policy_result, + ) + + +def promote_release( + *, + cfg: WorkspaceConfig, + storage: Storage, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str, +) -> ActionOutcome: + return _evaluate_promotion_or_rollback( + cfg=cfg, + storage=storage, + action="promote", + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) + + +def rollback_release( + *, + cfg: WorkspaceConfig, + storage: Storage, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str, +) -> ActionOutcome: + return _evaluate_promotion_or_rollback( + cfg=cfg, + storage=storage, + action="rollback", + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) + + +def list_timeline( + *, + storage: Storage, + agent_id: str | None = None, + environment: str | None = None, + action_limit: int = 50, +) -> TimelineOutcome: + releases = [ + { + "release_id": r.release_id, + "agent_id": r.agent_id, + "version": r.version, + "environment": r.environment, + "checksum": r.checksum, + "created_at": r.created_at.isoformat(), + } + for r in storage.list_releases() + ] + promoted = [ + {"agent_id": a, "environment": e, "release_id": rid} + for (a, e, rid) in storage.list_promoted_pointers() + ] + actions = [] + for a in storage.list_release_actions(agent_id=agent_id, environment=environment)[: max(0, action_limit)]: + actions.append( + { + "action_id": a.action_id, + "action": a.action, + "release_id": a.release_id, + "agent_id": a.agent_id, + "environment": a.environment, + "baseline_release_id": a.baseline_release_id, + "reason": a.reason, + "policy_passed": a.policy_result.passed, + "policy_reasons": a.policy_result.reasons, + "created_at": a.created_at.isoformat(), + "audit_seq": a.audit_seq, + } + ) + return TimelineOutcome(releases=releases, promoted=promoted, actions=actions) diff --git a/src/flightdeck/quickstart_smoke.py b/src/flightdeck/quickstart_smoke.py new file mode 100644 index 0000000..deb03fd --- /dev/null +++ b/src/flightdeck/quickstart_smoke.py @@ -0,0 +1,93 @@ +"""Cross-platform quickstart smoke: mirrors ``examples/quickstart`` in a temp workspace.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +QS = REPO / "examples" / "quickstart" +BASELINE_PH = "__BASELINE_RELEASE_ID__" +CANDIDATE_PH = "__CANDIDATE_RELEASE_ID__" + + +def _flightdeck_cmd() -> list[str]: + exe = shutil.which("flightdeck") + if exe: + return [exe] + return [sys.executable, "-m", "flightdeck.cli.main"] + + +def _run(fd: list[str], *args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*fd, *args], + cwd=cwd, + check=True, + text=True, + capture_output=True, + ) + + +def main() -> None: + fd = _flightdeck_cmd() + with tempfile.TemporaryDirectory(prefix="fd_qs_", ignore_cleanup_errors=True) as tmp_s: + tmp = Path(tmp_s) + baseline_events = tmp / "baseline-events.jsonl" + candidate_events = tmp / "candidate-events.jsonl" + + _run(fd, "init", cwd=tmp) + _run(fd, "pricing", "import", str(QS / "pricing-baseline.yaml"), cwd=tmp) + _run(fd, "pricing", "import", str(QS / "pricing-candidate.yaml"), cwd=tmp) + _run(fd, "policy", "set", str(QS / "policy.yaml"), cwd=tmp) + + reg_b = _run(fd, "release", "register", str(QS / "baseline-release"), cwd=tmp) + baseline_id = reg_b.stdout.strip() + reg_c = _run(fd, "release", "register", str(QS / "candidate-release"), cwd=tmp) + candidate_id = reg_c.stdout.strip() + + baseline_events.write_text( + (QS / "baseline-events.jsonl").read_text(encoding="utf-8").replace(BASELINE_PH, baseline_id), + encoding="utf-8", + ) + candidate_events.write_text( + (QS / "candidate-events.jsonl").read_text(encoding="utf-8").replace(CANDIDATE_PH, candidate_id), + encoding="utf-8", + ) + + _run(fd, "runs", "ingest", str(baseline_events), cwd=tmp) + _run(fd, "runs", "ingest", str(candidate_events), cwd=tmp) + _run(fd, "release", "diff", baseline_id, candidate_id, "--window", "7d", cwd=tmp) + _run( + fd, + "release", + "promote", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "quickstart smoke", + cwd=tmp, + ) + _run(fd, "release", "history", "--agent", "agent_support", "--env", "local", cwd=tmp) + _run(fd, "release", "verify", baseline_id, "--path", str(QS / "baseline-release"), cwd=tmp) + _run(fd, "doctor", cwd=tmp) + + print("quickstart_smoke: OK") + + +def quickstart_verify_main() -> None: + """Entry point for ``flightdeck-quickstart-verify`` (exits non-zero on failure).""" + try: + main() + except subprocess.CalledProcessError as e: + print(e.stderr or e.stdout or str(e), file=sys.stderr) + raise SystemExit(e.returncode or 1) from e + + +if __name__ == "__main__": + quickstart_verify_main() diff --git a/src/flightdeck/sdk/__init__.py b/src/flightdeck/sdk/__init__.py index 48dfb59..fc104c7 100644 --- a/src/flightdeck/sdk/__init__.py +++ b/src/flightdeck/sdk/__init__.py @@ -1 +1,5 @@ """Small client helpers for emitting runtime evidence to FlightDeck.""" + +from flightdeck.sdk.client import AsyncFlightdeckClient, FlightdeckClient + +__all__ = ["FlightdeckClient", "AsyncFlightdeckClient"] diff --git a/src/flightdeck/sdk/client.py b/src/flightdeck/sdk/client.py index 7edd2b8..9797afa 100644 --- a/src/flightdeck/sdk/client.py +++ b/src/flightdeck/sdk/client.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Iterable +import asyncio +import time +from typing import Any, Iterable import httpx @@ -8,18 +10,330 @@ class FlightdeckClient: - def __init__(self, base_url: str, *, timeout_s: float = 5.0, client: httpx.Client | None = None) -> None: + def __init__( + self, + base_url: str, + *, + timeout_s: float = 5.0, + max_retries: int = 0, + retry_backoff_s: float = 0.1, + api_token: str | None = None, + client: httpx.Client | None = None, + ) -> None: self._base_url = base_url.rstrip("/") self._owns_client = client is None self._client = client or httpx.Client(timeout=timeout_s) + self._max_retries = max(0, max_retries) + self._retry_backoff_s = max(0.0, retry_backoff_s) + self._api_token = api_token def close(self) -> None: if self._owns_client: self._client.close() + def _auth_headers(self) -> dict[str, str]: + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} + + def _json_headers(self) -> dict[str, str]: + return {"Content-Type": "application/json", **self._auth_headers()} + + def health(self) -> dict[str, Any]: + resp = self._request_with_retry("GET", "/health", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + def list_releases(self) -> dict[str, Any]: + resp = self._request_with_retry("GET", "/v1/releases", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + def list_promoted(self) -> dict[str, Any]: + resp = self._request_with_retry("GET", "/v1/promoted", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + def list_actions( + self, + *, + agent_id: str | None = None, + environment: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + params: dict[str, str | int] = {"limit": limit} + if agent_id is not None: + params["agent"] = agent_id + if environment is not None: + params["env"] = environment + resp = self._request_with_retry( + "GET", + "/v1/actions", + params=params, + headers=self._auth_headers() or None, + ) + resp.raise_for_status() + return resp.json() + + def post_diff( + self, + *, + baseline_release_id: str, + candidate_release_id: str, + window: str, + environment: str | None = None, + tenant_id: str | None = None, + task_id: str | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = { + "baseline_release_id": baseline_release_id, + "candidate_release_id": candidate_release_id, + "window": window, + "environment": environment, + "tenant_id": tenant_id, + "task_id": task_id, + } + resp = self._request_with_retry("POST", "/v1/diff", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + + def post_promote( + self, + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str = "sdk", + ) -> dict[str, Any]: + body = { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + resp = self._request_with_retry("POST", "/v1/promote", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + + def post_rollback( + self, + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str = "sdk", + ) -> dict[str, Any]: + body = { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + resp = self._request_with_retry("POST", "/v1/rollback", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + def ingest_run_events(self, events: Iterable[RunEvent]) -> int: payload = {"events": [e.model_dump(mode="json") for e in events]} - resp = self._client.post(f"{self._base_url}/v1/events", json=payload) + if not payload["events"]: + return 0 + resp = self._request_with_retry("POST", "/v1/events", json=payload, headers=self._json_headers()) resp.raise_for_status() data = resp.json() return int(data["inserted"]) + + def ingest_run_events_batch(self, events: Iterable[RunEvent], *, chunk_size: int = 500) -> int: + if chunk_size <= 0: + raise ValueError("chunk_size must be > 0") + total = 0 + chunk: list[RunEvent] = [] + for event in events: + chunk.append(event) + if len(chunk) >= chunk_size: + total += self.ingest_run_events(chunk) + chunk = [] + if chunk: + total += self.ingest_run_events(chunk) + return total + + def _request_with_retry(self, method: str, path: str, **kwargs) -> httpx.Response: + last_exc: httpx.RequestError | None = None + for attempt in range(self._max_retries + 1): + try: + return self._client.request(method, f"{self._base_url}{path}", **kwargs) + except httpx.RequestError as exc: + last_exc = exc + if attempt >= self._max_retries: + raise + time.sleep(self._retry_backoff_s * (2**attempt)) + assert last_exc is not None + raise last_exc + + +class AsyncFlightdeckClient: + def __init__( + self, + base_url: str, + *, + timeout_s: float = 5.0, + max_retries: int = 0, + retry_backoff_s: float = 0.1, + api_token: str | None = None, + client: httpx.AsyncClient | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._owns_client = client is None + self._client = client or httpx.AsyncClient(timeout=timeout_s) + self._max_retries = max(0, max_retries) + self._retry_backoff_s = max(0.0, retry_backoff_s) + self._api_token = api_token + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + def _auth_headers(self) -> dict[str, str]: + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} + + def _json_headers(self) -> dict[str, str]: + return {"Content-Type": "application/json", **self._auth_headers()} + + async def health(self) -> dict[str, Any]: + resp = await self._request_with_retry("GET", "/health", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + async def list_releases(self) -> dict[str, Any]: + resp = await self._request_with_retry("GET", "/v1/releases", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + async def list_promoted(self) -> dict[str, Any]: + resp = await self._request_with_retry("GET", "/v1/promoted", headers=self._auth_headers() or None) + resp.raise_for_status() + return resp.json() + + async def list_actions( + self, + *, + agent_id: str | None = None, + environment: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + params: dict[str, str | int] = {"limit": limit} + if agent_id is not None: + params["agent"] = agent_id + if environment is not None: + params["env"] = environment + resp = await self._request_with_retry( + "GET", + "/v1/actions", + params=params, + headers=self._auth_headers() or None, + ) + resp.raise_for_status() + return resp.json() + + async def post_diff( + self, + *, + baseline_release_id: str, + candidate_release_id: str, + window: str, + environment: str | None = None, + tenant_id: str | None = None, + task_id: str | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = { + "baseline_release_id": baseline_release_id, + "candidate_release_id": candidate_release_id, + "window": window, + "environment": environment, + "tenant_id": tenant_id, + "task_id": task_id, + } + resp = await self._request_with_retry("POST", "/v1/diff", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + + async def post_promote( + self, + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str = "sdk", + ) -> dict[str, Any]: + body = { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + resp = await self._request_with_retry("POST", "/v1/promote", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + + async def post_rollback( + self, + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str = "sdk", + ) -> dict[str, Any]: + body = { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + resp = await self._request_with_retry("POST", "/v1/rollback", json=body, headers=self._json_headers()) + resp.raise_for_status() + return resp.json() + + async def ingest_run_events(self, events: Iterable[RunEvent]) -> int: + payload = {"events": [e.model_dump(mode="json") for e in events]} + if not payload["events"]: + return 0 + resp = await self._request_with_retry("POST", "/v1/events", json=payload, headers=self._json_headers()) + resp.raise_for_status() + data = resp.json() + return int(data["inserted"]) + + async def ingest_run_events_batch(self, events: Iterable[RunEvent], *, chunk_size: int = 500) -> int: + if chunk_size <= 0: + raise ValueError("chunk_size must be > 0") + total = 0 + chunk: list[RunEvent] = [] + for event in events: + chunk.append(event) + if len(chunk) >= chunk_size: + total += await self.ingest_run_events(chunk) + chunk = [] + if chunk: + total += await self.ingest_run_events(chunk) + return total + + async def _request_with_retry(self, method: str, path: str, **kwargs) -> httpx.Response: + last_exc: httpx.RequestError | None = None + for attempt in range(self._max_retries + 1): + try: + return await self._client.request(method, f"{self._base_url}{path}", **kwargs) + except httpx.RequestError as exc: + last_exc = exc + if attempt >= self._max_retries: + raise + await asyncio.sleep(self._retry_backoff_s * (2**attempt)) + assert last_exc is not None + raise last_exc diff --git a/src/flightdeck/server/app.py b/src/flightdeck/server/app.py index f518c3f..4939bbf 100644 --- a/src/flightdeck/server/app.py +++ b/src/flightdeck/server/app.py @@ -1,46 +1,42 @@ from __future__ import annotations -from typing import Any +from contextlib import asynccontextmanager +import os +from pathlib import Path -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel, Field +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from flightdeck.config import load_config -from flightdeck.models import RunEvent +from flightdeck.server.routes import include_routes from flightdeck.storage import Storage -class IngestEventsRequest(BaseModel): - events: list[dict[str, Any]] = Field(min_length=1) - - def create_app() -> FastAPI: - app = FastAPI(title="FlightDeck", version="local") + @asynccontextmanager + async def lifespan(app: FastAPI): + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + app.state.cfg = cfg + app.state.storage = storage + app.state.local_api_token = os.environ.get("FLIGHTDECK_LOCAL_API_TOKEN") + yield + + app = FastAPI(title="FlightDeck", version="local", lifespan=lifespan) + include_routes(app) + static_dir = Path(__file__).resolve().parent / "static" + assets_dir = static_dir / "assets" + if assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="ui-assets") @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} - @app.post("/v1/events") - def ingest_events(req: IngestEventsRequest) -> dict[str, int]: - cfg = load_config() - storage = Storage(cfg.db_path) - storage.migrate() - - events: list[RunEvent] = [] - for item in req.events: - av = item.get("api_version", "v1") - if av != "v1": - raise HTTPException( - status_code=400, - detail=f"Unsupported api_version for POST /v1/events: {av!r} (only 'v1' is accepted).", - ) - try: - events.append(RunEvent.model_validate(item)) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid RunEvent: {e}") from e - - inserted = storage.insert_run_events(events) - return {"inserted": inserted} + @app.get("/") + def ui_index() -> FileResponse: + return FileResponse(static_dir / "index.html") return app diff --git a/src/flightdeck/server/routes/__init__.py b/src/flightdeck/server/routes/__init__.py index e69de29..8bc463d 100644 --- a/src/flightdeck/server/routes/__init__.py +++ b/src/flightdeck/server/routes/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from flightdeck.server.routes.actions import router as actions_router +from flightdeck.server.routes.ingest import router as ingest_router +from flightdeck.server.routes.read import router as read_router + + +def include_routes(app: FastAPI) -> None: + app.include_router(ingest_router) + app.include_router(read_router) + app.include_router(actions_router) diff --git a/src/flightdeck/server/routes/actions.py b/src/flightdeck/server/routes/actions.py new file mode 100644 index 0000000..ad807a3 --- /dev/null +++ b/src/flightdeck/server/routes/actions.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from flightdeck.operations import OperationError, compute_diff, promote_release, rollback_release +from flightdeck.server.routes.common import ensure_app_state + +router = APIRouter() + +_LOCAL_CLIENT_HOSTS = {"127.0.0.1", "::1", "localhost", "testclient"} + + +class DiffRequest(BaseModel): + baseline_release_id: str + candidate_release_id: str + window: str + tenant_id: str | None = None + task_id: str | None = None + environment: str | None = None + + +class ActionRequest(BaseModel): + release_id: str + environment: str + window: str + reason: str = Field(min_length=1) + actor: str = "http" + + +def _raise_bad_request(exc: OperationError) -> HTTPException: + return HTTPException(status_code=400, detail=str(exc)) + + +def _require_mutation_access(request: Request) -> None: + ensure_app_state(request) + expected_token: str | None = request.app.state.local_api_token + auth_header = request.headers.get("authorization", "") + if expected_token: + if auth_header != f"Bearer {expected_token}": + raise HTTPException(status_code=401, detail="Missing or invalid API token for mutation route.") + return + + host = request.client.host if request.client else "" + if host not in _LOCAL_CLIENT_HOSTS: + raise HTTPException( + status_code=403, + detail="Mutation routes are restricted to local clients unless FLIGHTDECK_LOCAL_API_TOKEN is configured.", + ) + + +@router.post("/v1/diff") +def post_diff(request: Request, req: DiffRequest) -> dict[str, object]: + cfg, storage = ensure_app_state(request) + try: + result = compute_diff( + cfg=cfg, + storage=storage, + baseline_release_id=req.baseline_release_id, + candidate_release_id=req.candidate_release_id, + window=req.window, + environment=req.environment, + tenant_id=req.tenant_id, + task_id=req.task_id, + ) + except OperationError as exc: + raise _raise_bad_request(exc) from exc + + return { + "window": result.window, + "since": result.since.isoformat(), + "until": result.until.isoformat(), + "filters": { + "environment": result.environment, + "tenant_id": result.tenant_id, + "task_id": result.task_id, + }, + "pricing": { + "baseline_provider": result.baseline_pricing_provider, + "baseline_version": result.baseline_pricing_version, + "baseline_model": result.baseline_model, + "candidate_provider": result.candidate_pricing_provider, + "candidate_version": result.candidate_pricing_version, + "candidate_model": result.candidate_model, + "pricing_or_model_changed": result.pricing_or_model_changed, + }, + "samples": { + "baseline_runs": result.baseline_runs, + "candidate_runs": result.candidate_runs, + "confidence": result.confidence, + "confidence_reason": result.confidence_reason, + }, + "metrics": { + "baseline_cost_per_run_usd": result.baseline_cost_per_run_usd, + "candidate_cost_per_run_usd": result.candidate_cost_per_run_usd, + "delta_cost_per_run_usd": result.delta_cost_per_run_usd, + "delta_cost_per_run_pct": result.delta_cost_per_run_pct, + "baseline_latency_ms_avg": result.baseline_latency_ms_avg, + "candidate_latency_ms_avg": result.candidate_latency_ms_avg, + "delta_latency_ms_avg": result.delta_latency_ms_avg, + "baseline_error_rate": result.baseline_error_rate, + "candidate_error_rate": result.candidate_error_rate, + "delta_error_rate": result.delta_error_rate, + }, + "policy": result.policy.model_dump(mode="json"), + } + + +@router.post("/v1/promote") +def post_promote(request: Request, req: ActionRequest) -> dict[str, object]: + _require_mutation_access(request) + cfg, storage = ensure_app_state(request) + try: + outcome = promote_release( + cfg=cfg, + storage=storage, + release_id=req.release_id, + environment=req.environment, + window=req.window, + reason=req.reason, + actor=req.actor, + ) + except OperationError as exc: + raise _raise_bad_request(exc) from exc + + return { + "action_id": outcome.action_id, + "action": outcome.action, + "release_id": outcome.release_id, + "agent_id": outcome.agent_id, + "environment": outcome.environment, + "baseline_release_id": outcome.baseline_release_id, + "promoted_pointer_changed": outcome.promoted_pointer_changed, + "policy": outcome.policy.model_dump(mode="json"), + } + + +@router.post("/v1/rollback") +def post_rollback(request: Request, req: ActionRequest) -> dict[str, object]: + _require_mutation_access(request) + cfg, storage = ensure_app_state(request) + try: + outcome = rollback_release( + cfg=cfg, + storage=storage, + release_id=req.release_id, + environment=req.environment, + window=req.window, + reason=req.reason, + actor=req.actor, + ) + except OperationError as exc: + raise _raise_bad_request(exc) from exc + + return { + "action_id": outcome.action_id, + "action": outcome.action, + "release_id": outcome.release_id, + "agent_id": outcome.agent_id, + "environment": outcome.environment, + "baseline_release_id": outcome.baseline_release_id, + "promoted_pointer_changed": outcome.promoted_pointer_changed, + "policy": outcome.policy.model_dump(mode="json"), + } diff --git a/src/flightdeck/server/routes/common.py b/src/flightdeck/server/routes/common.py new file mode 100644 index 0000000..a12ccc1 --- /dev/null +++ b/src/flightdeck/server/routes/common.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os +from fastapi import Request + +from flightdeck.config import load_config +from flightdeck.models import WorkspaceConfig +from flightdeck.storage import Storage + + +def ensure_app_state(request: Request) -> tuple[WorkspaceConfig, Storage]: + cfg: WorkspaceConfig | None = getattr(request.app.state, "cfg", None) + storage: Storage | None = getattr(request.app.state, "storage", None) + if cfg is not None and storage is not None: + return cfg, storage + + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + request.app.state.cfg = cfg + request.app.state.storage = storage + if not hasattr(request.app.state, "local_api_token"): + request.app.state.local_api_token = os.environ.get("FLIGHTDECK_LOCAL_API_TOKEN") + return cfg, storage diff --git a/src/flightdeck/server/routes/ingest.py b/src/flightdeck/server/routes/ingest.py new file mode 100644 index 0000000..d79b727 --- /dev/null +++ b/src/flightdeck/server/routes/ingest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from flightdeck.models import RunEvent +from flightdeck.server.routes.common import ensure_app_state + +router = APIRouter() + + +class IngestEventsRequest(BaseModel): + events: list[dict[str, Any]] = Field(min_length=1) + + +@router.post("/v1/events") +def ingest_events(request: Request, req: IngestEventsRequest) -> dict[str, int]: + _, storage = ensure_app_state(request) + + events: list[RunEvent] = [] + for item in req.events: + av = item.get("api_version", "v1") + if av != "v1": + raise HTTPException( + status_code=400, + detail=f"Unsupported api_version for POST /v1/events: {av!r} (only 'v1' is accepted).", + ) + try: + events.append(RunEvent.model_validate(item)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid RunEvent: {e}") from e + + inserted = storage.insert_run_events(events) + return {"inserted": inserted} diff --git a/src/flightdeck/server/routes/read.py b/src/flightdeck/server/routes/read.py new file mode 100644 index 0000000..549e473 --- /dev/null +++ b/src/flightdeck/server/routes/read.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import APIRouter, Query, Request + +from flightdeck.operations import list_timeline +from flightdeck.server.routes.common import ensure_app_state + +router = APIRouter() + + +@router.get("/v1/releases") +def get_releases(request: Request) -> dict[str, list[dict[str, object]]]: + _, storage = ensure_app_state(request) + timeline = list_timeline(storage=storage, action_limit=0) + return {"releases": timeline.releases} + + +@router.get("/v1/promoted") +def get_promoted(request: Request) -> dict[str, list[dict[str, str]]]: + _, storage = ensure_app_state(request) + timeline = list_timeline(storage=storage, action_limit=0) + return {"promoted": timeline.promoted} + + +@router.get("/v1/actions") +def get_actions( + request: Request, + agent_id: str | None = Query(default=None, alias="agent"), + environment: str | None = Query(default=None, alias="env"), + limit: int = Query(default=50, ge=1, le=500), +) -> dict[str, list[dict[str, object]]]: + _, storage = ensure_app_state(request) + timeline = list_timeline(storage=storage, agent_id=agent_id, environment=environment, action_limit=limit) + return {"actions": timeline.actions} diff --git a/src/flightdeck/server/static/assets/index-DOd77gwY.css b/src/flightdeck/server/static/assets/index-DOd77gwY.css new file mode 100644 index 0000000..ede0288 --- /dev/null +++ b/src/flightdeck/server/static/assets/index-DOd77gwY.css @@ -0,0 +1 @@ +:root{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;line-height:1.45;color:#111;background:#fafafa}body{margin:0;padding:1.25rem 1.5rem 2rem}h1{margin:0 0 .25rem;font-size:1.35rem}h2{margin:1.35rem 0 .45rem;font-size:1.05rem}section{border:1px solid #ddd;border-radius:8px;padding:.85rem 1rem;margin-bottom:.85rem;background:#fff}label{display:inline-block;min-width:9.5rem;margin-bottom:.35rem;font-size:.9rem}input{margin-bottom:.45rem;min-width:16rem;padding:.25rem .4rem}button{margin:.35rem .5rem .35rem 0;padding:.35rem .65rem}pre{white-space:pre-wrap;background:#f6f6f6;border:1px solid #eee;padding:.65rem;border-radius:6px;font-size:.82rem}.muted{color:#555;font-size:.9rem;margin-bottom:.75rem} diff --git a/src/flightdeck/server/static/assets/index-DzFoZmnS.js b/src/flightdeck/server/static/assets/index-DzFoZmnS.js new file mode 100644 index 0000000..54c590d --- /dev/null +++ b/src/flightdeck/server/static/assets/index-DzFoZmnS.js @@ -0,0 +1,9 @@ +(function(){const cl=document.createElement("link").relList;if(cl&&cl.supports&&cl.supports("modulepreload"))return;for(const q of document.querySelectorAll('link[rel="modulepreload"]'))o(q);new MutationObserver(q=>{for(const w of q)if(w.type==="childList")for(const ml of w.addedNodes)ml.tagName==="LINK"&&ml.rel==="modulepreload"&&o(ml)}).observe(document,{childList:!0,subtree:!0});function $(q){const w={};return q.integrity&&(w.integrity=q.integrity),q.referrerPolicy&&(w.referrerPolicy=q.referrerPolicy),q.crossOrigin==="use-credentials"?w.credentials="include":q.crossOrigin==="anonymous"?w.credentials="omit":w.credentials="same-origin",w}function o(q){if(q.ep)return;q.ep=!0;const w=$(q);fetch(q.href,w)}})();var fi={exports:{}},ze={};var hy;function Wm(){if(hy)return ze;hy=1;var M=Symbol.for("react.transitional.element"),cl=Symbol.for("react.fragment");function $(o,q,w){var ml=null;if(w!==void 0&&(ml=""+w),q.key!==void 0&&(ml=""+q.key),"key"in q){w={};for(var Hl in q)Hl!=="key"&&(w[Hl]=q[Hl])}else w=q;return q=w.ref,{$$typeof:M,type:o,key:ml,ref:q!==void 0?q:null,props:w}}return ze.Fragment=cl,ze.jsx=$,ze.jsxs=$,ze}var oy;function $m(){return oy||(oy=1,fi.exports=Wm()),fi.exports}var Y=$m(),ci={exports:{}},B={};var Sy;function Fm(){if(Sy)return B;Sy=1;var M=Symbol.for("react.transitional.element"),cl=Symbol.for("react.portal"),$=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),w=Symbol.for("react.consumer"),ml=Symbol.for("react.context"),Hl=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),E=Symbol.for("react.memo"),F=Symbol.for("react.lazy"),N=Symbol.for("react.activity"),il=Symbol.iterator;function Ql(v){return v===null||typeof v!="object"?null:(v=il&&v[il]||v["@@iterator"],typeof v=="function"?v:null)}var Nl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Rl=Object.assign,dt={};function xl(v,T,_){this.props=v,this.context=T,this.refs=dt,this.updater=_||Nl}xl.prototype.isReactComponent={},xl.prototype.setState=function(v,T){if(typeof v!="object"&&typeof v!="function"&&v!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,v,T,"setState")},xl.prototype.forceUpdate=function(v){this.updater.enqueueForceUpdate(this,v,"forceUpdate")};function At(){}At.prototype=xl.prototype;function Ml(v,T,_){this.props=v,this.context=T,this.refs=dt,this.updater=_||Nl}var Kl=Ml.prototype=new At;Kl.constructor=Ml,Rl(Kl,xl.prototype),Kl.isPureReactComponent=!0;var Zl=Array.isArray;function _l(){}var Q={H:null,A:null,T:null,S:null},Cl=Object.prototype.hasOwnProperty;function x(v,T,_){var D=_.ref;return{$$typeof:M,type:v,key:T,ref:D!==void 0?D:null,props:_}}function tt(v,T){return x(v.type,T,v.props)}function Jl(v){return typeof v=="object"&&v!==null&&v.$$typeof===M}function Dl(v){var T={"=":"=0",":":"=2"};return"$"+v.replace(/[=:]/g,function(_){return T[_]})}var Eu=/\/+/g;function Rt(v,T){return typeof v=="object"&&v!==null&&v.key!=null?Dl(""+v.key):T.toString(36)}function _t(v){switch(v.status){case"fulfilled":return v.value;case"rejected":throw v.reason;default:switch(typeof v.status=="string"?v.then(_l,_l):(v.status="pending",v.then(function(T){v.status==="pending"&&(v.status="fulfilled",v.value=T)},function(T){v.status==="pending"&&(v.status="rejected",v.reason=T)})),v.status){case"fulfilled":return v.value;case"rejected":throw v.reason}}throw v}function b(v,T,_,D,j){var Z=typeof v;(Z==="undefined"||Z==="boolean")&&(v=null);var ll=!1;if(v===null)ll=!0;else switch(Z){case"bigint":case"string":case"number":ll=!0;break;case"object":switch(v.$$typeof){case M:case cl:ll=!0;break;case F:return ll=v._init,b(ll(v._payload),T,_,D,j)}}if(ll)return j=j(v),ll=D===""?"."+Rt(v,0):D,Zl(j)?(_="",ll!=null&&(_=ll.replace(Eu,"$&/")+"/"),b(j,T,_,"",function(Ma){return Ma})):j!=null&&(Jl(j)&&(j=tt(j,_+(j.key==null||v&&v.key===j.key?"":(""+j.key).replace(Eu,"$&/")+"/")+ll)),T.push(j)),1;ll=0;var Vl=D===""?".":D+":";if(Zl(v))for(var gl=0;gl>>1,sl=b[al];if(0>>1;alq(_,C))Dq(j,_)?(b[al]=j,b[D]=C,al=D):(b[al]=_,b[T]=C,al=T);else if(Dq(j,C))b[al]=j,b[D]=C,al=D;else break l}}return A}function q(b,A){var C=b.sortIndex-A.sortIndex;return C!==0?C:b.id-A.id}if(M.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var w=performance;M.unstable_now=function(){return w.now()}}else{var ml=Date,Hl=ml.now();M.unstable_now=function(){return ml.now()-Hl}}var p=[],E=[],F=1,N=null,il=3,Ql=!1,Nl=!1,Rl=!1,dt=!1,xl=typeof setTimeout=="function"?setTimeout:null,At=typeof clearTimeout=="function"?clearTimeout:null,Ml=typeof setImmediate<"u"?setImmediate:null;function Kl(b){for(var A=$(E);A!==null;){if(A.callback===null)o(E);else if(A.startTime<=b)o(E),A.sortIndex=A.expirationTime,cl(p,A);else break;A=$(E)}}function Zl(b){if(Rl=!1,Kl(b),!Nl)if($(p)!==null)Nl=!0,_l||(_l=!0,Dl());else{var A=$(E);A!==null&&_t(Zl,A.startTime-b)}}var _l=!1,Q=-1,Cl=5,x=-1;function tt(){return dt?!0:!(M.unstable_now()-xb&&tt());){var al=N.callback;if(typeof al=="function"){N.callback=null,il=N.priorityLevel;var sl=al(N.expirationTime<=b);if(b=M.unstable_now(),typeof sl=="function"){N.callback=sl,Kl(b),A=!0;break t}N===$(p)&&o(p),Kl(b)}else o(p);N=$(p)}if(N!==null)A=!0;else{var v=$(E);v!==null&&_t(Zl,v.startTime-b),A=!1}}break l}finally{N=null,il=C,Ql=!1}A=void 0}}finally{A?Dl():_l=!1}}}var Dl;if(typeof Ml=="function")Dl=function(){Ml(Jl)};else if(typeof MessageChannel<"u"){var Eu=new MessageChannel,Rt=Eu.port2;Eu.port1.onmessage=Jl,Dl=function(){Rt.postMessage(null)}}else Dl=function(){xl(Jl,0)};function _t(b,A){Q=xl(function(){b(M.unstable_now())},A)}M.unstable_IdlePriority=5,M.unstable_ImmediatePriority=1,M.unstable_LowPriority=4,M.unstable_NormalPriority=3,M.unstable_Profiling=null,M.unstable_UserBlockingPriority=2,M.unstable_cancelCallback=function(b){b.callback=null},M.unstable_forceFrameRate=function(b){0>b||125al?(b.sortIndex=C,cl(E,b),$(p)===null&&b===$(E)&&(Rl?(At(Q),Q=-1):Rl=!0,_t(Zl,C-al))):(b.sortIndex=sl,cl(p,b),Nl||Ql||(Nl=!0,_l||(_l=!0,Dl()))),b},M.unstable_shouldYield=tt,M.unstable_wrapCallback=function(b){var A=il;return function(){var C=il;il=A;try{return b.apply(this,arguments)}finally{il=C}}}})(vi)),vi}var zy;function Im(){return zy||(zy=1,si.exports=km()),si.exports}var yi={exports:{}},Xl={};var ry;function Pm(){if(ry)return Xl;ry=1;var M=di();function cl(p){var E="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(M)}catch(cl){console.error(cl)}}return M(),yi.exports=Pm(),yi.exports}var Ey;function t1(){if(Ey)return re;Ey=1;var M=Im(),cl=di(),$=l1();function o(l){var t="https://react.dev/errors/"+l;if(1sl||(l.current=al[sl],al[sl]=null,sl--)}function _(l,t){sl++,al[sl]=l.current,l.current=t}var D=v(null),j=v(null),Z=v(null),ll=v(null);function Vl(l,t){switch(_(Z,t),_(j,l),_(D,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?jv(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=jv(t),l=Gv(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}T(D),_(D,l)}function gl(){T(D),T(j),T(Z)}function Ma(l){l.memoizedState!==null&&_(ll,l);var t=D.current,u=Gv(t,l.type);t!==u&&(_(j,l),_(D,u))}function Ee(l){j.current===l&&(T(D),T(j)),ll.current===l&&(T(ll),oe._currentValue=C)}var xn,mi;function Au(l){if(xn===void 0)try{throw Error()}catch(u){var t=u.stack.trim().match(/\n( *(at )?)/);xn=t&&t[1]||"",mi=-1)":-1e||i[a]!==m[e]){var g=` +`+i[a].replace(" at new "," at ");return l.displayName&&g.includes("")&&(g=g.replace("",l.displayName)),g}while(1<=a&&0<=e);break}}}finally{Zn=!1,Error.prepareStackTrace=u}return(u=l?l.displayName||l.name:"")?Au(u):""}function Oy(l,t){switch(l.tag){case 26:case 27:case 5:return Au(l.type);case 16:return Au("Lazy");case 13:return l.child!==t&&t!==null?Au("Suspense Fallback"):Au("Suspense");case 19:return Au("SuspenseList");case 0:case 15:return Vn(l.type,!1);case 11:return Vn(l.type.render,!1);case 1:return Vn(l.type,!0);case 31:return Au("Activity");default:return""}}function hi(l){try{var t="",u=null;do t+=Oy(l,u),u=l,l=l.return;while(l);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ln=Object.prototype.hasOwnProperty,Kn=M.unstable_scheduleCallback,Jn=M.unstable_cancelCallback,My=M.unstable_shouldYield,Dy=M.unstable_requestPaint,ut=M.unstable_now,Uy=M.unstable_getCurrentPriorityLevel,oi=M.unstable_ImmediatePriority,Si=M.unstable_UserBlockingPriority,Ae=M.unstable_NormalPriority,py=M.unstable_LowPriority,gi=M.unstable_IdlePriority,Hy=M.log,Ny=M.unstable_setDisableYieldValue,Da=null,at=null;function kt(l){if(typeof Hy=="function"&&Ny(l),at&&typeof at.setStrictMode=="function")try{at.setStrictMode(Da,l)}catch{}}var et=Math.clz32?Math.clz32:qy,Ry=Math.log,Cy=Math.LN2;function qy(l){return l>>>=0,l===0?32:31-(Ry(l)/Cy|0)|0}var _e=256,Oe=262144,Me=4194304;function _u(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function De(l,t,u){var a=l.pendingLanes;if(a===0)return 0;var e=0,n=l.suspendedLanes,f=l.pingedLanes;l=l.warmLanes;var c=a&134217727;return c!==0?(a=c&~n,a!==0?e=_u(a):(f&=c,f!==0?e=_u(f):u||(u=c&~l,u!==0&&(e=_u(u))))):(c=a&~n,c!==0?e=_u(c):f!==0?e=_u(f):u||(u=a&~l,u!==0&&(e=_u(u)))),e===0?0:t!==0&&t!==e&&(t&n)===0&&(n=e&-e,u=t&-t,n>=u||n===32&&(u&4194048)!==0)?t:e}function Ua(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function By(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function bi(){var l=Me;return Me<<=1,(Me&62914560)===0&&(Me=4194304),l}function wn(l){for(var t=[],u=0;31>u;u++)t.push(l);return t}function pa(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Yy(l,t,u,a,e,n){var f=l.pendingLanes;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=u,l.entangledLanes&=u,l.errorRecoveryDisabledLanes&=u,l.shellSuspendCounter=0;var c=l.entanglements,i=l.expirationTimes,m=l.hiddenUpdates;for(u=f&~u;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var Zy=/[\n"\\]/g;function ht(l){return l.replace(Zy,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Pn(l,t,u,a,e,n,f,c){l.name="",f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?l.type=f:l.removeAttribute("type"),t!=null?f==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+mt(t)):l.value!==""+mt(t)&&(l.value=""+mt(t)):f!=="submit"&&f!=="reset"||l.removeAttribute("value"),t!=null?lf(l,f,mt(t)):u!=null?lf(l,f,mt(u)):a!=null&&l.removeAttribute("value"),e==null&&n!=null&&(l.defaultChecked=!!n),e!=null&&(l.checked=e&&typeof e!="function"&&typeof e!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.name=""+mt(c):l.removeAttribute("name")}function Ni(l,t,u,a,e,n,f,c){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||u!=null){if(!(n!=="submit"&&n!=="reset"||t!=null)){In(l);return}u=u!=null?""+mt(u):"",t=t!=null?""+mt(t):u,c||t===l.value||(l.value=t),l.defaultValue=t}a=a??e,a=typeof a!="function"&&typeof a!="symbol"&&!!a,l.checked=c?l.checked:!!a,l.defaultChecked=!!a,f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"&&(l.name=f),In(l)}function lf(l,t,u){t==="number"&&He(l.ownerDocument)===l||l.defaultValue===""+u||(l.defaultValue=""+u)}function wu(l,t,u,a){if(l=l.options,t){t={};for(var e=0;e"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),nf=!1;if(Bt)try{var Ca={};Object.defineProperty(Ca,"passive",{get:function(){nf=!0}}),window.addEventListener("test",Ca,Ca),window.removeEventListener("test",Ca,Ca)}catch{nf=!1}var Pt=null,ff=null,Re=null;function Gi(){if(Re)return Re;var l,t=ff,u=t.length,a,e="value"in Pt?Pt.value:Pt.textContent,n=e.length;for(l=0;l=Ya),Li=" ",Ki=!1;function Ji(l,t){switch(l){case"keyup":return Sd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function wi(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var ku=!1;function bd(l,t){switch(l){case"compositionend":return wi(t);case"keypress":return t.which!==32?null:(Ki=!0,Li);case"textInput":return l=t.data,l===Li&&Ki?null:l;default:return null}}function zd(l,t){if(ku)return l==="compositionend"||!df&&Ji(l,t)?(l=Gi(),Re=ff=Pt=null,ku=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:u,offset:t-l};l=a}l:{for(;u;){if(u.nextSibling){u=u.nextSibling;break l}u=u.parentNode}u=void 0}u=t0(u)}}function a0(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?a0(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function e0(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=He(l.document);t instanceof l.HTMLIFrameElement;){try{var u=typeof t.contentWindow.location.href=="string"}catch{u=!1}if(u)l=t.contentWindow;else break;t=He(l.document)}return t}function of(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Dd=Bt&&"documentMode"in document&&11>=document.documentMode,Iu=null,Sf=null,Qa=null,gf=!1;function n0(l,t,u){var a=u.window===u?u.document:u.nodeType===9?u:u.ownerDocument;gf||Iu==null||Iu!==He(a)||(a=Iu,"selectionStart"in a&&of(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Qa&&Xa(Qa,a)||(Qa=a,a=Mn(Sf,"onSelect"),0>=f,e-=f,Ut=1<<32-et(t)+e|u<X?(J=U,U=null):J=U.sibling;var I=h(y,U,d[X],z);if(I===null){U===null&&(U=J);break}l&&U&&I.alternate===null&&t(y,U),s=n(I,s,X),k===null?H=I:k.sibling=I,k=I,U=J}if(X===d.length)return u(y,U),W&&jt(y,X),H;if(U===null){for(;XX?(J=U,U=null):J=U.sibling;var Tu=h(y,U,I.value,z);if(Tu===null){U===null&&(U=J);break}l&&U&&Tu.alternate===null&&t(y,U),s=n(Tu,s,X),k===null?H=Tu:k.sibling=Tu,k=Tu,U=J}if(I.done)return u(y,U),W&&jt(y,X),H;if(U===null){for(;!I.done;X++,I=d.next())I=r(y,I.value,z),I!==null&&(s=n(I,s,X),k===null?H=I:k.sibling=I,k=I);return W&&jt(y,X),H}for(U=a(U);!I.done;X++,I=d.next())I=S(U,y,X,I.value,z),I!==null&&(l&&I.alternate!==null&&U.delete(I.key===null?X:I.key),s=n(I,s,X),k===null?H=I:k.sibling=I,k=I);return l&&U.forEach(function(wm){return t(y,wm)}),W&&jt(y,X),H}function fl(y,s,d,z){if(typeof d=="object"&&d!==null&&d.type===Rl&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case Ql:l:{for(var H=d.key;s!==null;){if(s.key===H){if(H=d.type,H===Rl){if(s.tag===7){u(y,s.sibling),z=e(s,d.props.children),z.return=y,y=z;break l}}else if(s.elementType===H||typeof H=="object"&&H!==null&&H.$$typeof===Cl&&Bu(H)===s.type){u(y,s.sibling),z=e(s,d.props),Ja(z,d),z.return=y,y=z;break l}u(y,s);break}else t(y,s);s=s.sibling}d.type===Rl?(z=Hu(d.props.children,y.mode,z,d.key),z.return=y,y=z):(z=Ze(d.type,d.key,d.props,null,y.mode,z),Ja(z,d),z.return=y,y=z)}return f(y);case Nl:l:{for(H=d.key;s!==null;){if(s.key===H)if(s.tag===4&&s.stateNode.containerInfo===d.containerInfo&&s.stateNode.implementation===d.implementation){u(y,s.sibling),z=e(s,d.children||[]),z.return=y,y=z;break l}else{u(y,s);break}else t(y,s);s=s.sibling}z=_f(d,y.mode,z),z.return=y,y=z}return f(y);case Cl:return d=Bu(d),fl(y,s,d,z)}if(_t(d))return O(y,s,d,z);if(Dl(d)){if(H=Dl(d),typeof H!="function")throw Error(o(150));return d=H.call(d),R(y,s,d,z)}if(typeof d.then=="function")return fl(y,s,$e(d),z);if(d.$$typeof===Ml)return fl(y,s,Ke(y,d),z);Fe(y,d)}return typeof d=="string"&&d!==""||typeof d=="number"||typeof d=="bigint"?(d=""+d,s!==null&&s.tag===6?(u(y,s.sibling),z=e(s,d),z.return=y,y=z):(u(y,s),z=Af(d,y.mode,z),z.return=y,y=z),f(y)):u(y,s)}return function(y,s,d,z){try{Ka=0;var H=fl(y,s,d,z);return sa=null,H}catch(U){if(U===ia||U===we)throw U;var k=ft(29,U,null,y.mode);return k.lanes=z,k.return=y,k}}}var ju=U0(!0),p0=U0(!1),eu=!1;function Yf(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function jf(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function nu(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function fu(l,t,u){var a=l.updateQueue;if(a===null)return null;if(a=a.shared,(P&2)!==0){var e=a.pending;return e===null?t.next=t:(t.next=e.next,e.next=t),a.pending=t,t=xe(l),d0(l,null,u),t}return Qe(l,a,t,u),xe(l)}function wa(l,t,u){if(t=t.updateQueue,t!==null&&(t=t.shared,(u&4194048)!==0)){var a=t.lanes;a&=l.pendingLanes,u|=a,t.lanes=u,ri(l,u)}}function Gf(l,t){var u=l.updateQueue,a=l.alternate;if(a!==null&&(a=a.updateQueue,u===a)){var e=null,n=null;if(u=u.firstBaseUpdate,u!==null){do{var f={lane:u.lane,tag:u.tag,payload:u.payload,callback:null,next:null};n===null?e=n=f:n=n.next=f,u=u.next}while(u!==null);n===null?e=n=t:n=n.next=t}else e=n=t;u={baseState:a.baseState,firstBaseUpdate:e,lastBaseUpdate:n,shared:a.shared,callbacks:a.callbacks},l.updateQueue=u;return}l=u.lastBaseUpdate,l===null?u.firstBaseUpdate=t:l.next=t,u.lastBaseUpdate=t}var Xf=!1;function Wa(){if(Xf){var l=ca;if(l!==null)throw l}}function $a(l,t,u,a){Xf=!1;var e=l.updateQueue;eu=!1;var n=e.firstBaseUpdate,f=e.lastBaseUpdate,c=e.shared.pending;if(c!==null){e.shared.pending=null;var i=c,m=i.next;i.next=null,f===null?n=m:f.next=m,f=i;var g=l.alternate;g!==null&&(g=g.updateQueue,c=g.lastBaseUpdate,c!==f&&(c===null?g.firstBaseUpdate=m:c.next=m,g.lastBaseUpdate=i))}if(n!==null){var r=e.baseState;f=0,g=m=i=null,c=n;do{var h=c.lane&-536870913,S=h!==c.lane;if(S?(K&h)===h:(a&h)===h){h!==0&&h===fa&&(Xf=!0),g!==null&&(g=g.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});l:{var O=l,R=c;h=t;var fl=u;switch(R.tag){case 1:if(O=R.payload,typeof O=="function"){r=O.call(fl,r,h);break l}r=O;break l;case 3:O.flags=O.flags&-65537|128;case 0:if(O=R.payload,h=typeof O=="function"?O.call(fl,r,h):O,h==null)break l;r=N({},r,h);break l;case 2:eu=!0}}h=c.callback,h!==null&&(l.flags|=64,S&&(l.flags|=8192),S=e.callbacks,S===null?e.callbacks=[h]:S.push(h))}else S={lane:h,tag:c.tag,payload:c.payload,callback:c.callback,next:null},g===null?(m=g=S,i=r):g=g.next=S,f|=h;if(c=c.next,c===null){if(c=e.shared.pending,c===null)break;S=c,c=S.next,S.next=null,e.lastBaseUpdate=S,e.shared.pending=null}}while(!0);g===null&&(i=r),e.baseState=i,e.firstBaseUpdate=m,e.lastBaseUpdate=g,n===null&&(e.shared.lanes=0),yu|=f,l.lanes=f,l.memoizedState=r}}function H0(l,t){if(typeof l!="function")throw Error(o(191,l));l.call(t)}function N0(l,t){var u=l.callbacks;if(u!==null)for(l.callbacks=null,l=0;ln?n:8;var f=b.T,c={};b.T=c,ec(l,!1,t,u);try{var i=e(),m=b.S;if(m!==null&&m(c,i),i!==null&&typeof i=="object"&&typeof i.then=="function"){var g=Yd(i,a);Ia(l,t,g,yt(l))}else Ia(l,t,a,yt(l))}catch(r){Ia(l,t,{then:function(){},status:"rejected",reason:r},yt())}finally{A.p=n,f!==null&&c.types!==null&&(f.types=c.types),b.T=f}}function Zd(){}function uc(l,t,u,a){if(l.tag!==5)throw Error(o(476));var e=ss(l).queue;is(l,e,t,C,u===null?Zd:function(){return vs(l),u(a)})}function ss(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:C,baseState:C,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:xt,lastRenderedState:C},next:null};var u={};return t.next={memoizedState:u,baseState:u,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:xt,lastRenderedState:u},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function vs(l){var t=ss(l);t.next===null&&(t=l.alternate.memoizedState),Ia(l,t.next.queue,{},yt())}function ac(){return Yl(oe)}function ys(){return zl().memoizedState}function ds(){return zl().memoizedState}function Vd(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var u=yt();l=nu(u);var a=fu(t,l,u);a!==null&&(Pl(a,t,u),wa(a,t,u)),t={cache:Rf()},l.payload=t;return}t=t.return}}function Ld(l,t,u){var a=yt();u={lane:a,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},fn(l)?hs(t,u):(u=Tf(l,t,u,a),u!==null&&(Pl(u,l,a),os(u,t,a)))}function ms(l,t,u){var a=yt();Ia(l,t,u,a)}function Ia(l,t,u,a){var e={lane:a,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null};if(fn(l))hs(t,e);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var f=t.lastRenderedState,c=n(f,u);if(e.hasEagerState=!0,e.eagerState=c,nt(c,f))return Qe(l,t,e,0),vl===null&&Xe(),!1}catch{}if(u=Tf(l,t,e,a),u!==null)return Pl(u,l,a),os(u,t,a),!0}return!1}function ec(l,t,u,a){if(a={lane:2,revertLane:Yc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},fn(l)){if(t)throw Error(o(479))}else t=Tf(l,u,a,2),t!==null&&Pl(t,l,2)}function fn(l){var t=l.alternate;return l===G||t!==null&&t===G}function hs(l,t){ya=Pe=!0;var u=l.pending;u===null?t.next=t:(t.next=u.next,u.next=t),l.pending=t}function os(l,t,u){if((u&4194048)!==0){var a=t.lanes;a&=l.pendingLanes,u|=a,t.lanes=u,ri(l,u)}}var Pa={readContext:Yl,use:un,useCallback:ol,useContext:ol,useEffect:ol,useImperativeHandle:ol,useLayoutEffect:ol,useInsertionEffect:ol,useMemo:ol,useReducer:ol,useRef:ol,useState:ol,useDebugValue:ol,useDeferredValue:ol,useTransition:ol,useSyncExternalStore:ol,useId:ol,useHostTransitionStatus:ol,useFormState:ol,useActionState:ol,useOptimistic:ol,useMemoCache:ol,useCacheRefresh:ol};Pa.useEffectEvent=ol;var Ss={readContext:Yl,use:un,useCallback:function(l,t){return Ll().memoizedState=[l,t===void 0?null:t],l},useContext:Yl,useEffect:P0,useImperativeHandle:function(l,t,u){u=u!=null?u.concat([l]):null,en(4194308,4,as.bind(null,t,l),u)},useLayoutEffect:function(l,t){return en(4194308,4,l,t)},useInsertionEffect:function(l,t){en(4,2,l,t)},useMemo:function(l,t){var u=Ll();t=t===void 0?null:t;var a=l();if(Gu){kt(!0);try{l()}finally{kt(!1)}}return u.memoizedState=[a,t],a},useReducer:function(l,t,u){var a=Ll();if(u!==void 0){var e=u(t);if(Gu){kt(!0);try{u(t)}finally{kt(!1)}}}else e=t;return a.memoizedState=a.baseState=e,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:e},a.queue=l,l=l.dispatch=Ld.bind(null,G,l),[a.memoizedState,l]},useRef:function(l){var t=Ll();return l={current:l},t.memoizedState=l},useState:function(l){l=kf(l);var t=l.queue,u=ms.bind(null,G,t);return t.dispatch=u,[l.memoizedState,u]},useDebugValue:lc,useDeferredValue:function(l,t){var u=Ll();return tc(u,l,t)},useTransition:function(){var l=kf(!1);return l=is.bind(null,G,l.queue,!0,!1),Ll().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,u){var a=G,e=Ll();if(W){if(u===void 0)throw Error(o(407));u=u()}else{if(u=t(),vl===null)throw Error(o(349));(K&127)!==0||j0(a,t,u)}e.memoizedState=u;var n={value:u,getSnapshot:t};return e.queue=n,P0(X0.bind(null,a,n,l),[l]),a.flags|=2048,ma(9,{destroy:void 0},G0.bind(null,a,n,u,t),null),u},useId:function(){var l=Ll(),t=vl.identifierPrefix;if(W){var u=pt,a=Ut;u=(a&~(1<<32-et(a)-1)).toString(32)+u,t="_"+t+"R_"+u,u=ln++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof a.is=="string"?f.createElement("select",{is:a.is}):f.createElement("select"),a.multiple?n.multiple=!0:a.size&&(n.size=a.size);break;default:n=typeof a.is=="string"?f.createElement(e,{is:a.is}):f.createElement(e)}}n[ql]=t,n[wl]=a;l:for(f=t.child;f!==null;){if(f.tag===5||f.tag===6)n.appendChild(f.stateNode);else if(f.tag!==4&&f.tag!==27&&f.child!==null){f.child.return=f,f=f.child;continue}if(f===t)break l;for(;f.sibling===null;){if(f.return===null||f.return===t)break l;f=f.return}f.sibling.return=f.return,f=f.sibling}t.stateNode=n;l:switch(Gl(n,e,a),e){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break l;case"img":a=!0;break l;default:a=!1}a&&Vt(t)}}return dl(t),bc(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,u),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==a&&Vt(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(o(166));if(l=Z.current,ea(t)){if(l=t.stateNode,u=t.memoizedProps,a=null,e=Bl,e!==null)switch(e.tag){case 27:case 5:a=e.memoizedProps}l[ql]=t,l=!!(l.nodeValue===u||a!==null&&a.suppressHydrationWarning===!0||Bv(l.nodeValue,u)),l||uu(t,!0)}else l=Dn(l).createTextNode(a),l[ql]=t,t.stateNode=l}return dl(t),null;case 31:if(u=t.memoizedState,l===null||l.memoizedState!==null){if(a=ea(t),u!==null){if(l===null){if(!a)throw Error(o(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(o(557));l[ql]=t}else Nu(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;dl(t),l=!1}else u=Uf(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),l=!0;if(!l)return t.flags&256?(it(t),t):(it(t),null);if((t.flags&128)!==0)throw Error(o(558))}return dl(t),null;case 13:if(a=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(e=ea(t),a!==null&&a.dehydrated!==null){if(l===null){if(!e)throw Error(o(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(o(317));e[ql]=t}else Nu(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;dl(t),e=!1}else e=Uf(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=e),e=!0;if(!e)return t.flags&256?(it(t),t):(it(t),null)}return it(t),(t.flags&128)!==0?(t.lanes=u,t):(u=a!==null,l=l!==null&&l.memoizedState!==null,u&&(a=t.child,e=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(e=a.alternate.memoizedState.cachePool.pool),n=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(n=a.memoizedState.cachePool.pool),n!==e&&(a.flags|=2048)),u!==l&&u&&(t.child.flags|=8192),dn(t,t.updateQueue),dl(t),null);case 4:return gl(),l===null&&Qc(t.stateNode.containerInfo),dl(t),null;case 10:return Xt(t.type),dl(t),null;case 19:if(T(bl),a=t.memoizedState,a===null)return dl(t),null;if(e=(t.flags&128)!==0,n=a.rendering,n===null)if(e)te(a,!1);else{if(Sl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=Ie(l),n!==null){for(t.flags|=128,te(a,!1),l=n.updateQueue,t.updateQueue=l,dn(t,l),t.subtreeFlags=0,l=u,u=t.child;u!==null;)m0(u,l),u=u.sibling;return _(bl,bl.current&1|2),W&&jt(t,a.treeForkCount),t.child}l=l.sibling}a.tail!==null&&ut()>gn&&(t.flags|=128,e=!0,te(a,!1),t.lanes=4194304)}else{if(!e)if(l=Ie(n),l!==null){if(t.flags|=128,e=!0,l=l.updateQueue,t.updateQueue=l,dn(t,l),te(a,!0),a.tail===null&&a.tailMode==="hidden"&&!n.alternate&&!W)return dl(t),null}else 2*ut()-a.renderingStartTime>gn&&u!==536870912&&(t.flags|=128,e=!0,te(a,!1),t.lanes=4194304);a.isBackwards?(n.sibling=t.child,t.child=n):(l=a.last,l!==null?l.sibling=n:t.child=n,a.last=n)}return a.tail!==null?(l=a.tail,a.rendering=l,a.tail=l.sibling,a.renderingStartTime=ut(),l.sibling=null,u=bl.current,_(bl,e?u&1|2:u&1),W&&jt(t,a.treeForkCount),l):(dl(t),null);case 22:case 23:return it(t),xf(),a=t.memoizedState!==null,l!==null?l.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(u&536870912)!==0&&(t.flags&128)===0&&(dl(t),t.subtreeFlags&6&&(t.flags|=8192)):dl(t),u=t.updateQueue,u!==null&&dn(t,u.retryQueue),u=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(u=l.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==u&&(t.flags|=2048),l!==null&&T(qu),null;case 24:return u=null,l!==null&&(u=l.memoizedState.cache),t.memoizedState.cache!==u&&(t.flags|=2048),Xt(rl),dl(t),null;case 25:return null;case 30:return null}throw Error(o(156,t.tag))}function $d(l,t){switch(Mf(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Xt(rl),gl(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return Ee(t),null;case 31:if(t.memoizedState!==null){if(it(t),t.alternate===null)throw Error(o(340));Nu()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(it(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(o(340));Nu()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return T(bl),null;case 4:return gl(),null;case 10:return Xt(t.type),null;case 22:case 23:return it(t),xf(),l!==null&&T(qu),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Xt(rl),null;case 25:return null;default:return null}}function Qs(l,t){switch(Mf(t),t.tag){case 3:Xt(rl),gl();break;case 26:case 27:case 5:Ee(t);break;case 4:gl();break;case 31:t.memoizedState!==null&&it(t);break;case 13:it(t);break;case 19:T(bl);break;case 10:Xt(t.type);break;case 22:case 23:it(t),xf(),l!==null&&T(qu);break;case 24:Xt(rl)}}function ue(l,t){try{var u=t.updateQueue,a=u!==null?u.lastEffect:null;if(a!==null){var e=a.next;u=e;do{if((u.tag&l)===l){a=void 0;var n=u.create,f=u.inst;a=n(),f.destroy=a}u=u.next}while(u!==e)}}catch(c){ul(t,t.return,c)}}function su(l,t,u){try{var a=t.updateQueue,e=a!==null?a.lastEffect:null;if(e!==null){var n=e.next;a=n;do{if((a.tag&l)===l){var f=a.inst,c=f.destroy;if(c!==void 0){f.destroy=void 0,e=t;var i=u,m=c;try{m()}catch(g){ul(e,i,g)}}}a=a.next}while(a!==n)}}catch(g){ul(t,t.return,g)}}function xs(l){var t=l.updateQueue;if(t!==null){var u=l.stateNode;try{N0(t,u)}catch(a){ul(l,l.return,a)}}}function Zs(l,t,u){u.props=Xu(l.type,l.memoizedProps),u.state=l.memoizedState;try{u.componentWillUnmount()}catch(a){ul(l,t,a)}}function ae(l,t){try{var u=l.ref;if(u!==null){switch(l.tag){case 26:case 27:case 5:var a=l.stateNode;break;case 30:a=l.stateNode;break;default:a=l.stateNode}typeof u=="function"?l.refCleanup=u(a):u.current=a}}catch(e){ul(l,t,e)}}function Ht(l,t){var u=l.ref,a=l.refCleanup;if(u!==null)if(typeof a=="function")try{a()}catch(e){ul(l,t,e)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof u=="function")try{u(null)}catch(e){ul(l,t,e)}else u.current=null}function Vs(l){var t=l.type,u=l.memoizedProps,a=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":u.autoFocus&&a.focus();break l;case"img":u.src?a.src=u.src:u.srcSet&&(a.srcset=u.srcSet)}}catch(e){ul(l,l.return,e)}}function zc(l,t,u){try{var a=l.stateNode;gm(a,l.type,u,t),a[wl]=t}catch(e){ul(l,l.return,e)}}function Ls(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&Su(l.type)||l.tag===4}function rc(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||Ls(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&Su(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Tc(l,t,u){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?(u.nodeType===9?u.body:u.nodeName==="HTML"?u.ownerDocument.body:u).insertBefore(l,t):(t=u.nodeType===9?u.body:u.nodeName==="HTML"?u.ownerDocument.body:u,t.appendChild(l),u=u._reactRootContainer,u!=null||t.onclick!==null||(t.onclick=qt));else if(a!==4&&(a===27&&Su(l.type)&&(u=l.stateNode,t=null),l=l.child,l!==null))for(Tc(l,t,u),l=l.sibling;l!==null;)Tc(l,t,u),l=l.sibling}function mn(l,t,u){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?u.insertBefore(l,t):u.appendChild(l);else if(a!==4&&(a===27&&Su(l.type)&&(u=l.stateNode),l=l.child,l!==null))for(mn(l,t,u),l=l.sibling;l!==null;)mn(l,t,u),l=l.sibling}function Ks(l){var t=l.stateNode,u=l.memoizedProps;try{for(var a=l.type,e=t.attributes;e.length;)t.removeAttributeNode(e[0]);Gl(t,a,u),t[ql]=l,t[wl]=u}catch(n){ul(l,l.return,n)}}var Lt=!1,Al=!1,Ec=!1,Js=typeof WeakSet=="function"?WeakSet:Set,pl=null;function Fd(l,t){if(l=l.containerInfo,Vc=qn,l=e0(l),of(l)){if("selectionStart"in l)var u={start:l.selectionStart,end:l.selectionEnd};else l:{u=(u=l.ownerDocument)&&u.defaultView||window;var a=u.getSelection&&u.getSelection();if(a&&a.rangeCount!==0){u=a.anchorNode;var e=a.anchorOffset,n=a.focusNode;a=a.focusOffset;try{u.nodeType,n.nodeType}catch{u=null;break l}var f=0,c=-1,i=-1,m=0,g=0,r=l,h=null;t:for(;;){for(var S;r!==u||e!==0&&r.nodeType!==3||(c=f+e),r!==n||a!==0&&r.nodeType!==3||(i=f+a),r.nodeType===3&&(f+=r.nodeValue.length),(S=r.firstChild)!==null;)h=r,r=S;for(;;){if(r===l)break t;if(h===u&&++m===e&&(c=f),h===n&&++g===a&&(i=f),(S=r.nextSibling)!==null)break;r=h,h=r.parentNode}r=S}u=c===-1||i===-1?null:{start:c,end:i}}else u=null}u=u||{start:0,end:0}}else u=null;for(Lc={focusedElem:l,selectionRange:u},qn=!1,pl=t;pl!==null;)if(t=pl,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,pl=l;else for(;pl!==null;){switch(t=pl,n=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(u=0;u title"))),Gl(n,a,u),n[ql]=l,Ul(n),a=n;break l;case"link":var f=Iv("link","href",e).get(a+(u.href||""));if(f){for(var c=0;cfl&&(f=fl,fl=R,R=f);var y=u0(c,R),s=u0(c,fl);if(y&&s&&(S.rangeCount!==1||S.anchorNode!==y.node||S.anchorOffset!==y.offset||S.focusNode!==s.node||S.focusOffset!==s.offset)){var d=r.createRange();d.setStart(y.node,y.offset),S.removeAllRanges(),R>fl?(S.addRange(d),S.extend(s.node,s.offset)):(d.setEnd(s.node,s.offset),S.addRange(d))}}}}for(r=[],S=c;S=S.parentNode;)S.nodeType===1&&r.push({element:S,left:S.scrollLeft,top:S.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;cu?32:u,b.T=null,u=pc,pc=null;var n=mu,f=$t;if(Ol=0,ba=mu=null,$t=0,(P&6)!==0)throw Error(o(331));var c=P;if(P|=4,av(n.current),lv(n,n.current,f,u),P=c,se(0,!1),at&&typeof at.onPostCommitFiberRoot=="function")try{at.onPostCommitFiberRoot(Da,n)}catch{}return!0}finally{A.p=e,b.T=a,Tv(l,t)}}function Av(l,t,u){t=St(u,t),t=ic(l.stateNode,t,2),l=fu(l,t,2),l!==null&&(pa(l,2),Nt(l))}function ul(l,t,u){if(l.tag===3)Av(l,l,u);else for(;t!==null;){if(t.tag===3){Av(t,l,u);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(du===null||!du.has(a))){l=St(u,l),u=_s(2),a=fu(t,u,2),a!==null&&(Os(u,a,t,l),pa(a,2),Nt(a));break}}t=t.return}}function Cc(l,t,u){var a=l.pingCache;if(a===null){a=l.pingCache=new Pd;var e=new Set;a.set(t,e)}else e=a.get(t),e===void 0&&(e=new Set,a.set(t,e));e.has(u)||(Oc=!0,e.add(u),l=em.bind(null,l,t,u),t.then(l,l))}function em(l,t,u){var a=l.pingCache;a!==null&&a.delete(t),l.pingedLanes|=l.suspendedLanes&u,l.warmLanes&=~u,vl===l&&(K&u)===u&&(Sl===4||Sl===3&&(K&62914560)===K&&300>ut()-Sn?(P&2)===0&&za(l,0):Mc|=u,ga===K&&(ga=0)),Nt(l)}function _v(l,t){t===0&&(t=bi()),l=pu(l,t),l!==null&&(pa(l,t),Nt(l))}function nm(l){var t=l.memoizedState,u=0;t!==null&&(u=t.retryLane),_v(l,u)}function fm(l,t){var u=0;switch(l.tag){case 31:case 13:var a=l.stateNode,e=l.memoizedState;e!==null&&(u=e.retryLane);break;case 19:a=l.stateNode;break;case 22:a=l.stateNode._retryCache;break;default:throw Error(o(314))}a!==null&&a.delete(t),_v(l,u)}function cm(l,t){return Kn(l,t)}var An=null,Ta=null,qc=!1,_n=!1,Bc=!1,ou=0;function Nt(l){l!==Ta&&l.next===null&&(Ta===null?An=Ta=l:Ta=Ta.next=l),_n=!0,qc||(qc=!0,sm())}function se(l,t){if(!Bc&&_n){Bc=!0;do for(var u=!1,a=An;a!==null;){if(l!==0){var e=a.pendingLanes;if(e===0)var n=0;else{var f=a.suspendedLanes,c=a.pingedLanes;n=(1<<31-et(42|l)+1)-1,n&=e&~(f&~c),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(u=!0,Uv(a,n))}else n=K,n=De(a,a===vl?n:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(n&3)===0||Ua(a,n)||(u=!0,Uv(a,n));a=a.next}while(u);Bc=!1}}function im(){Ov()}function Ov(){_n=qc=!1;var l=0;ou!==0&&zm()&&(l=ou);for(var t=ut(),u=null,a=An;a!==null;){var e=a.next,n=Mv(a,t);n===0?(a.next=null,u===null?An=e:u.next=e,e===null&&(Ta=u)):(u=a,(l!==0||(n&3)!==0)&&(_n=!0)),a=e}Ol!==0&&Ol!==5||se(l),ou!==0&&(ou=0)}function Mv(l,t){for(var u=l.suspendedLanes,a=l.pingedLanes,e=l.expirationTimes,n=l.pendingLanes&-62914561;0c)break;var g=i.transferSize,r=i.initiatorType;g&&Yv(r)&&(i=i.responseEnd,f+=g*(i"u"?null:document;function Wv(l,t,u){var a=Ea;if(a&&typeof t=="string"&&t){var e=ht(t);e='link[rel="'+l+'"][href="'+e+'"]',typeof u=="string"&&(e+='[crossorigin="'+u+'"]'),wv.has(e)||(wv.add(e),l={rel:l,crossOrigin:u,href:t},a.querySelector(e)===null&&(t=a.createElement("link"),Gl(t,"link",l),Ul(t),a.head.appendChild(t)))}}function Um(l){Ft.D(l),Wv("dns-prefetch",l,null)}function pm(l,t){Ft.C(l,t),Wv("preconnect",l,t)}function Hm(l,t,u){Ft.L(l,t,u);var a=Ea;if(a&&l&&t){var e='link[rel="preload"][as="'+ht(t)+'"]';t==="image"&&u&&u.imageSrcSet?(e+='[imagesrcset="'+ht(u.imageSrcSet)+'"]',typeof u.imageSizes=="string"&&(e+='[imagesizes="'+ht(u.imageSizes)+'"]')):e+='[href="'+ht(l)+'"]';var n=e;switch(t){case"style":n=Aa(l);break;case"script":n=_a(l)}Et.has(n)||(l=N({rel:"preload",href:t==="image"&&u&&u.imageSrcSet?void 0:l,as:t},u),Et.set(n,l),a.querySelector(e)!==null||t==="style"&&a.querySelector(me(n))||t==="script"&&a.querySelector(he(n))||(t=a.createElement("link"),Gl(t,"link",l),Ul(t),a.head.appendChild(t)))}}function Nm(l,t){Ft.m(l,t);var u=Ea;if(u&&l){var a=t&&typeof t.as=="string"?t.as:"script",e='link[rel="modulepreload"][as="'+ht(a)+'"][href="'+ht(l)+'"]',n=e;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=_a(l)}if(!Et.has(n)&&(l=N({rel:"modulepreload",href:l},t),Et.set(n,l),u.querySelector(e)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(u.querySelector(he(n)))return}a=u.createElement("link"),Gl(a,"link",l),Ul(a),u.head.appendChild(a)}}}function Rm(l,t,u){Ft.S(l,t,u);var a=Ea;if(a&&l){var e=Ku(a).hoistableStyles,n=Aa(l);t=t||"default";var f=e.get(n);if(!f){var c={loading:0,preload:null};if(f=a.querySelector(me(n)))c.loading=5;else{l=N({rel:"stylesheet",href:l,"data-precedence":t},u),(u=Et.get(n))&&kc(l,u);var i=f=a.createElement("link");Ul(i),Gl(i,"link",l),i._p=new Promise(function(m,g){i.onload=m,i.onerror=g}),i.addEventListener("load",function(){c.loading|=1}),i.addEventListener("error",function(){c.loading|=2}),c.loading|=4,pn(f,t,a)}f={type:"stylesheet",instance:f,count:1,state:c},e.set(n,f)}}}function Cm(l,t){Ft.X(l,t);var u=Ea;if(u&&l){var a=Ku(u).hoistableScripts,e=_a(l),n=a.get(e);n||(n=u.querySelector(he(e)),n||(l=N({src:l,async:!0},t),(t=Et.get(e))&&Ic(l,t),n=u.createElement("script"),Ul(n),Gl(n,"link",l),u.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(e,n))}}function qm(l,t){Ft.M(l,t);var u=Ea;if(u&&l){var a=Ku(u).hoistableScripts,e=_a(l),n=a.get(e);n||(n=u.querySelector(he(e)),n||(l=N({src:l,async:!0,type:"module"},t),(t=Et.get(e))&&Ic(l,t),n=u.createElement("script"),Ul(n),Gl(n,"link",l),u.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(e,n))}}function $v(l,t,u,a){var e=(e=Z.current)?Un(e):null;if(!e)throw Error(o(446));switch(l){case"meta":case"title":return null;case"style":return typeof u.precedence=="string"&&typeof u.href=="string"?(t=Aa(u.href),u=Ku(e).hoistableStyles,a=u.get(t),a||(a={type:"style",instance:null,count:0,state:null},u.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(u.rel==="stylesheet"&&typeof u.href=="string"&&typeof u.precedence=="string"){l=Aa(u.href);var n=Ku(e).hoistableStyles,f=n.get(l);if(f||(e=e.ownerDocument||e,f={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,f),(n=e.querySelector(me(l)))&&!n._p&&(f.instance=n,f.state.loading=5),Et.has(l)||(u={rel:"preload",as:"style",href:u.href,crossOrigin:u.crossOrigin,integrity:u.integrity,media:u.media,hrefLang:u.hrefLang,referrerPolicy:u.referrerPolicy},Et.set(l,u),n||Bm(e,l,u,f.state))),t&&a===null)throw Error(o(528,""));return f}if(t&&a!==null)throw Error(o(529,""));return null;case"script":return t=u.async,u=u.src,typeof u=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=_a(u),u=Ku(e).hoistableScripts,a=u.get(t),a||(a={type:"script",instance:null,count:0,state:null},u.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(o(444,l))}}function Aa(l){return'href="'+ht(l)+'"'}function me(l){return'link[rel="stylesheet"]['+l+"]"}function Fv(l){return N({},l,{"data-precedence":l.precedence,precedence:null})}function Bm(l,t,u,a){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=l.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),Gl(t,"link",u),Ul(t),l.head.appendChild(t))}function _a(l){return'[src="'+ht(l)+'"]'}function he(l){return"script[async]"+l}function kv(l,t,u){if(t.count++,t.instance===null)switch(t.type){case"style":var a=l.querySelector('style[data-href~="'+ht(u.href)+'"]');if(a)return t.instance=a,Ul(a),a;var e=N({},u,{"data-href":u.href,"data-precedence":u.precedence,href:null,precedence:null});return a=(l.ownerDocument||l).createElement("style"),Ul(a),Gl(a,"style",e),pn(a,u.precedence,l),t.instance=a;case"stylesheet":e=Aa(u.href);var n=l.querySelector(me(e));if(n)return t.state.loading|=4,t.instance=n,Ul(n),n;a=Fv(u),(e=Et.get(e))&&kc(a,e),n=(l.ownerDocument||l).createElement("link"),Ul(n);var f=n;return f._p=new Promise(function(c,i){f.onload=c,f.onerror=i}),Gl(n,"link",a),t.state.loading|=4,pn(n,u.precedence,l),t.instance=n;case"script":return n=_a(u.src),(e=l.querySelector(he(n)))?(t.instance=e,Ul(e),e):(a=u,(e=Et.get(n))&&(a=N({},u),Ic(a,e)),l=l.ownerDocument||l,e=l.createElement("script"),Ul(e),Gl(e,"link",a),l.head.appendChild(e),t.instance=e);case"void":return null;default:throw Error(o(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,pn(a,u.precedence,l));return t.instance}function pn(l,t,u){for(var a=u.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),e=a.length?a[a.length-1]:null,n=e,f=0;f title"):null)}function Ym(l,t,u){if(u===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;return t.rel==="stylesheet"?(l=t.disabled,typeof t.precedence=="string"&&l==null):!0;case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function ly(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function jm(l,t,u,a){if(u.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(u.state.loading&4)===0){if(u.instance===null){var e=Aa(a.href),n=t.querySelector(me(e));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Nn.bind(l),t.then(l,l)),u.state.loading|=4,u.instance=n,Ul(n);return}n=t.ownerDocument||t,a=Fv(a),(e=Et.get(e))&&kc(a,e),n=n.createElement("link"),Ul(n);var f=n;f._p=new Promise(function(c,i){f.onload=c,f.onerror=i}),Gl(n,"link",a),u.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(u,t),(t=u.state.preload)&&(u.state.loading&3)===0&&(l.count++,u=Nn.bind(l),t.addEventListener("load",u),t.addEventListener("error",u))}}var Pc=0;function Gm(l,t){return l.stylesheets&&l.count===0&&Cn(l,l.stylesheets),0Pc?50:800)+t);return l.unsuspend=u,function(){l.unsuspend=null,clearTimeout(a),clearTimeout(e)}}:null}function Nn(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Cn(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var Rn=null;function Cn(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Rn=new Map,t.forEach(Xm,l),Rn=null,Nn.call(l))}function Xm(l,t){if(!(t.state.loading&4)){var u=Rn.get(l);if(u)var a=u.get(null);else{u=new Map,Rn.set(l,u);for(var e=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(M)}catch(cl){console.error(cl)}}return M(),ii.exports=t1(),ii.exports}var a1=u1();async function Te(M,cl){const $=new Headers(cl?.headers),o=await fetch(M,{...cl,headers:$}),q=await o.json().catch(()=>({}));if(!o.ok){const w=typeof q=="object"&&q!==null&&"detail"in q?String(q.detail):JSON.stringify(q);throw new Error(w||`HTTP ${o.status}`)}return q}function e1(){const[M,cl]=lt.useState("Loading…"),[$,o]=lt.useState(""),[q,w]=lt.useState(""),[ml,Hl]=lt.useState("7d"),[p,E]=lt.useState("local"),[F,N]=lt.useState(""),[il,Ql]=lt.useState(""),[Nl,Rl]=lt.useState("local"),[dt,xl]=lt.useState("7d"),[At,Ml]=lt.useState(""),[Kl,Zl]=lt.useState(""),_l=lt.useCallback(async()=>{try{const[x,tt,Jl]=await Promise.all([Te("/v1/releases"),Te("/v1/promoted"),Te("/v1/actions")]);cl(JSON.stringify({releases:x.releases,promoted:tt.promoted,actions:Jl.actions},null,2))}catch(x){cl(String(x))}},[]);lt.useEffect(()=>{_l()},[_l]);const Q=async()=>{N("");try{const x={baseline_release_id:$.trim(),candidate_release_id:q.trim(),window:ml.trim(),environment:p.trim()||null},tt=await Te("/v1/diff",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});N(JSON.stringify(tt,null,2))}catch(x){N(String(x))}},Cl=async x=>{Zl("");const tt=At.trim();if(!tt){Zl("Reason is required.");return}const Jl=x==="/v1/promote"?"promote":"rollback";if(window.confirm(`Confirm ${Jl} for this release?`))try{const Dl=await Te(x,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({release_id:il.trim(),environment:Nl.trim(),window:dt.trim(),reason:tt,actor:"react-ui"})});Zl(JSON.stringify(Dl,null,2)),await _l()}catch(Dl){Zl(String(Dl))}};return Y.jsxs(Y.Fragment,{children:[Y.jsx("h1",{children:"FlightDeck"}),Y.jsx("p",{className:"muted",children:"Local timeline, diff, and promotion actions (React + Vite)."}),Y.jsxs("section",{children:[Y.jsx("h2",{children:"Timeline"}),Y.jsx("button",{type:"button",onClick:()=>{_l()},children:"Refresh"}),Y.jsx("pre",{children:M})]}),Y.jsxs("section",{children:[Y.jsx("h2",{children:"Run diff"}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"db",children:"Baseline release"}),Y.jsx("input",{id:"db",value:$,onChange:x=>o(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"dc",children:"Candidate release"}),Y.jsx("input",{id:"dc",value:q,onChange:x=>w(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"dw",children:"Window"}),Y.jsx("input",{id:"dw",value:ml,onChange:x=>Hl(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"de",children:"Environment"}),Y.jsx("input",{id:"de",value:p,onChange:x=>E(x.target.value)})]}),Y.jsx("button",{type:"button",onClick:()=>{Q()},children:"Compute diff"}),Y.jsx("pre",{children:F})]}),Y.jsxs("section",{children:[Y.jsx("h2",{children:"Promote / rollback"}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"ar",children:"Release id"}),Y.jsx("input",{id:"ar",value:il,onChange:x=>Ql(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"ae",children:"Environment"}),Y.jsx("input",{id:"ae",value:Nl,onChange:x=>Rl(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"aw",children:"Window"}),Y.jsx("input",{id:"aw",value:dt,onChange:x=>xl(x.target.value)})]}),Y.jsxs("div",{children:[Y.jsx("label",{htmlFor:"ar2",children:"Reason"}),Y.jsx("input",{id:"ar2",value:At,onChange:x=>Ml(x.target.value)})]}),Y.jsx("button",{type:"button",onClick:()=>{Cl("/v1/promote")},children:"Promote"}),Y.jsx("button",{type:"button",onClick:()=>{Cl("/v1/rollback")},children:"Rollback"}),Y.jsx("pre",{children:Kl})]})]})}const _y=document.getElementById("root");if(!_y)throw new Error("Missing #root");a1.createRoot(_y).render(Y.jsx(lt.StrictMode,{children:Y.jsx(e1,{})})); diff --git a/src/flightdeck/server/static/index.html b/src/flightdeck/server/static/index.html new file mode 100644 index 0000000..4e51181 --- /dev/null +++ b/src/flightdeck/server/static/index.html @@ -0,0 +1,13 @@ + + + + + + FlightDeck + + + + +
+ + diff --git a/tests/fixtures/json/policy_invalid_error_rate_gt_1_v1.json b/tests/fixtures/json/policy_invalid_error_rate_gt_1_v1.json new file mode 100644 index 0000000..1a8902e --- /dev/null +++ b/tests/fixtures/json/policy_invalid_error_rate_gt_1_v1.json @@ -0,0 +1,4 @@ +{ + "policy_id": "bad-policy", + "max_error_rate": 1.5 +} diff --git a/tests/fixtures/json/pricing_table_invalid_negative_price_v1.json b/tests/fixtures/json/pricing_table_invalid_negative_price_v1.json new file mode 100644 index 0000000..58ae15e --- /dev/null +++ b/tests/fixtures/json/pricing_table_invalid_negative_price_v1.json @@ -0,0 +1,11 @@ +{ + "provider": "openai", + "pricing_version": "openai-test-negative", + "entries": [ + { + "model": "gpt-4.1-mini", + "input_usd_per_1k_tokens": -0.01, + "output_usd_per_1k_tokens": 1.0 + } + ] +} diff --git a/tests/fixtures/json/release_artifact_invalid_wrong_kind_v1.json b/tests/fixtures/json/release_artifact_invalid_wrong_kind_v1.json new file mode 100644 index 0000000..cb0a226 --- /dev/null +++ b/tests/fixtures/json/release_artifact_invalid_wrong_kind_v1.json @@ -0,0 +1,24 @@ +{ + "api_version": "v1", + "kind": "Deployment", + "metadata": { + "name": "fixture-agent", + "version": "1" + }, + "spec": { + "agent": { + "agent_id": "agent_fixture" + }, + "runtime": { + "provider": "openai", + "model": "gpt-4.1-mini" + }, + "prompts": { + "system_ref": "prompts/system.md" + }, + "pricing_reference": { + "provider": "openai", + "pricing_version": "openai-fixture-1" + } + } +} diff --git a/tests/fixtures/json/run_event_invalid_api_version_v0.json b/tests/fixtures/json/run_event_invalid_api_version_v0.json new file mode 100644 index 0000000..a071c2a --- /dev/null +++ b/tests/fixtures/json/run_event_invalid_api_version_v0.json @@ -0,0 +1,28 @@ +{ + "api_version": "v0", + "type": "run_end", + "timestamp": "2026-04-30T12:00:00+00:00", + "workspace_id": "ws_fixture", + "agent_id": "agent_fixture", + "release_id": "rel_fixture", + "run_id": "run_fixture_bad_api", + "tenant_id": "tenant_fixture", + "task_id": "task_fixture", + "environment": "local", + "metrics": { + "latency_ms": 10, + "success": true, + "error_type": null + }, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 100, + "output_tokens": 50, + "cached_input_tokens": 0 + }, + "tools": [] + }, + "labels": {} +} diff --git a/tests/fixtures/json/run_event_invalid_missing_release_id_v1.json b/tests/fixtures/json/run_event_invalid_missing_release_id_v1.json new file mode 100644 index 0000000..1dca5dc --- /dev/null +++ b/tests/fixtures/json/run_event_invalid_missing_release_id_v1.json @@ -0,0 +1,26 @@ +{ + "api_version": "v1", + "type": "run_end", + "timestamp": "2026-05-01T00:00:00+00:00", + "workspace_id": "ws_local", + "agent_id": "agent_support", + "run_id": "run_1", + "tenant_id": "tenant_acme", + "task_id": "task_support", + "environment": "local", + "metrics": { + "latency_ms": 1000, + "success": true + }, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 100, + "output_tokens": 50, + "cached_input_tokens": 0 + }, + "tools": [] + }, + "labels": {} +} diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py new file mode 100644 index 0000000..fc6206a --- /dev/null +++ b/tests/test_cli_contract.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +from click.testing import CliRunner + +from flightdeck.cli.main import cli +from tests.test_spine import write_events, write_policy, write_pricing, write_release + + +def test_release_verify_checksum_mismatch_exits_2(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + rel_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + release_id = runner.invoke(cli, ["release", "register", str(rel_dir)]).output.strip() + + prompt_file = rel_dir / "prompts" / "system.md" + prompt_file.write_text("system changed", encoding="utf-8") + res = runner.invoke(cli, ["release", "verify", release_id, "--path", str(rel_dir)]) + assert res.exit_code == 2 + assert "CHECKSUM MISMATCH" in res.output + + +def test_release_diff_contract_invalid_window_is_nonzero(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate)]).output.strip() + res = runner.invoke(cli, ["release", "diff", baseline_id, candidate_id, "--window", "7x"]) + assert res.exit_code != 0 + assert "Invalid window unit: 7x" in res.output + + +def test_release_promote_policy_fail_contract(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=0.0001) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + baseline = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate)]).output.strip() + + now = datetime.now(tz=timezone.utc) + be = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + ce = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(be)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(ce)]).exit_code == 0 + assert ( + runner.invoke( + cli, + ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "baseline"], + ).exit_code + == 0 + ) + res = runner.invoke( + cli, + ["release", "promote", candidate_id, "--env", "local", "--window", "7d", "--reason", "attempt"], + ) + assert res.exit_code != 0 + assert "Policy: FAIL" in res.output + assert "Promotion blocked by policy" in res.output + + +def test_release_verify_ok_exits_zero(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + rel_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + release_id = runner.invoke(cli, ["release", "register", str(rel_dir)]).output.strip() + res = runner.invoke(cli, ["release", "verify", release_id, "--path", str(rel_dir)]) + assert res.exit_code == 0 + assert "OK: checksum matches" in res.output + + +def test_release_diff_unknown_baseline_nonzero(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + cand = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_id = runner.invoke(cli, ["release", "register", str(cand)]).output.strip() + res = runner.invoke(cli, ["release", "diff", "rel_does_not_exist", candidate_id, "--window", "7d"]) + assert res.exit_code != 0 + assert "Unknown baseline release" in res.output + + +def test_release_history_shows_promote_line(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + baseline = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate)]).output.strip() + + now = datetime.now(tz=timezone.utc) + be = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + ce = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(be)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(ce)]).exit_code == 0 + assert ( + runner.invoke( + cli, + ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "first"], + ).exit_code + == 0 + ) + + hist = runner.invoke(cli, ["release", "history", "--agent", "agent_support", "--env", "local"]) + assert hist.exit_code == 0 + assert "promote" in hist.output + assert baseline_id in hist.output + + +def test_release_rollback_exits_zero_and_history_shows_rollback(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + rollback_dir = write_release( + tmp_path, + agent_id="agent_support", + version="3", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + rollback_id = runner.invoke(cli, ["release", "register", str(rollback_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + for rid in (baseline_id, candidate_id, rollback_id): + ev = write_events(tmp_path, release_id=rid, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(ev)]).exit_code == 0 + + assert ( + runner.invoke( + cli, + ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "baseline"], + ).exit_code + == 0 + ) + assert ( + runner.invoke( + cli, + ["release", "promote", candidate_id, "--env", "local", "--window", "7d", "--reason", "candidate"], + ).exit_code + == 0 + ) + rb = runner.invoke( + cli, + ["release", "rollback", rollback_id, "--env", "local", "--window", "7d", "--reason", "rollback smoke"], + ) + assert rb.exit_code == 0 + assert "Rolled back" in rb.output + + hist = runner.invoke(cli, ["release", "history", "--agent", "agent_support", "--env", "local"]) + assert hist.exit_code == 0 + assert "rollback" in hist.output + assert rollback_id in hist.output diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 84d7fd7..d97baba 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -170,3 +170,58 @@ def test_doctor_fails_when_promoted_release_missing(tmp_path: Path, monkeypatch) res = runner.invoke(cli, ["doctor"]) assert res.exit_code != 0 assert "rel_missing" in res.output or "missing" in res.output.lower() + + +def test_release_actions_audit_seq_is_contiguous_direct_check(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + rollback_dir = write_release( + tmp_path, + agent_id="agent_support", + version="3", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + rollback_id = runner.invoke(cli, ["release", "register", str(rollback_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + assert runner.invoke(cli, ["runs", "ingest", str(write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now))]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now))]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(write_events(tmp_path, release_id=rollback_id, agent_id="agent_support", n=5, ts=now))]).exit_code == 0 + + assert runner.invoke( + cli, ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "baseline"] + ).exit_code == 0 + assert runner.invoke( + cli, ["release", "promote", candidate_id, "--env", "local", "--window", "7d", "--reason", "candidate"] + ).exit_code == 0 + assert runner.invoke( + cli, ["release", "rollback", rollback_id, "--env", "local", "--window", "7d", "--reason", "rollback"] + ).exit_code == 0 + + storage = Storage(str(tmp_path / ".flightdeck" / "flightdeck.db")) + actions = storage.list_release_actions(agent_id="agent_support", environment="local") + seqs = sorted([a.audit_seq for a in actions if a.audit_seq is not None]) + assert seqs == list(range(1, len(seqs) + 1)) diff --git a/tests/test_quickstart_smoke.py b/tests/test_quickstart_smoke.py index 9d11311..cae3acc 100644 --- a/tests/test_quickstart_smoke.py +++ b/tests/test_quickstart_smoke.py @@ -7,9 +7,8 @@ def test_quickstart_smoke_script_exits_zero() -> None: root = Path(__file__).resolve().parents[1] - script = root / "scripts" / "quickstart_smoke.py" proc = subprocess.run( - [sys.executable, str(script)], + [sys.executable, "-m", "flightdeck.quickstart_smoke"], cwd=root, capture_output=True, text=True, diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 0a4188f..585766e 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -5,7 +5,7 @@ from typing import Any import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from flightdeck.models import Policy, PricingTable, ReleaseArtifact, RunEvent @@ -31,6 +31,22 @@ def test_minimal_json_fixture_validates(filename: str, model: type[BaseModel]) - model.model_validate(data) +@pytest.mark.parametrize( + ("filename", "model"), + [ + ("run_event_invalid_missing_release_id_v1.json", RunEvent), + ("run_event_invalid_api_version_v0.json", RunEvent), + ("policy_invalid_error_rate_gt_1_v1.json", Policy), + ("pricing_table_invalid_negative_price_v1.json", PricingTable), + ("release_artifact_invalid_wrong_kind_v1.json", ReleaseArtifact), + ], +) +def test_invalid_json_fixture_rejected(filename: str, model: type[BaseModel]) -> None: + data = _read_json(_FIXTURE_DIR / filename) + with pytest.raises(ValidationError): + model.model_validate(data) + + def test_committed_json_schemas_match_models() -> None: root = Path(__file__).resolve().parents[1] / "schemas" / "v1" diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 5f94f8f..2a0a5f8 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,12 +1,14 @@ from __future__ import annotations +import asyncio import json from datetime import datetime, timezone import httpx +import pytest from flightdeck.models import RunEvent, RunEventModelUsage, RunEventUsage -from flightdeck.sdk.client import FlightdeckClient +from flightdeck.sdk.client import AsyncFlightdeckClient, FlightdeckClient def test_flightdeck_client_ingest_uses_post_v1_events() -> None: @@ -50,3 +52,117 @@ def handler(request: httpx.Request) -> httpx.Response: assert len(events_out) == 1 assert events_out[0]["run_id"] == "run_sdk_mock" assert events_out[0]["api_version"] == "v1" + + +def _event(run_id: str) -> RunEvent: + now = datetime.now(tz=timezone.utc) + return RunEvent( + timestamp=now, + agent_id="agent_support", + release_id="rel_test", + run_id=run_id, + tenant_id="tenant_acme", + task_id="task_1", + environment="local", + usage=RunEventUsage( + model=RunEventModelUsage( + provider="openai", + model="gpt-4.1-mini", + input_tokens=10, + output_tokens=5, + ) + ), + ) + + +def test_flightdeck_client_sends_bearer_when_api_token_set() -> None: + seen_auth: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_auth.append(request.headers.get("authorization")) + return httpx.Response(200, json={"inserted": 1}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http, api_token="secret") + client.ingest_run_events([_event("tok-run")]) + assert seen_auth == ["Bearer secret"] + + +def test_flightdeck_client_list_releases_get() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url.path == "/v1/releases" + return httpx.Response(200, json={"releases": []}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http) + assert client.list_releases() == {"releases": []} + + +def test_flightdeck_client_ingest_batch_chunks_payload() -> None: + seen_lengths: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content.decode("utf-8")) + seen_lengths.append(len(body["events"])) + return httpx.Response(200, json={"inserted": len(body["events"])}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http) + inserted = client.ingest_run_events_batch([_event("r1"), _event("r2"), _event("r3")], chunk_size=2) + assert inserted == 3 + assert seen_lengths == [2, 1] + + +def test_flightdeck_client_retries_request_error() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise httpx.ConnectError("temporary", request=request) + return httpx.Response(200, json={"inserted": 1}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient( + "http://flightdeck.test", + client=http, + max_retries=1, + retry_backoff_s=0.0, + ) + assert client.ingest_run_events([_event("retry-run")]) == 1 + assert attempts == 2 + + +def test_flightdeck_client_invalid_chunk_size() -> None: + client = FlightdeckClient("http://flightdeck.test") + try: + with pytest.raises(ValueError, match="chunk_size must be > 0"): + client.ingest_run_events_batch([], chunk_size=0) + finally: + client.close() + + +def test_async_flightdeck_client_ingest_batch() -> None: + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + body = json.loads(request.content.decode("utf-8")) + return httpx.Response(200, json={"inserted": len(body["events"])}) + + async def _run() -> int: + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport, base_url="http://flightdeck.test") as http: + client = AsyncFlightdeckClient("http://flightdeck.test", client=http) + return await client.ingest_run_events_batch([_event("a1"), _event("a2"), _event("a3")], chunk_size=2) + + inserted = asyncio.run(_run()) + assert inserted == 3 + assert calls == 2 diff --git a/tests/test_server_actions.py b/tests/test_server_actions.py new file mode 100644 index 0000000..87c74ba --- /dev/null +++ b/tests/test_server_actions.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import os +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from click.testing import CliRunner +from fastapi.testclient import TestClient + +from flightdeck.cli.main import cli +from flightdeck.config import load_config +from flightdeck.server.app import create_app +from flightdeck.storage import Storage +from tests.test_spine import write_events, write_policy, write_pricing, write_release + + +@contextmanager +def _cwd(path: Path): + prev = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(prev) + + +def _seed_workspace(path: Path) -> tuple[CliRunner, str, str]: + path.mkdir(parents=True, exist_ok=True) + runner = CliRunner() + with _cwd(path): + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + baseline_dir = write_release( + path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events(path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + candidate_events = write_events(path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + assert ( + runner.invoke( + cli, + ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "baseline"], + ).exit_code + == 0 + ) + return runner, baseline_id, candidate_id + + +def test_http_routes_expose_read_and_diff(tmp_path: Path) -> None: + ws = tmp_path / "ws" + runner, baseline_id, candidate_id = _seed_workspace(ws) + del runner + with _cwd(ws): + with TestClient(create_app()) as client: + rel = client.get("/v1/releases") + assert rel.status_code == 200 + assert any(item["release_id"] == baseline_id for item in rel.json()["releases"]) + + promoted = client.get("/v1/promoted") + assert promoted.status_code == 200 + assert promoted.json()["promoted"][0]["release_id"] == baseline_id + + diff_resp = client.post( + "/v1/diff", + json={ + "baseline_release_id": baseline_id, + "candidate_release_id": candidate_id, + "window": "7d", + "environment": "local", + }, + ) + assert diff_resp.status_code == 200 + body = diff_resp.json() + assert body["samples"]["baseline_runs"] == 5 + assert body["samples"]["candidate_runs"] == 5 + + +def test_http_promote_parity_with_cli_outcome(tmp_path: Path) -> None: + cli_ws = tmp_path / "cli" + http_ws = tmp_path / "http" + + cli_runner, _, cli_candidate_id = _seed_workspace(cli_ws) + http_runner, _, http_candidate_id = _seed_workspace(http_ws) + del http_runner + + with _cwd(cli_ws): + cli_res = cli_runner.invoke( + cli, + ["release", "promote", cli_candidate_id, "--env", "local", "--window", "7d", "--reason", "ship"], + ) + assert cli_res.exit_code == 0 + cli_storage = Storage(load_config().db_path) + cli_storage.migrate() + cli_ptr = cli_storage.get_promoted_release_id("agent_support", "local") + cli_last = cli_storage.list_release_actions(agent_id="agent_support", environment="local")[0] + + with _cwd(http_ws): + with TestClient(create_app()) as client: + http_res = client.post( + "/v1/promote", + json={ + "release_id": http_candidate_id, + "environment": "local", + "window": "7d", + "reason": "ship", + "actor": "http-test", + }, + ) + assert http_res.status_code == 200 + assert http_res.json()["promoted_pointer_changed"] is True + + http_storage = Storage(load_config().db_path) + http_storage.migrate() + http_ptr = http_storage.get_promoted_release_id("agent_support", "local") + http_last = http_storage.list_release_actions(agent_id="agent_support", environment="local")[0] + + assert cli_ptr == cli_candidate_id + assert http_ptr == http_candidate_id + assert cli_last.action == http_last.action == "promote" + assert cli_last.policy_result.passed is True + assert http_last.policy_result.passed is True + assert cli_last.baseline_release_id is not None + assert http_last.baseline_release_id is not None + + +def test_http_promote_requires_reason(tmp_path: Path) -> None: + ws = tmp_path / "ws_reason" + _, _, candidate_id = _seed_workspace(ws) + with _cwd(ws): + with TestClient(create_app()) as client: + res = client.post( + "/v1/promote", + json={ + "release_id": candidate_id, + "environment": "local", + "window": "7d", + "reason": "", + }, + ) + assert res.status_code == 422 + + +def test_ui_root_serves_vite_index(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + assert CliRunner().invoke(cli, ["init"]).exit_code == 0 + app = create_app() + with TestClient(app) as client: + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in (r.headers.get("content-type") or "") + assert '
' in r.text + assert "/assets/" in r.text diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..13d6ca1 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,17 @@ +.env.local +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# Ephemeral agent/IDE chunk files (never commit; delete if they reappear) +.plchunk*.txt +.pkt*.txt +.lock-part-*.txt +.slice* +.c[0-9][0-9] +.k[0-9][0-9] +.s[0-9][0-9] +.t[0-9]* +.tiny +/_lock_*.json diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..b50b3cb --- /dev/null +++ b/web/README.md @@ -0,0 +1,70 @@ +# FlightDeck web UI (React + Vite) + +Source for the local UI served by **`flightdeck serve`** at **`/`**. Production bundles are emitted to **`../src/flightdeck/server/static/`** (FastAPI serves **`index.html`** and hashed files under **`/assets/`**). + +## Commands + +```bash +cd web +npm ci +npm run build +``` + +After any change under **`web/src/`**, run **`npm run build`** again and commit the updated **`src/flightdeck/server/static/`** tree. **CI** rebuilds and runs **`git diff --exit-code`** on that path so committed assets cannot drift. + +## Local development (`npm run dev`) + +1. In one terminal, run the API from a workspace with **`flightdeck.yaml`** (default **`8765`**): + + ```bash + flightdeck serve + ``` + +2. In another: + + ```bash + cd web + cp .env.example .env.local # optional: set VITE_FLIGHTDECK_LOCAL_API_TOKEN + npm ci + npm run dev + ``` + +**Vite** proxies **`/v1/*`** and **`/health`** to **`http://127.0.0.1:8765`** (override with **`VITE_DEV_PROXY_TARGET`** in **`.env.local`** or the environment). The React app calls relative **`/v1/...`** URLs so the browser talks to the Vite dev server only. + +**Auth:** when the server has **`FLIGHTDECK_LOCAL_API_TOKEN`** set, set **`VITE_FLIGHTDECK_LOCAL_API_TOKEN`** in **`.env.local`** to the same value so promote/rollback requests include **`Authorization: Bearer …`**. + +## Playwright E2E + +**CI** (Ubuntu + Windows) and the **PyPI release** workflow run **`npm run test:e2e`** after the production **`static/`** build. One-time browser download locally: + +```bash +cd web +npm ci +npx playwright install chromium +npm run test:e2e +``` + +**`playwright.config.ts`** starts **`scripts/e2e-server.mjs`**: a fresh workspace under **`.tmp/playwright-fd-workspace/`**, then **`flightdeck serve`** on **`http://127.0.0.1:9876`**. On GitHub Actions the server uses **`uv run flightdeck …`**; locally it uses **`python -m flightdeck.cli.main`** or **`py -3`**. + +Run **`npm`** commands from this **`web/`** directory (repo root is one level up: **`cd web`**). + +## PR split (subagent-friendly) + +**Already landed:** Vite + React + TS **`web/`**, committed **`static/`**, FastAPI **`/assets`** mount, CI **`npm run build`** + **`git diff --exit-code`** on **`static/`**, Playwright smoke, LF normalization via **`.gitattributes`** (stable **`git diff`** on Windows). + +**Suggested follow-ups:** + +1. **PR B — UI behavior** + Timeline UX (tables, loading states, `/v1/actions` query filters), mutation UX (inline errors, disable buttons while pending). Touch **`web/src/`** only, then **`npm run build`** and commit **`static/`**. + + *Subagent prompt:* “Improve **`web/src/App.tsx`** (and small new components under **`web/src/`**) for timeline and promote/rollback UX only; rebuild **`static/`**; do not change Python HTTP contracts.” + +2. **PR C — Optional** + React Router, richer diff visualization, shared design tokens. (**Playwright** smoke is under **`e2e/`**; see **Playwright E2E** above.) + +**Parallel subagents for PR B** (non-overlapping files if you split components first): + +- **Agent 1 — Read path:** `TimelinePanel` (or equivalent) + styles for releases/promoted/actions. +- **Agent 2 — Write path:** `DiffPanel` + `MutationPanel` + token-aware **`fetch`** helpers. + +Rebase one branch onto the other, run **`npm run build` once**, fix any conflicts in **`static/`**, then push. diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..fd6e666 --- /dev/null +++ b/web/e2e/smoke.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("home loads FlightDeck heading and timeline", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "FlightDeck", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Timeline", level: 2 })).toBeVisible(); + const timelinePre = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Timeline", level: 2 }) }) + .locator("pre"); + await expect(timelinePre).toContainText('"releases"', { timeout: 30_000 }); +}); + +test("health endpoint", async ({ request }) => { + const res = await request.get("/health"); + expect(res.ok()).toBeTruthy(); + await expect(res.json()).resolves.toMatchObject({ status: "ok" }); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a2cac90 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + FlightDeck + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b99928a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1882 @@ +{ + "name": "flightdeck-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flightdeck-web", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "~5.9.3", + "vite": "^7.1.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.348", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz", + "integrity": "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..ba9c274 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "flightdeck-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test:e2e": "playwright test" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "~5.9.3", + "vite": "^7.1.12" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..5c481d5 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, devices } from "@playwright/test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const e2eServer = path.join(__dirname, "scripts", "e2e-server.mjs"); +const port = process.env.FD_E2E_PORT || "9876"; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + ...devices["Desktop Chrome"], + baseURL, + trace: "on-first-retry", + }, + webServer: { + command: `node "${e2eServer}"`, + cwd: __dirname, + url: `${baseURL}/health`, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/web/scripts/e2e-server.mjs b/web/scripts/e2e-server.mjs new file mode 100644 index 0000000..ea20eff --- /dev/null +++ b/web/scripts/e2e-server.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Fresh workspace + `flightdeck serve` for Playwright (cross-platform). + * CI: uses `uv run flightdeck …` when GITHUB_ACTIONS is set. + * Local: `python -m flightdeck.cli.main` or Windows `py -3 -m flightdeck.cli.main`. + */ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, "..", ".."); +const ws = path.join(root, ".tmp", "playwright-fd-workspace"); +const port = process.env.FD_E2E_PORT || "9876"; +const inCi = Boolean(process.env.GITHUB_ACTIONS); + +function run(cmd, args, opts) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit", ...opts }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} ${args.join(" ")} exited ${code} signal=${signal}`)); + }); + }); +} + +fs.rmSync(ws, { recursive: true, force: true }); +fs.mkdirSync(ws, { recursive: true }); + +if (inCi) { + await run("uv", ["run", "flightdeck", "init"], { cwd: ws }); +} else if (process.platform === "win32") { + await run("py", ["-3", "-m", "flightdeck.cli.main", "init"], { cwd: ws }); +} else { + await run("python", ["-m", "flightdeck.cli.main", "init"], { cwd: ws }); +} + +let serveArgs; +if (inCi) { + serveArgs = ["run", "flightdeck", "serve", "--host", "127.0.0.1", "--port", port]; +} else if (process.platform === "win32") { + serveArgs = ["-3", "-m", "flightdeck.cli.main", "serve", "--host", "127.0.0.1", "--port", port]; +} else { + serveArgs = ["-m", "flightdeck.cli.main", "serve", "--host", "127.0.0.1", "--port", port]; +} + +const serveCmd = inCi ? "uv" : process.platform === "win32" ? "py" : "python"; +const serve = spawn(serveCmd, serveArgs, { cwd: ws, stdio: "inherit" }); + +function forward(sig) { + try { + serve.kill(sig); + } catch { + /* ignore */ + } +} +process.on("SIGTERM", () => forward("SIGTERM")); +process.on("SIGINT", () => forward("SIGINT")); + +serve.on("exit", (code, signal) => { + if (signal) process.exit(0); + process.exit(code ?? 1); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..d953349 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useState } from "react"; + +type ReleaseRow = { + release_id: string; + agent_id: string; + version: string; + environment: string; + checksum: string; + created_at: string; +}; + +type PromotedRow = { + agent_id: string; + environment: string; + release_id: string; +}; + +type ActionRow = { + action_id: string; + action: string; + release_id: string; + agent_id: string; + environment: string; + baseline_release_id: string | null; + reason: string; + policy_passed: boolean; + policy_reasons: string[]; + created_at: string; + audit_seq: number | null; +}; + +async function fetchJson(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + const token = import.meta.env.VITE_FLIGHTDECK_LOCAL_API_TOKEN; + if (typeof token === "string" && token.trim().length > 0 && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token.trim()}`); + } + const res = await fetch(path, { ...init, headers }); + const data: unknown = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = + typeof data === "object" && data !== null && "detail" in data + ? String((data as { detail: unknown }).detail) + : JSON.stringify(data); + throw new Error(detail || `HTTP ${res.status}`); + } + return data as T; +} + +export function App() { + const [timelineText, setTimelineText] = useState("Loading…"); + const [diffBaseline, setDiffBaseline] = useState(""); + const [diffCandidate, setDiffCandidate] = useState(""); + const [diffWindow, setDiffWindow] = useState("7d"); + const [diffEnv, setDiffEnv] = useState("local"); + const [diffOut, setDiffOut] = useState(""); + + const [actRelease, setActRelease] = useState(""); + const [actEnv, setActEnv] = useState("local"); + const [actWindow, setActWindow] = useState("7d"); + const [actReason, setActReason] = useState(""); + const [actOut, setActOut] = useState(""); + + const refreshTimeline = useCallback(async () => { + try { + const [releases, promoted, actions] = await Promise.all([ + fetchJson<{ releases: ReleaseRow[] }>("/v1/releases"), + fetchJson<{ promoted: PromotedRow[] }>("/v1/promoted"), + fetchJson<{ actions: ActionRow[] }>("/v1/actions"), + ]); + setTimelineText( + JSON.stringify( + { + releases: releases.releases, + promoted: promoted.promoted, + actions: actions.actions, + }, + null, + 2, + ), + ); + } catch (e) { + setTimelineText(String(e)); + } + }, []); + + useEffect(() => { + void refreshTimeline(); + }, [refreshTimeline]); + + const runDiff = async () => { + setDiffOut(""); + try { + const body = { + baseline_release_id: diffBaseline.trim(), + candidate_release_id: diffCandidate.trim(), + window: diffWindow.trim(), + environment: diffEnv.trim() || null, + }; + const data = await fetchJson("/v1/diff", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setDiffOut(JSON.stringify(data, null, 2)); + } catch (e) { + setDiffOut(String(e)); + } + }; + + const runAction = async (path: "/v1/promote" | "/v1/rollback") => { + setActOut(""); + const reason = actReason.trim(); + if (!reason) { + setActOut("Reason is required."); + return; + } + const label = path === "/v1/promote" ? "promote" : "rollback"; + if (!window.confirm(`Confirm ${label} for this release?`)) { + return; + } + try { + const data = await fetchJson(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + release_id: actRelease.trim(), + environment: actEnv.trim(), + window: actWindow.trim(), + reason, + actor: "react-ui", + }), + }); + setActOut(JSON.stringify(data, null, 2)); + await refreshTimeline(); + } catch (e) { + setActOut(String(e)); + } + }; + + return ( + <> +

FlightDeck

+

Local timeline, diff, and promotion actions (React + Vite).

+ +
+

Timeline

+ +
{timelineText}
+
+ +
+

Run diff

+
+ + setDiffBaseline(e.target.value)} /> +
+
+ + setDiffCandidate(e.target.value)} /> +
+
+ + setDiffWindow(e.target.value)} /> +
+
+ + setDiffEnv(e.target.value)} /> +
+ +
{diffOut}
+
+ +
+

Promote / rollback

+
+ + setActRelease(e.target.value)} /> +
+
+ + setActEnv(e.target.value)} /> +
+
+ + setActWindow(e.target.value)} /> +
+
+ + setActReason(e.target.value)} /> +
+ + +
{actOut}
+
+ + ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..590eb1a --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,62 @@ +:root { + font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + line-height: 1.45; + color: #111; + background: #fafafa; +} + +body { + margin: 0; + padding: 1.25rem 1.5rem 2rem; +} + +h1 { + margin: 0 0 0.25rem; + font-size: 1.35rem; +} + +h2 { + margin: 1.35rem 0 0.45rem; + font-size: 1.05rem; +} + +section { + border: 1px solid #ddd; + border-radius: 8px; + padding: 0.85rem 1rem; + margin-bottom: 0.85rem; + background: #fff; +} + +label { + display: inline-block; + min-width: 9.5rem; + margin-bottom: 0.35rem; + font-size: 0.9rem; +} + +input { + margin-bottom: 0.45rem; + min-width: 16rem; + padding: 0.25rem 0.4rem; +} + +button { + margin: 0.35rem 0.5rem 0.35rem 0; + padding: 0.35rem 0.65rem; +} + +pre { + white-space: pre-wrap; + background: #f6f6f6; + border: 1px solid #eee; + padding: 0.65rem; + border-radius: 6px; + font-size: 0.82rem; +} + +.muted { + color: #555; + font-size: 0.9rem; + margin-bottom: 0.75rem; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3f1658e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; + +const el = document.getElementById("root"); +if (!el) { + throw new Error("Missing #root"); +} + +createRoot(el).render( + + + , +); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..3d6ecda --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** When set, sent as `Authorization: Bearer …` on API calls (matches server `FLIGHTDECK_LOCAL_API_TOKEN`). */ + readonly VITE_FLIGHTDECK_LOCAL_API_TOKEN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f7adb80 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..59c03a4 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..fe2fd90 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,34 @@ +import path from "node:path"; +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +const staticOut = path.resolve(__dirname, "../src/flightdeck/server/static"); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + const proxyTarget = env.VITE_DEV_PROXY_TARGET || "http://127.0.0.1:8765"; + + return { + plugins: [react()], + base: "/", + server: { + port: 5173, + proxy: { + "/v1": { target: proxyTarget, changeOrigin: true }, + "/health": { target: proxyTarget, changeOrigin: true }, + }, + }, + build: { + outDir: staticOut, + emptyOutDir: true, + assetsDir: "assets", + rollupOptions: { + output: { + assetFileNames: "assets/[name]-[hash][extname]", + chunkFileNames: "assets/[name]-[hash].js", + entryFileNames: "assets/[name]-[hash].js", + }, + }, + }, + }; +}); From bfe1e71face810abb615b0d6b210507eeb48dd42 Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 19:00:31 -0700 Subject: [PATCH 2/4] docs: fix PR review nits (canonical links, README dedupe, verify bar) Co-authored-by: Cursor --- CHANGELOG.md | 21 +++++++++++---------- README.md | 5 +++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e85a2..a067c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Changed +- **Docs:** README deduplicates **RELEASE_NOTES** links; **CHANGELOG** historic bullets use canonical **`https://github.com/flightdeckdev/flightdeck/...`** URLs for **`docs/*`** and **RESEARCH.md** (this slim tree does not ship those files). - **`tests/conftest.py`:** create repo **`.tmp/`** at import time so **`pytest --basetemp=.tmp/pytest`** works on fresh checkouts and **Linux** CI (parent dir is no longer Windows-only). - **`pyproject.toml` `[project] name`:** **`flightdeck-ai`** to match the **PyPI** trusted-publisher project; install with **`pip install flightdeck-ai`** / **`uv add flightdeck-ai`** (CLI remains **`flightdeck`**, imports **`flightdeck.*`**). - **Contributor docs** (**`README.md`**, **`DEVELOPMENT.md`**, **`CONTRIBUTING.md`**, **`AGENTS.md`**, **`CLAUDE.md`**, **`.cursorrules`**): prefer **uv**; keep **pip** / **`python -m venv`** as fallback. @@ -76,16 +77,16 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Added -- **[docs/cli.md](docs/cli.md)**: CLI reference (synopsis, flags, exit codes, pointers to quickstart examples). +- **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)**: CLI reference (synopsis, flags, exit codes, pointers to quickstart examples). - **`scripts/quickstart_smoke.py`**: cross-platform quickstart smoke (**no bash**): temp workspace, Python placeholder substitution, **`release verify`**, **`doctor`**. - **CI:** run quickstart smoke on **Ubuntu** and **Windows** matrix jobs (alongside pytest and schema drift). - **Tests:** `tests/test_quickstart_smoke.py` exercises the smoke script. -- **0.8 milestone planning** (CLI + CI): archived under **`docs/reviews/`** in development clones; shipped artifacts are **`docs/cli.md`** and **`scripts/quickstart_smoke.py`** above. +- **0.8 milestone planning** (CLI + CI): archived under **`docs/reviews/`** in development clones; shipped CLI narrative is **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** on the canonical repo; this tree ships **`scripts/quickstart_smoke.py`** / **`flightdeck-quickstart-verify`** (see **Unreleased**). ### Changed -- **[docs/quickstart.md](docs/quickstart.md)**: recommend **`python scripts/quickstart_smoke.py`** on Windows; bash flow kept as optional. -- **[docs/architecture.md](docs/architecture.md)**: deferred section updated for shipped SDK / rollback / serve / doctor / verify. +- **[docs/quickstart.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/quickstart.md)**: recommend **`python scripts/quickstart_smoke.py`** on Windows; bash flow kept as optional. +- **[docs/architecture.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/architecture.md)**: deferred section updated for shipped SDK / rollback / serve / doctor / verify. - **`scripts/verify-repo-standards.sh`** / **`.ps1`**: run **`quickstart_smoke.py`** after pytest (same bar as CI). ## 0.7.0 - 2026-04-30 @@ -113,7 +114,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Changed -- **README** / **[docs/github-organization.md](docs/github-organization.md)** / **[docs/git-remotes.md](docs/git-remotes.md):** point at **`https://github.com/flightdeckdev/flightdeck`**. +- **README** / **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** / **[docs/git-remotes.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md):** point at **`https://github.com/flightdeckdev/flightdeck`**. ## 0.5.1 - 2026-04-30 @@ -126,7 +127,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Added -- **[docs/v1-next-steps.md](docs/v1-next-steps.md)**: Maintainer v1 gap analysis (P0/P1/P2), milestone sequencing (0.5–1.0), and risk callouts; tracks implementation status as work lands. +- **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**: Maintainer v1 gap analysis (P0/P1/P2), milestone sequencing (0.5–1.0), and risk callouts; tracks implementation status as work lands. - **`flightdeck serve`**: warns when `--host` is not loopback (trust boundary; see forward spec §4). - **`POST /v1/events`**: rejects unsupported `api_version` before Pydantic with a clear 400 detail. - **Ledger tests** (`tests/test_ledger.py`): `diff_releases` rejects cross-agent and mixed-agent run batches. @@ -142,17 +143,17 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Added -- **[docs/git-remotes.md](docs/git-remotes.md)**: configure **`origin`** (personal research) vs **`org`** ([flightdeckdev](https://github.com/flightdeckdev) canonical), with everyday `git push` examples. +- **[docs/git-remotes.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md)**: configure **`origin`** (personal research) vs **`org`** ([flightdeckdev](https://github.com/flightdeckdev) canonical), with everyday `git push` examples. ### Changed -- **Research workflow docs** ([docs/research-workflow.md](docs/research-workflow.md), [RESEARCH.md](RESEARCH.md), [AGENTS.md](AGENTS.md), [.cursorrules](.cursorrules), [docs/github-organization.md](docs/github-organization.md)) now state explicitly: **personal account** = research clone; **org** = user-facing canonical. +- **Research workflow docs** ([docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md), [RESEARCH.md](https://github.com/flightdeckdev/flightdeck/blob/main/RESEARCH.md), [AGENTS.md](AGENTS.md), [.cursorrules](.cursorrules), [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)) now state explicitly: **personal account** = research clone; **org** = user-facing canonical. ## 0.4.1 - 2026-04-30 ### Added -- **[docs/github-organization.md](docs/github-organization.md)** for the **[flightdeckdev](https://github.com/flightdeckdev)** org: when to add repos, pre-push checklist, private-file policy. +- **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** for the **[flightdeckdev](https://github.com/flightdeckdev)** org: when to add repos, pre-push checklist, private-file policy. - **`scripts/verify-repo-standards.sh`** / **`.ps1`**: run ruff + pytest before pushing. ### Changed @@ -169,7 +170,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Added -- **Forward v1 specification** (`docs/spec-v1-forward.md`): normative direction for v1 GA (versioning, migrations, bundle checksum canonicalization, trust boundaries, diff/policy defaults, SDK testing discipline). `docs/spec.md` remains the frozen 0.x implementation narrative; new guarantees land here first. +- **Forward v1 specification** ([docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)): normative direction for v1 GA (versioning, migrations, bundle checksum canonicalization, trust boundaries, diff/policy defaults, SDK testing discipline). [`docs/spec.md`](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md) remains the frozen 0.x implementation narrative; new guarantees land here first. - **SDK unit test** using `httpx.MockTransport` so the Python client is covered without relying on sync ASGI transport quirks. ### Changed diff --git a/README.md b/README.md index 08383dd..5cfaec3 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ FlightDeck is **local-first** and ships as a Python CLI backed by SQLite. **v1.0.0** establishes **SemVer-stable public contracts** for the documented CLI (**[README.md](https://github.com/flightdeckdev/flightdeck/blob/main/README.md)** on `main`), committed **`schemas/v1/`**, and **`POST /v1/events`** with **`api_version` `v1`**. See -**[RELEASE_NOTES.md](RELEASE_NOTES.md)** and -**[RELEASE_NOTES.md](https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md)**. +**[RELEASE_NOTES.md](RELEASE_NOTES.md)** (same narrative on +**[`main`](https://github.com/flightdeckdev/flightdeck/blob/main/RELEASE_NOTES.md)**). The product scope is still intentionally narrow (release governance, not a hosted agent platform). Not implemented yet: @@ -126,6 +126,7 @@ This clone keeps docs lightweight. Core references: uv sync --frozen --extra dev uv run python -m ruff check src tests uv run python -m pytest +uv run flightdeck-quickstart-verify ``` See [DEVELOPMENT.md](DEVELOPMENT.md) for **uv** and **pip** setup, verification, troubleshooting, and **PyPI releases** (tag-driven; not on merge to `main`). From a14083385aa98340007640b934061919d1b24a51 Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 19:02:56 -0700 Subject: [PATCH 3/4] build(web): normalize static output to LF after Vite on Windows Co-authored-by: Cursor --- .gitattributes | 3 +++ web/README.md | 2 +- web/package.json | 2 +- web/scripts/normalize-static-lf.mjs | 29 +++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 web/scripts/normalize-static-lf.mjs diff --git a/.gitattributes b/.gitattributes index 9d31dd4..180004b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ tests/fixtures/golden_bundle/** text eol=lf # Vite build output must stay LF so `git diff --exit-code` matches on Windows CI/workstations. src/flightdeck/server/static/** text eol=lf + +# Post-build Node scripts committed under web/ (avoid CRLF churn on Windows checkouts). +web/scripts/** text eol=lf diff --git a/web/README.md b/web/README.md index b50b3cb..b2dbeb1 100644 --- a/web/README.md +++ b/web/README.md @@ -10,7 +10,7 @@ npm ci npm run build ``` -After any change under **`web/src/`**, run **`npm run build`** again and commit the updated **`src/flightdeck/server/static/`** tree. **CI** rebuilds and runs **`git diff --exit-code`** on that path so committed assets cannot drift. +After any change under **`web/src/`**, run **`npm run build`** again and commit the updated **`src/flightdeck/server/static/`** tree. The build runs **`scripts/normalize-static-lf.mjs`** after Vite so emitted HTML/JS/CSS use **LF** on Windows (avoids CRLF-only noise against **`.gitattributes`**). **CI** rebuilds and runs **`git diff --exit-code`** on that path so committed assets cannot drift. ## Local development (`npm run dev`) diff --git a/web/package.json b/web/package.json index ba9c274..5696540 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/normalize-static-lf.mjs", "preview": "vite preview", "test:e2e": "playwright test" }, diff --git a/web/scripts/normalize-static-lf.mjs b/web/scripts/normalize-static-lf.mjs new file mode 100644 index 0000000..0d74725 --- /dev/null +++ b/web/scripts/normalize-static-lf.mjs @@ -0,0 +1,29 @@ +/** + * Force LF line endings under src/flightdeck/server/static/ after Vite build. + * Avoids CRLF-only diffs on Windows (CI uses git diff --exit-code on static/). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const staticDir = path.resolve(__dirname, "..", "..", "src", "flightdeck", "server", "static"); + +function normalizeFile(filePath) { + const buf = fs.readFileSync(filePath); + if (buf.includes(0)) return; + const s = buf.toString("utf8"); + const n = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (n !== s) fs.writeFileSync(filePath, n, "utf8"); +} + +function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p); + else normalizeFile(p); + } +} + +walk(staticDir); From ef8617992c729bbbb60d301f23202ff9c7fa7aaa Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 19:07:38 -0700 Subject: [PATCH 4/4] fix(web): canonicalize index.html body spacing after Vite build --- web/scripts/normalize-static-lf.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/scripts/normalize-static-lf.mjs b/web/scripts/normalize-static-lf.mjs index 0d74725..0cbabd8 100644 --- a/web/scripts/normalize-static-lf.mjs +++ b/web/scripts/normalize-static-lf.mjs @@ -1,6 +1,8 @@ /** - * Force LF line endings under src/flightdeck/server/static/ after Vite build. - * Avoids CRLF-only diffs on Windows (CI uses git diff --exit-code on static/). + * Normalize built assets under src/flightdeck/server/static/ after Vite build. + * - CRLF -> LF (Windows vs CI) + * - index.html: collapse extra blank lines before (Vite 7.3+ adds one; + * CI uses git diff --exit-code on static/) */ import fs from "node:fs"; import path from "node:path"; @@ -13,8 +15,11 @@ function normalizeFile(filePath) { const buf = fs.readFileSync(filePath); if (buf.includes(0)) return; const s = buf.toString("utf8"); - const n = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (n !== s) fs.writeFileSync(filePath, n, "utf8"); + let out = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (path.basename(filePath) === "index.html") { + out = out.replace(/(
<\/div>)\n\n+(\s*<\/body>)/, "$1\n$2"); + } + if (out !== s) fs.writeFileSync(filePath, out, "utf8"); } function walk(dir) {