From b0506021696fc020c2f50ab01edaa795fa03af11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 02:14:57 +0000 Subject: [PATCH 1/2] fix(server): return conflict for policy-blocked actions Co-authored-by: Gottam Sai Bharath --- src/flightdeck/server/routes/actions.py | 53 +++++++++++++++---------- tests/test_sdk_client.py | 31 +++++++++++++++ tests/test_server_actions.py | 34 ++++++++++++++++ 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/flightdeck/server/routes/actions.py b/src/flightdeck/server/routes/actions.py index ad807a3..bc3123e 100644 --- a/src/flightdeck/server/routes/actions.py +++ b/src/flightdeck/server/routes/actions.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from flightdeck.operations import OperationError, compute_diff, promote_release, rollback_release +from flightdeck.operations import ActionOutcome, OperationError, compute_diff, promote_release, rollback_release from flightdeck.server.routes.common import ensure_app_state router = APIRouter() @@ -32,6 +32,29 @@ def _raise_bad_request(exc: OperationError) -> HTTPException: return HTTPException(status_code=400, detail=str(exc)) +def _action_body(outcome: ActionOutcome) -> dict[str, object]: + 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"), + } + + +def _raise_policy_blocked(action: str, outcome: ActionOutcome) -> HTTPException: + return HTTPException( + status_code=409, + detail={ + "message": f"{action.capitalize()} blocked by policy.", + "outcome": _action_body(outcome), + }, + ) + + def _require_mutation_access(request: Request) -> None: ensure_app_state(request) expected_token: str | None = request.app.state.local_api_token @@ -123,16 +146,10 @@ def post_promote(request: Request, req: ActionRequest) -> dict[str, object]: 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"), - } + if not outcome.policy.passed: + raise _raise_policy_blocked("promotion", outcome) + + return _action_body(outcome) @router.post("/v1/rollback") @@ -152,13 +169,7 @@ def post_rollback(request: Request, req: ActionRequest) -> dict[str, object]: 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"), - } + if not outcome.policy.passed: + raise _raise_policy_blocked("rollback", outcome) + + return _action_body(outcome) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 2a0a5f8..f9045f3 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -101,6 +101,37 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.list_releases() == {"releases": []} +def test_flightdeck_client_promote_raises_on_policy_block() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/v1/promote" + return httpx.Response( + 409, + json={ + "detail": { + "message": "Promotion blocked by policy.", + "outcome": { + "promoted_pointer_changed": False, + "policy": {"passed": False, "reasons": ["candidate cost exceeds max"]}, + }, + } + }, + ) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http) + with pytest.raises(httpx.HTTPStatusError) as excinfo: + client.post_promote( + release_id="rel_blocked", + environment="local", + window="7d", + reason="policy check", + ) + + assert excinfo.value.response.status_code == 409 + + def test_flightdeck_client_ingest_batch_chunks_payload() -> None: seen_lengths: list[int] = [] diff --git a/tests/test_server_actions.py b/tests/test_server_actions.py index 87c74ba..25e9a17 100644 --- a/tests/test_server_actions.py +++ b/tests/test_server_actions.py @@ -160,6 +160,40 @@ def test_http_promote_requires_reason(tmp_path: Path) -> None: assert res.status_code == 422 +def test_http_promote_policy_block_returns_conflict_and_keeps_audit(tmp_path: Path) -> None: + ws = tmp_path / "ws_policy_block" + runner, baseline_id, candidate_id = _seed_workspace(ws) + with _cwd(ws): + policy = write_policy(ws, max_cost_per_run_usd=0.0001) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + + with TestClient(create_app()) as client: + res = client.post( + "/v1/promote", + json={ + "release_id": candidate_id, + "environment": "local", + "window": "7d", + "reason": "too expensive", + "actor": "http-test", + }, + ) + + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["message"] == "Promotion blocked by policy." + outcome = detail["outcome"] + assert outcome["promoted_pointer_changed"] is False + assert outcome["policy"]["passed"] is False + + storage = Storage(load_config().db_path) + storage.migrate() + assert storage.get_promoted_release_id("agent_support", "local") == baseline_id + last = storage.list_release_actions(agent_id="agent_support", environment="local")[0] + assert last.release_id == candidate_id + assert last.policy_result.passed is False + + def test_ui_root_serves_vite_index(tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) assert CliRunner().invoke(cli, ["init"]).exit_code == 0 From 6837eb68f57e7d8ebe3e60b9b66f5e6d9de56de1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 11:07:03 +0000 Subject: [PATCH 2/2] docs: add web UI guide and update contributor docs for shipped UI - Add docs/web-ui.md: user-facing reference for the three-page browser UI shipped in PR #17 (Overview, Diff, Promote/Rollback). Covers navigation, per-page usage, authentication tiers, dev mode, and a common-issues table. - Add docs/web-ui.md to README.md documentation index. - Replace stale 'PR split (subagent-friendly)' planning section in web/README.md with an Architecture table that reflects the current file structure (AppShell, pages/, context/, api.ts, components/). Co-authored-by: Gottam Sai Bharath --- README.md | 1 + docs/web-ui.md | 178 +++++++++++++++++++++++++++++++++++++++++++++++++ web/README.md | 39 ++++++----- 3 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 docs/web-ui.md diff --git a/README.md b/README.md index 3c1cec9..c972fab 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Substitute them before ingestion, or run **`uv run flightdeck-quickstart-verify` - [CLI reference](docs/cli.md) — all commands, flags, arguments, and exit codes - [HTTP API reference](docs/http-api.md) — all `/v1/*` routes, request/response shapes, auth, `RunEvent` field reference +- [Web UI](docs/web-ui.md) — browser UI served by `flightdeck serve`: Overview, Diff, and Promote pages; auth; dev mode - [Python SDK](docs/sdk.md) — `FlightdeckClient` / `AsyncFlightdeckClient` usage guide - [Operations and policy](docs/operations-and-policy.md) — diff, promote, rollback internals; policy model and confidence tiers - [Release artifacts and pricing](docs/release-artifact.md) — `release.yaml` format, bundle layout, checksum algorithm, workspace config, pricing tables diff --git a/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 0000000..9d23e8c --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,178 @@ +# FlightDeck Web UI + +`flightdeck serve` ships a local browser UI at `http://127.0.0.1:8765/` (same host and +port as the HTTP API). The UI is a single-page React application served as static files +under `src/flightdeck/server/static/` — no separate Node process is needed at runtime. + +## Starting the UI + +```bash +flightdeck serve # http://127.0.0.1:8765 +flightdeck serve --port 9000 # custom port +flightdeck serve --host 0.0.0.0 # non-loopback (prints warning; see SECURITY.md) +``` + +The server requires a `flightdeck.yaml` in the working directory. Run `flightdeck init` +if it does not exist. + +Open `http://127.0.0.1:8765` in your browser after starting the server. + +--- + +## Navigation + +The shell has a persistent header with three nav links: + +| Link | Hash route | Page | +|------|-----------|------| +| **Overview** | `/#/` | Registered releases, current promotion pointers, recent audit actions | +| **Diff** | `/#/diff` | Interactive release diff (same contract as `flightdeck release diff`) | +| **Promote** | `/#/actions` | Promote or rollback a release | + +Navigation uses a hash router so deep-linking works without server-side routing +configuration (`/#/diff`, `/#/actions`). + +--- + +## Overview page + +Shows three tables loaded from the API on every page visit: + +- **Releases** — all releases registered in this workspace (`GET /v1/releases`). + Displays `release_id`, agent, version, environment, checksum, and creation time. + Long IDs are truncated with a `title` tooltip showing the full value. + +- **Promoted** — the currently promoted release for each agent/environment pair + (`GET /v1/promoted`). + +- **Recent actions** — promotion and rollback attempts from the audit ledger + (`GET /v1/actions`, default limit 50). Each row shows when the action occurred, + whether it was a promote or rollback, a `PASS`/`FAIL` policy badge, the target + release, environment, and the reason string. + +A **Refresh** button re-fetches all three tables. The Overview also refreshes +automatically after a successful promote or rollback on the Promote page. + +--- + +## Diff page (`/#/diff`) + +An interactive form that calls `POST /v1/diff` to compare two releases over a time +window. Inputs: + +| Field | Description | +|-------|-------------| +| **Baseline release ID** | The `release_id` of the baseline (older) release | +| **Candidate release ID** | The `release_id` of the release under evaluation | +| **Window** | Time window string: `7d`, `24h`, `30m`, etc. | +| **Environment** | Environment name (default `local`; must match ingested events) | + +After clicking **Compute diff**, the page shows: + +- **Policy badge** (`PASS`/`FAIL`) and, on failure, the list of constraint reasons. +- **Sample counts** — baseline runs, candidate runs, confidence label (`HIGH`, + `MEDIUM`, or `LOW`), and an optional reason when confidence is degraded. +- **Metric cards** for cost per run (USD), average latency (ms), and error rate. + Each card shows baseline (`B`) and candidate (`C`) values plus the delta. +- **Raw diff JSON** in a collapsible panel for debugging. + +`POST /v1/diff` is a read-only computation — it does not change promoted pointers or +write to the audit ledger. It does not require the mutation token. + +For diff semantics, confidence tiers, and policy evaluation see +[operations-and-policy.md](operations-and-policy.md). + +--- + +## Promote & rollback page (`/#/actions`) + +Sends `POST /v1/promote` or `POST /v1/rollback` after confirming a dialog. Inputs: + +| Field | Description | +|-------|-------------| +| **Release ID** | The `release_id` to promote or roll back to | +| **Environment** | Environment name (default `local`) | +| **Window** | Time window for evidence sampling (default `7d`) | +| **Reason** (required) | Non-empty reason string recorded in the audit ledger | + +Both **Promote** and **Rollback** buttons are disabled while a request is in flight. +The response JSON is shown inline after completion. If the operation is blocked by +policy (HTTP 409), the error message includes the policy constraint reasons. + +After a successful mutation, the Overview page refreshes its tables automatically via +`TimelineRefreshContext`. + +> **First promotion:** when no prior release has been promoted for a given +> agent/environment, the first promotion always succeeds — policy evaluation is skipped +> and the release is promoted unconditionally. + +--- + +## Authentication in the UI + +The server has two access tiers (see [http-api.md](http-api.md) for the full table): + +- **Read-only routes** (`GET /v1/*`, `GET /health`, `POST /v1/diff`) — always open on + loopback; no token required. +- **Mutation routes** (`POST /v1/promote`, `POST /v1/rollback`) — loopback-only by + default. When `FLIGHTDECK_LOCAL_API_TOKEN` is set on the server, callers must send + `Authorization: Bearer `. + +**When using the static UI served by `flightdeck serve`**, mutations always originate +from `localhost` so they work without a token in the default setup. + +If you configure a token (recommended when binding to a non-loopback address), the +static UI cannot inject the token automatically — use the CLI or SDK for mutations in +that configuration, or run the Vite dev server with `VITE_FLIGHTDECK_LOCAL_API_TOKEN` +(see [Development](#development-mode) below). + +--- + +## Development mode + +To iterate on the UI source under `web/src/`: + +1. Start the API in one terminal from a workspace with `flightdeck.yaml`: + + ```bash + flightdeck serve # default 127.0.0.1:8765 + ``` + +2. In another terminal, run the Vite dev server: + + ```bash + cd web + cp .env.example .env.local # optional: set VITE_FLIGHTDECK_LOCAL_API_TOKEN + npm ci + npm run dev # http://localhost:5173 + ``` + +Vite proxies `/v1/*` and `/health` to `http://127.0.0.1:8765` so hot-reload works +against a live FlightDeck server. + +After making changes, rebuild the committed static bundle: + +```bash +cd web +npm run build +cd .. +git diff --exit-code src/flightdeck/server/static/ +``` + +Commit every file under `src/flightdeck/server/static/` (including the hashed +`assets/*.js` and updated `index.html`). CI enforces this check. + +See [web/README.md](../web/README.md) for Playwright E2E test instructions. + +--- + +## Common issues + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| **"Loading…" never resolves** | `flightdeck serve` is not running or is on a different port | Start the server; verify `GET http://127.0.0.1:8765/health` returns `{"status": "ok"}` | +| **Tables show empty state** | No releases registered yet | Run `flightdeck release register ` and refresh | +| **"Reason is required"** on Promote page | The Reason field is empty | Enter a non-empty reason before clicking Promote or Rollback | +| **HTTP 409 on promote/rollback** | Active policy blocked the action | Check the `policy.reasons` in the error message; adjust the policy or address the constraint | +| **HTTP 401/403 on promote/rollback** | Token mismatch or missing `Authorization` header | Use the CLI or SDK with the correct token, or start the server without `FLIGHTDECK_LOCAL_API_TOKEN` for local dev | +| **Stale data after promote** | Rare race between mutation and auto-refresh | Click **Refresh** on the Overview page manually | diff --git a/web/README.md b/web/README.md index cc3508b..bae2ba5 100644 --- a/web/README.md +++ b/web/README.md @@ -50,23 +50,22 @@ npm run test:e2e 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. +## Architecture + +The source is organized under **`web/src/`**: + +| Path | Purpose | +|------|---------| +| `main.tsx` | React entry point; mounts `` into `#root` | +| `App.tsx` | `HashRouter` with routes for `OverviewPage`, `DiffPage`, and `ActionsPage` inside `AppShell` | +| `api.ts` | Typed fetch helpers (`fetchJson`, `loadTimeline`) and row types (`ReleaseRow`, `ActionRow`, …) | +| `components/AppShell.tsx` | Persistent header with primary nav links; wraps `TimelineRefreshProvider` | +| `components/Badge.tsx` | Inline `PASS`/`FAIL` badge with tone variants | +| `components/JsonPanel.tsx` | Collapsible raw JSON panel used on Diff and Actions pages | +| `context/TimelineRefreshContext.tsx` | Shared `generation` counter; incremented after successful mutations so `OverviewPage` re-fetches automatically | +| `pages/OverviewPage.tsx` | Releases, promoted pointers, and recent actions tables | +| `pages/DiffPage.tsx` | Interactive diff form; renders metric cards and policy result | +| `pages/ActionsPage.tsx` | Promote and rollback form; calls `notifyTimelineMutated()` on success | +| `index.css` | All scoped `fd-*` CSS classes (no external CSS framework) | + +For user-facing UI documentation (what each page does, authentication, common issues) see **[docs/web-ui.md](../docs/web-ui.md)**.