From c8212b600eea2fd668f2b6aef8fbc647cb02103a Mon Sep 17 00:00:00 2001 From: Afnan Date: Sun, 20 Sep 2026 03:09:52 +0530 Subject: [PATCH 1/2] Phase 2: separate API from frontend, central API client, API contract docs - FastAPI no longer mounts web/dist; serves only /api, /docs, /openapi.json - web/src/api.js: single client, stable ApiError shape, no auto-retry, honest timeout/abort messages; console migrated to it - Console renders EVALUATION_ERROR as a technical failure - Vite proxies /api, /docs, /openapi.json to FastAPI (strictPort 5173) - start.ps1 / start-frontend.ps1, README and beginner test updated - docs/api_contract.md and docs/phase3_migration_map.md - Tests: API independence, JSON 404, Vite origin; 22 vitest client tests Co-Authored-By: Claude Sonnet 5 --- BEGINNER_HTTP_TEST.md | 6 +- README.md | 57 ++++- docs/api_contract.md | 123 ++++++++++ docs/phase3_migration_map.md | 85 +++++++ engine/api.py | 6 +- requirements.txt | 2 +- start-frontend.ps1 | 5 + start.ps1 | 5 +- tests/test_api.py | 34 +++ web/package-lock.json | 442 ++++++++++++++++++++++++++++++++++- web/package.json | 6 +- web/src/api.js | 214 +++++++++++++++++ web/src/api.test.js | 159 +++++++++++++ web/src/main.jsx | 60 ++--- web/src/styles.css | 9 + web/vite.config.js | 13 +- 16 files changed, 1180 insertions(+), 46 deletions(-) create mode 100644 docs/api_contract.md create mode 100644 docs/phase3_migration_map.md create mode 100644 start-frontend.ps1 create mode 100644 web/src/api.js create mode 100644 web/src/api.test.js diff --git a/BEGINNER_HTTP_TEST.md b/BEGINNER_HTTP_TEST.md index c9f6cce..8adc1ef 100644 --- a/BEGINNER_HTTP_TEST.md +++ b/BEGINNER_HTTP_TEST.md @@ -7,14 +7,14 @@ This is the easiest way to check that PlanReview works. You are not applying any In PowerShell, from the `PlanReview` folder, start the server: ```powershell -.\.venv\Scripts\python.exe -m uvicorn engine.api:app --host 127.0.0.1 --port 8000 +.\start.ps1 ``` -Wait for `Uvicorn running on http://127.0.0.1:8000`. +Wait for `Uvicorn running on http://127.0.0.1:8000`. In a second PowerShell window, run `.\start-frontend.ps1` and wait for the Vite `Local:` line. ## In the browser -Open `http://127.0.0.1:8000`. +Open `http://127.0.0.1:5173` (the API alone is on port 8000 and does not serve the console). 1. On **Create task**, leave **Replay** selected. Click **Create task**. - Expected: you see **Confirm contract**. diff --git a/README.md b/README.md index 62220d9..1b4ab2f 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,69 @@ A local security checkpoint for Terraform changes. Confirm an immutable contract **Working locally:** real Terraform fixtures, deterministic and Cedar evaluation, FastAPI, SQLite audit, React/Tailwind console, local Ollama/Strands Terraform editing, blocked AWS apply proof, and successful local-resource apply proof. Docker-based emulator apply remains unavailable. See [STATUS.md](STATUS.md). -## Start on Windows +## Architecture (Phase 2) -Requirements: Python 3.11+, Node 20+, Terraform on PATH. Fixture planning uses dummy AWS credentials and performs no AWS refresh or apply. +```text +Browser -> React/Vite (127.0.0.1:5173) -> relative /api -> FastAPI (127.0.0.1:8000) + -> Strands/Ollama, Terraform, Cedar, SQLite +``` + +FastAPI serves **only** the API (`/api/*`, `/docs`, `/openapi.json`); it does not host the frontend and starts with or without `web/dist`. Vite serves the UI and proxies `/api`, `/docs` and `/openapi.json` to FastAPI. All frontend HTTP goes through `web/src/api.js`. See [API contract](docs/api_contract.md) and the [Phase 3 migration map](docs/phase3_migration_map.md). The PlanBound visual site in `design/` is a standalone prototype with sample data; it is not connected to the backend. + +## Start on Windows (development) + +Requirements: Python 3.11+ (3.12/3.13 tested), Node 20+, Terraform on PATH. Fixture planning uses dummy AWS credentials and performs no AWS refresh or apply. No AWS credentials are needed. + +One-time setup, from the repository root: + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +.\.venv\Scripts\python.exe terraform\scripts\generate.py # creates local Terraform state/plans for fixtures (needs network for the AWS provider on first run) +cd web; npm ci; cd .. +``` + +`generate.py` rewrites the tracked fixture files under `terraform/fixtures/` and `tests/fixtures/`; review `git status` afterwards and do not commit incidental changes. + +Run each service in its own terminal: ```powershell +.\start.ps1 # FastAPI -> http://127.0.0.1:8000 (docs: /docs) +.\start-frontend.ps1 # Vite -> http://127.0.0.1:5173 +``` +There is no single-command launcher. Open . Check it works: + +```powershell +curl.exe http://127.0.0.1:8000/api/health # direct +curl.exe http://127.0.0.1:5173/api/health # through the Vite proxy ``` -Open [the console](http://localhost:8000) or [the API documentation](http://localhost:8000/docs). The server listens only on loopback. Run the frontend build before starting the backend so it can mount the compiled console. +Both return `{"status":"ok","evaluator":"cedar","cloud_apply":false}`. The **API reference** link in the console opens `/docs` via the proxy. Stop each service with Ctrl+C in its terminal. + +Production build of the UI (does not need FastAPI running): `cd web; npm run build`. The build output is not served by FastAPI. + +Common failures: + +| Symptom | Fix | +|---|---| +| Console shows `BACKEND_UNAVAILABLE` / "backend is not reachable" | Start `.\start.ps1`; check port 8000 is free. | +| `Missing .venv` | Run the one-time setup above. | +| `409 Pipeline incomplete: run fixture generator first` | Run `terraform\scripts\generate.py`. | +| `MODEL_UNAVAILABLE` in Live mode | Start Ollama and `ollama pull llama3.2:3b`. The backend never falls back to replay silently. | +| Vite: port 5173 in use | Stop the other process (Vite is set to `strictPort`, so the Origin allow-list stays valid). | +| `403 Cross-origin mutation blocked` | Open the UI on `127.0.0.1:5173` or `localhost:5173`; other origins are refused by design. | +| `Invalid host header` | Only `localhost` / `127.0.0.1` Host headers are accepted. | + +Set `PLANREVIEW_API_URL` to point Vite at a different local backend URL (development only). -On Linux/macOS use `.venv/bin/python`, `npm`, and `python -m uvicorn engine.api:app --host 127.0.0.1 --port 8000`. Python requirements were executed on Windows 3.12; platform-specific lock output is in `requirements.lock.txt`. +On Linux/macOS use `.venv/bin/python -m uvicorn engine.api:app --host 127.0.0.1 --port 8000` and `cd web && npm run dev`. Platform-specific lock output is in `requirements.lock.txt`. ## Verify ```powershell -.\.venv\Scripts\python.exe -m pytest -q +.\.venv\Scripts\python.exe -m pytest -q # backend (slow: real Terraform) +cd web; npm test # frontend API-client tests (mocked fetch) .\run-demo.ps1 # Reproducible automation; approvals are marked as scripted demo actions: .\run-demo.ps1 -Scripted diff --git a/docs/api_contract.md b/docs/api_contract.md new file mode 100644 index 0000000..f1fa34d --- /dev/null +++ b/docs/api_contract.md @@ -0,0 +1,123 @@ +# PlanReview backend API contract (Phase 2) + +Documents the API **as implemented** in `engine/api.py` and `engine/pipeline.py` on the Phase 1 code. Shapes were observed against a running backend. Nothing here is aspirational; gaps are listed in the last section. The machine-generated schema is at `/openapi.json` and `/docs`, but most responses are untyped dicts, so this document is the reference for response bodies. + +The backend serves **only** the API. It does not host the frontend. It binds to `127.0.0.1:8000` by default. + +## Conventions + +- Base path `/api`. Bodies are JSON. Real AWS apply is disabled (`/api/health` → `cloud_apply: false`). +- **Local protections:** `TrustedHostMiddleware` accepts `Host` of `localhost`, `127.0.0.1` (any port), `testserver`. A non-`GET/HEAD/OPTIONS` request that carries an `Origin` header must be one of `http://localhost:8000`, `http://127.0.0.1:8000`, `http://localhost:5173`, `http://127.0.0.1:5173`, otherwise `403 {"detail":"Cross-origin mutation blocked"}`. There is no CORS middleware and no authentication. Origin checking is a local CSRF guard, not authorization. The Vite proxy forwards the browser's `Origin: http://127.0.0.1:5173` (or `localhost`), which is allowed. +- All task mutations run under one process-wide lock (`LOCK`); requests are serialized. +- Unknown routes return FastAPI's JSON `404 {"detail":"Not Found"}`, never HTML. + +### Error shapes (three, all under `detail`) + +| Shape | Source | Example | +|---|---|---| +| Structured | `PlanReviewError` subclasses | `{"detail":{"error":"UNSUPPORTED_OPERATION","message":"…","details":{…}}}` | +| Legacy string | `ValueError` → 409, `OSError/TimeoutError` → 503, origin block → 403, unknown route → 404 | `{"detail":"Pipeline incomplete: evaluate first"}` | +| Pydantic | request validation → 422 | `{"detail":[{"type":"string_too_short","loc":["body","task"],"msg":"…"}]}` | + +Structured codes and HTTP status: `UNSUPPORTED_OPERATION` 400, `AMBIGUOUS_REQUEST` 400, `MODEL_UNAVAILABLE` 503 (Ollama unreachable or model not installed in `ollama`/`live` mode; the backend does **not** fall back to replay). Other `PlanReviewError`s default to `GENERIC_ERROR` 400. + +Notable legacy errors (all HTTP 409 unless noted): `Task not found` (**409, not 404**), `Contract is immutable after confirmation`, `Active confirmed contract required`, `Contract expired`, `Pipeline incomplete: …` (wrong stage), `Plan the prepared edit before editing again`, `Run already applied`, `Unplanned edits exist; create and evaluate a new plan`, `Policies changed; evaluate again…`, `Address X experienced an evaluation error and cannot be approved…`, `Only REVIEW items accept approve/reject resolutions…`. `Pipeline incomplete: run fixture generator first` is also 409 (fixtures missing). A `503 Pipeline incomplete: …` comes from OS/timeouts (e.g. Terraform missing). + +The frontend client (`web/src/api.js`) maps these to `{code, message, details, status}`; see the client rules below. + +### Stages + +`task.stage`: `draft` → `confirmed` → `edited` → `planned` → `canonicalized` → `evaluated` → `resolved` → (`blocked` | `applied` | `failed`, from the apply result status lowercased). Re-editing/re-planning starts a new **run** in `task.runs`; only `runs[-1]` (the latest) is authoritative. Resolutions are cleared by a new plan or re-evaluation. + +## Endpoints + +"Long" = may take seconds to minutes (Terraform, Ollama). + +### `GET /api/health` +`200 {"status":"ok","evaluator":"cedar","cloud_apply":false}`. Read-only, safe to retry. + +### `GET /api/tasks` +`200 [Task, …]` (full task objects, including all runs; newest first). Read-only. No pagination. + +### `POST /api/tasks` — mutates, **long in `ollama`/`live` mode** +Body `{"task": string(1..4000), "mode": "replay"|"adversarial"|"ollama"|"live"}` (mode default `replay`). Interprets the request (AI-assisted in ollama/live), validates against the capability registry, drafts a contract. +Success `200 Task` with `stage:"draft"`, `contract.status:"draft"`, `intent` (`operation`, `resource_address`, `resource_type`, `attribute`, `requested_value`, `raw_task`, `status:"VALIDATED"`, `reason`). +Errors: 400 `UNSUPPORTED_OPERATION`, 400 `AMBIGUOUS_REQUEST`, 503 `MODEL_UNAVAILABLE`, 409 `Unknown mode`, 422 empty/oversized `task`. +Retry: **not idempotent** — every success creates a new task. Do not retry automatically. + +### `GET /api/tasks/{id}` / `GET /api/tasks/{id}/intent` +`200 Task` / `200 Task.intent` (may be `null`). `409 Task not found`. Read-only. + +### `POST /api/tasks/{id}/confirm` — mutates +Body: the contract object (optional; defaults to the stored draft). The contract id must not change. Seals the contract with a hash (`confirmed_contract_hash`) and sets `contract.status:"confirmed"`, `stage:"confirmed"`. +Errors: 409 `Contract is immutable after confirmation`, `Contract ID cannot change`, integrity mismatch, and an invalid contract body (raw pydantic text in a legacy 409 string, since `pydantic.ValidationError` is a `ValueError`; the body is `dict`, so FastAPI does not return 422). Retry: a second call fails (immutable). + +### `POST /api/tasks/{id}/agent?variant=…` — mutates, **long** +Query `variant` ∈ `baseline|intended|review|poisoned|adversarial` (default `poisoned`; replay-mode fixture selector, ignored in `ollama`/`live` mode which edits the baseline with the Strands agent). Requires an active confirmed contract. Prepares `main.tf` in the task workspace. `stage:"edited"`, `prepared:true`. +Errors: 409 `Active confirmed contract required`, `Unknown fixture`, `Plan the prepared edit before editing again`, `Pipeline incomplete: run fixture generator first`; 503 `MODEL_UNAVAILABLE` (live). Retry: not while `prepared` is true. +Note: in replay mode the fixture variant, not the natural-language task, determines the edit. + +### `POST /api/tasks/{id}/plan` — mutates, **long** +Runs a real `terraform plan -refresh=false -out=…` (dummy credentials, no AWS calls), enforces the pre-plan guard, saves the plan and hashes. Appends a run to `runs` (`plan_path`, `plan_hash`, `raw_path`, `raw_hash`, `plan_stdout`). `stage:"planned"`. Requires stage `edited`. +Errors: 409 `Pipeline incomplete: run edits first`, `Contract expired`, guard rejections and Terraform stderr (text of the failure), 503 on OS/timeout. +Retry: creates another run; do not retry blindly. + +### `POST /api/tasks/{id}/canonicalize` — mutates +Converts the saved raw plan into `run.canonical[]`: `{address, resource_type, action, environment, region, changes:[{attribute,before,after}], dependencies, unknown}`. `stage:"canonicalized"`. Errors: 409 `Raw plan hash mismatch`. Idempotent for the same run. + +### `POST /api/tasks/{id}/evaluate` — mutates +Evaluates each canonical change with Cedar against the confirmed contract. Sets `run.verdicts[]` (`{address, verdict, changes, reason, determining_policies}`) and `run.policy_hash`; clears `run.resolutions`. `stage:"evaluated"`. Errors: 409 `Pipeline incomplete: canonicalize first`. + +**`verdict` is one of `ALLOW`, `REVIEW`, `DENY`, `EVALUATION_ERROR`.** `EVALUATION_ERROR` is a **technical failure** of Cedar evaluation (`reason` starts `Cedar evaluation failed:`). It is not REVIEW, cannot be approved through `/resolve`, and always blocks `/apply`. The client must present it as a failure, distinct from REVIEW. + +### `POST /api/tasks/{id}/resolve` — mutates +Body `{ "
": "approve" | "reject", … }`. Only addresses whose verdict is `REVIEW` accept decisions; `DENY` and `EVALUATION_ERROR` addresses are rejected with 409. Stored on `run.resolutions` and bound to that run's plan/policy hash. `stage:"resolved"`. +Errors: 409 `Only REVIEW items accept approve/reject…`, `…experienced an evaluation error and cannot be approved…`, `Pipeline incomplete: evaluate first`. Retry: idempotent for identical decisions on the same run. +Gap: the backend does not return who resolved. + +### `POST /api/tasks/{id}/apply` — mutates +The **server-side gate**. Verifies contract validity, no unplanned edits, policy hash, plan hash, verdicts, resolutions. Result in `run.apply_result` `{status, reason, spawned}` and `stage`: +- `BLOCKED` — gate reason text (unresolved REVIEW/DENY, evaluation error, expired contract, hash mismatch, or `AWS apply disabled: configure and verify an isolated emulator first`). +- `APPLIED` / `FAILED` — only reachable for a genuine local-resource Terraform apply in tests; real AWS apply is disabled. +Returns the full Task (`200`) even when blocked; a block is data, not an HTTP error. Errors: 409 `Run already applied`, `Unplanned edits exist…`, `Policies changed…`, `Pipeline incomplete: evaluate first`. May be long when it spawns Terraform. Not idempotent after `APPLIED`. +The frontend must display `apply_result`; it must not compute its own gate. + +### `GET /api/tasks/{id}/audit` +`200 [{id, timestamp, kind, data}, …]` — persistent SQLite audit, in stored order. Kinds seen: `draft`, `human_confirmation`, `apply_result`, and others for each stage. Read-only. + +## Supported Phase 1 operations (boundaries) + +Only two infrastructure edits are supported: + +| Operation | Resource | Attribute | Constraint | +|---|---|---|---| +| `update_memory` | `aws_lambda_function.dev_api` | `memory_size` | integer 128–10240 MB (1 MB increments) | +| `update_tags` | `aws_s3_bucket.assets` | `tags.Team` | non-security `Team` tag only | + +Anything else (RDS, IAM, security groups, networking, deletes, compound requests) returns `UNSUPPORTED_OPERATION` or `AMBIGUOUS_REQUEST`. Replay-mode fixtures (`poisoned`, `review`, …) exist to demonstrate ALLOW/REVIEW/DENY on real Terraform plans and are not new capabilities. + +## Recommended real workflow + +1. `POST /tasks` → inspect `intent` and `contract` (draft). +2. `POST /tasks/{id}/confirm` with the (possibly edited) contract. +3. `POST /tasks/{id}/agent` → `POST …/plan` → `POST …/canonicalize` → `POST …/evaluate`. Stop at the first failure; do not skip stages. +4. Inspect `runs[-1].verdicts`. `EVALUATION_ERROR` → stop; repair and re-plan. +5. `POST …/resolve` for `REVIEW` items. +6. `POST …/apply` → read `apply_result`. +7. `GET …/audit`. + +## Frontend client rules (`web/src/api.js`) + +- Every failure throws `ApiError {code, message, details, status}`. Backend codes pass through; client codes: `NETWORK_ERROR`, `BACKEND_UNAVAILABLE` (empty-body 500/502/503/504 from the Vite proxy), `TIMEOUT`, `ABORTED`, `INVALID_RESPONSE` (non-JSON 2xx), `VALIDATION_ERROR`, `INVALID_STATE` (409), `FORBIDDEN`, `NOT_FOUND`, `PIPELINE_INCOMPLETE` (legacy 503), `HTTP_ERROR`. +- No automatic retries. Timeouts/aborts on mutating calls state that the backend may still be running the operation; the client never claims Terraform or the agent was cancelled. +- Timeouts: 30 s default; 15 min for create/agent/plan. + +## Gaps for the Phase 3 UI (documented, not implemented) + +- `Task not found` is 409, and mutation-error codes are mostly free-text legacy strings; no stable code for stage errors. +- Task responses expose absolute filesystem paths (`workspace`, `plan_path`, `raw_path`) and full `plan_stdout`; no summary/diff-only endpoint. +- `GET /tasks` returns full objects with no pagination or summary view. +- No per-stage progress or cancellation endpoint; long operations are a single blocking request. +- No endpoint to read Cedar policy text or per-policy explanations beyond `determining_policies` ids and `reason`. +- No resolver identity on `resolutions`; no evidence export endpoint (audit list only). +- No endpoint listing supported operations (the capability registry is only visible through errors). diff --git a/docs/phase3_migration_map.md b/docs/phase3_migration_map.md new file mode 100644 index 0000000..19c77f2 --- /dev/null +++ b/docs/phase3_migration_map.md @@ -0,0 +1,85 @@ +# Phase 3 migration map: PlanBound design → real PlanReview backend + +Status: **planning document only.** Phase 2 does not migrate any visuals. `design/` is unchanged. + +Sources read: `design/SITE_README.md`, `design/SITE_SELF_REVIEW.md`, `design/planbound/{site-app,site-engine,site-views,build_site}` and the module list in `design/planbound/` (`core, boot, globe-end, tree, robot, cursor, audio, perf, site-support, site-ui, site.css`). Backend reference: [api_contract.md](api_contract.md). + +## Ground rules (binding for Phase 3) + +The standalone site (`design/planbound-site.html`) is a **browser-only prototype**. Its `site-engine.js` holds a sample rules engine, sample plans A/B/C, a browser contract with a browser-generated hash, a session evidence store and a simulated apply. None of that is authority or evidence. + +1. **Do not carry `site-engine.js` (sample rules, sample plans, `PB.store` authorization, simulated apply) into the live product.** The React app must never compute ALLOW/REVIEW/DENY, gate state or apply eligibility. It renders `run.verdicts`, `run.apply_result` and `audit` returned by the backend. +2. The live product uses only: backend-confirmed contracts (`confirmed_contract_hash`), real saved Terraform plans (`run.plan_hash`), backend canonical changes (`run.canonical`), actual Cedar verdicts (`run.verdicts`), backend-authorized REVIEW decisions (`/resolve`), backend-enforced gate results (`/apply` → `apply_result`), persisted audit (`/audit`). +3. Sample A/B/C must not appear as product features and must not imply IAM, RDS or security-group **editing** support. Supported edits are only `update_memory` (dev_api Lambda) and `update_tags` (assets S3 `Team`). Replay fixtures may be labeled "fixture replay", never "sample" that suggests generality. +4. Browser-generated contract hashes (12-char) are not backend evidence; show `confirmed_contract_hash` from the backend. +5. Pasted JSON is not an authenticated Terraform plan. Remove the paste-plan feature or keep it strictly as a labeled offline explainer that never touches gate state. +6. "Applied (simulated)" is not an infrastructure apply. Real AWS apply is disabled; the live UI shows the backend's `BLOCKED` reason. +7. Remove/replace the "SAMPLE DATA" badges only where the underlying data becomes real; anything still fake stays labeled. +8. A visually enabled button never proves authorization; the backend decides. UI disabling is a convenience only. +9. Frontend HTTP goes through `web/src/api.js` only. `EVALUATION_ERROR` is a technical failure, never REVIEW. +10. Original 3D components, shaders, camera descent and interaction sequence are preserved, not redesigned. + +## Target frontend architecture + +- `web/` (React/Vite) stays the app. The design is ported into React modules (routes as React Router or hash-router views, `site.css` as CSS modules or scoped stylesheet). No iframe. No single giant component. Design sources stay in `design/` as the visual reference. +- Server state lives in one store/hook layer wrapping `api.js` (task list, current task, latest run, audit). Requests carry an `AbortSignal` from the owning component; an abort only stops the browser wait, so the UI says the backend may still be running and re-fetches `GET /tasks/{id}`. +- Stale state rule for every view: after any failed or timed-out mutation, re-read the task; never assume the stage. + +## WebGL / 3D lifecycle (safe React integration) + +The 3D code (`core.js, boot.js, globe-end.js, tree.js, robot.js, cursor.js, audio.js, perf.js`) is written as global IIFEs on `window.PB`, creating renderers/canvases, `requestAnimationFrame` loops and listeners at load. Porting risks: duplicate renderers on remount, React StrictMode double-invocation, leaked listeners, two loops. + +Approach: +1. Wrap the scene in a single **`SceneHost`** mounted once at the app root, above the router. It owns one canvas per scene (globe/tree, robot), one shared `THREE.WebGLRenderer` each, and one `requestAnimationFrame` loop driven from a single scheduler (`PB.perf` sampling stays). +2. Convert each IIFE into `init(container) → { dispose() }` factories. `dispose()` cancels the rAF, removes every listener it added (keep the list of `[target,event,fn]`), disposes geometries/materials/textures, calls `renderer.dispose()` and `forceContextLoss()`, and stops audio nodes. +3. Use an idempotent singleton guard (`if (scene) return scene`) so StrictMode's double effect cannot create two renderers; cleanup runs on real unmount only. +4. Route changes do not unmount `SceneHost`. Landing route shows the canvas; other routes pause the loop (`visible=false`) rather than destroy it. Pause on `document.hidden` and when off-screen (IntersectionObserver), as `PB.globe.visible` already models. +5. Scene ↔ React data through a tiny event bus/store with scoped subscriptions returning unsubscribe functions (`site-support.js` `PB.bus.on` already returns an `off`). The tree's gate and leaf colors are driven by backend verdict data (props), not `PB.store`. +6. Preserve `prefers-reduced-motion`, touch/coarse-pointer fallbacks, `PB.webgl=false` fallback, and (new in Phase 3; the design has no `webglcontextlost` handler today) `webglcontextlost/restored` handling. Keep the boot gate ("CLICK TO START") since it also unlocks audio. +7. Tests: mount/unmount ×N leaves 1 canvas, 0 stray listeners, 0 running loops (assert via counters), and no console errors. + +## Component map + +Phase key: **P3a** foundation + read-only views, **P3b** workflow (create → confirm → plan → resolve), **P3c** evidence/polish/QA, **P4+** needs new backend work. + +| # | Component | 1. Design source | 2. Browser-only/sample data | 3. Real backend info needed | 4. Existing endpoint | 5. Missing | 6. Loading / empty / error / stale | Phase | +|---|---|---|---|---|---|---|---|---| +| 1 | Landing page + globe | `core.js`, `boot.js`, `globe-end.js`, `#home-view` in `planbound-site.html` | Session counters "PLANS EVALUATED / CHANGES GATED / HUMAN DECISIONS" (`PB.store.metrics`) | Optional real counts from task list | `GET /tasks`, `GET /health` | No summary/metrics endpoint | Boot progress gate; counters show "—" until loaded; on error show badge "backend unreachable" (globe still renders); refetch on focus | P3a | +| 2 | Camera descent | `core.js` (progress `P`, `targetProgress`, travel), `globe-end.js` | none | none (pure visual) | — | — | No data dependency; degrade to static frame if WebGL unavailable | P3a | +| 3 | Tree + gate visualization | `tree.js`, `site-app.js` `homeState` | Gate open/closed and 3 verdict leaves read `PB.store` sample verdicts | Latest task's `runs[-1].verdicts`, `apply_result` | `GET /tasks` (latest), `GET /tasks/{id}` | Needs a "current task" concept (latest by `created_at`) | No task: neutral/idle tree with copy "No plan yet"; loading: dim; error: neutral + banner; stale: refetch after any mutation. Gate is closed unless backend says otherwise; it is never open (real apply disabled) except as data from `apply_result` | P3a | +| 4 | Robot reviewer | `robot.js`, `#unit` section | Robot status text/steps (canned) | Latest run state (stage, verdict counts) to animate status | `GET /tasks/{id}` | none required | Idle animation when no data; status text from `stage`; error state shows neutral | P3a | +| 5 | Task creation | Not in the new site (uses Contract wizard); existing `web/` console "Create task" screen | Boundary wizard pre-fills environment/classes/blast radius | Natural-language request + `mode` | `POST /tasks` | none | Submit disabled while busy; long in Ollama mode → show progress copy; errors: `UNSUPPORTED_OPERATION`, `AMBIGUOUS_REQUEST`, `MODEL_UNAVAILABLE` rendered with the message (no replay fallback); failed create never yields a task | P3b | +| 6 | Contract wizard | `site-views.js` `#/contract`, `site-engine.js` blank contract | Environment ∈ dev/prod, resource classes incl. `db_instance`, IAM; `maxBlast`; consent checkbox; 12-char hash | Backend `contract` fields: `allowed_resource_addresses/types`, `allowed_operations`, `allowed_regions`, `max_changed_resources`, `denies`, `expires_at`, `status`, `confirmed_contract_hash` | `POST /tasks` (draft), `POST /tasks/{id}/confirm` | Environment/class choices beyond Phase 1 scope are not supported: wizard must edit the backend contract, not the sample model; no endpoint listing supported operations | Draft vs sealed states from `contract.status`; immutable after confirm (409 shown); expiry countdown from `expires_at`; error surfaces raw 409 text | P3b | +| 7 | Plan Review | `#/plan-review`, `site-views.js`, sample plans A/B/C | Sample plans, pasted normalized JSON, plan picker | `runs[-1]`: `canonical`, `verdicts`, `plan_hash`, `policy_hash` | `POST /agent`→`/plan`→`/canonicalize`→`/evaluate` | Fixture `variant` picker only in replay mode; no progress events for long stages | Empty: "Pipeline incomplete" with next stage; loading: per-stage labels; stop at first failed stage; stale: re-read task after error/timeout | P3b | +| 8 | Resource changes + before/after diffs | `site-views.js` change rows | Sample change objects | `verdicts[].changes[{attribute,before,after}]`, `canonical[].action` | via task/run | none | Empty verdict list state; unknown values (`unknown:true`) rendered as "known after apply" | P3b | +| 9 | Cedar verdict explanations | rule/"why" text in `site-engine.js` (`rule-reason`) | Sample rule ids and reasons | `verdicts[].reason`, `determining_policies` | via run | No policy text/explanation endpoint (policy ids only) | Show backend `reason` verbatim; policy ids as chips; `EVALUATION_ERROR` shown as technical failure (distinct style/alert) | P3b (text), P4 for policy source | +| 10 | REVIEW resolution | approve/reject/explain buttons, `j/k/a/r/e` keys | Browser-side resolutions in sessionStorage | `run.resolutions`, per-address `REVIEW` verdicts | `POST /resolve` | Resolver identity not returned; reject semantics ("excluded from apply set") are design-only | Only REVIEW rows show controls; DENY / `EVALUATION_ERROR` have none; error toast on 409; resolutions cleared on new plan → refetch | P3b | +| 11 | Apply / gate | "Apply Changes" + gate panel | Simulated apply, gate computed in browser (`PB.gateText`) | `apply_result{status,reason,spawned}`, `stage` | `POST /apply` | Real apply disabled; gate has no pre-flight "explain" endpoint | Button labeled as a gate check ("Verify with server gate"); result is backend `BLOCKED` reason; never show "Applied" unless `apply_result.status=="APPLIED"` | P3b | +| 12 | Evidence explorer | `#/evidence` filters, search, detail, Copy/Download JSON | Session-only evidence records | Audit log entries `{id,timestamp,kind,data}` per task | `GET /tasks/{id}/audit` | No cross-task audit endpoint; no server-side filter/search; audit `data` includes absolute paths | Empty: "No audit yet"; error banner; client-side filter over fetched records; export = the fetched JSON, labeled as a client export | P3c | +| 13 | Shared status + navigation | `site-app.js` router, header, `sound-toggle`, sample notice | "SAMPLE DATA" notice; session metrics | Backend reachability, `cloud_apply:false` | `GET /health` | none | Status pill: connecting / online / offline; `cloud_apply:false` shown as "AWS apply disabled" | P3a | +| 14 | How It Works | `#/how-it-works` | Static copy | none | — | Copy must not claim unsupported capabilities | Static | P3a | +| 15 | About | `#/about` | Static copy/FAQ | none | — | Copy review vs Phase 1 limits | Static | P3a | +| 16 | Legal placeholders | `#/legal` | Static placeholder, sample-data notice | none | — | Legal text is placeholder; must stay labeled pre-launch | Static | P3a | +| 17 | Responsive + keyboard | `site_keyboard.py`, `g`+letters, `?`, `M`, `j/k/a/r/e`, cursor | none | none | — | Shortcuts must not fire while typing in inputs (already so); mutating shortcuts (`a`,`r`) must call `/resolve`, not local state | Keyboard help overlay; focus management on route change; coarse-pointer disables custom cursor | P3c | +| 18 | WebGL lifecycle | see section above | none | none | — | — | Context-loss fallback (new); pause when hidden | P3a | +| 19 | Existing functional console (`web/`) | `web/src/main.jsx` | none | full workflow | all | — | Kept working through Phase 3 as the behavior reference; retired only after PlanBound views reach parity with tests | P3b→P3c | + +## What each sample-only concept becomes + +| Design/sample concept | Live replacement | +|---|---| +| Sample A/B/C plans | Real saved plan from `/plan`; replay fixtures labeled as fixtures | +| Browser sample rules → verdicts | `run.verdicts` from Cedar | +| Contract "hash" (12 char) | `confirmed_contract_hash` | +| Pasted JSON plan | Removed (or offline explainer, no gate influence) | +| Simulated apply / "Applied (simulated)" | `apply_result` from `/apply` (currently `BLOCKED`) | +| Session evidence | Persistent `/audit` | +| Session metrics counters | Derived from `GET /tasks`, or removed | + +## Backend gaps to close before or during Phase 3 (from api_contract.md) + +Stable machine codes for stage errors and a real 404 for unknown tasks; a task summary endpoint without absolute paths / full stdout; supported-operations endpoint; policy explanation endpoint; resolver identity; cross-task audit query. Each must be backward-compatible and must not change Cedar semantics. + +## Explicitly out of scope until requested + +Expanding infrastructure-editing capabilities; enabling real AWS apply; deploying; authentication/multi-user. diff --git a/engine/api.py b/engine/api.py index 24d692a..b9ad7a8 100644 --- a/engine/api.py +++ b/engine/api.py @@ -1,9 +1,8 @@ from fastapi import FastAPI, HTTPException -from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse from starlette.middleware.trustedhost import TrustedHostMiddleware from pydantic import BaseModel, Field -from engine.pipeline import Pipeline, LOCK, ROOT +from engine.pipeline import Pipeline, LOCK from engine.exceptions import PlanReviewError app = FastAPI(title="PlanReview", version="0.1.0") @@ -118,6 +117,3 @@ def apply(id: str): def audit(id: str): return call(pipeline.store.audit, id) - -if (ROOT / "web/dist").exists(): - app.mount("/", StaticFiles(directory=ROOT / "web/dist", html=True), name="console") diff --git a/requirements.txt b/requirements.txt index 9d3f241..45ab90a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,6 @@ fastapi>=0.115,<1 uvicorn>=0.34,<1 pydantic>=2.10,<3 cedarpy>=4.8,<5 -strands-agents>=1,<2 +strands-agents[ollama]>=1,<2 pytest>=8,<10 httpx>=0.28,<1 diff --git a/start-frontend.ps1 b/start-frontend.ps1 new file mode 100644 index 0000000..a408c26 --- /dev/null +++ b/start-frontend.ps1 @@ -0,0 +1,5 @@ +# Starts the Vite dev server on 127.0.0.1:5173. /api is proxied to FastAPI (127.0.0.1:8000). +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot 'web') +if (-not (Test-Path 'node_modules')) { npm ci } +npm run dev diff --git a/start.ps1 b/start.ps1 index 711bdd3..99448d8 100644 --- a/start.ps1 +++ b/start.ps1 @@ -1,3 +1,6 @@ +# Starts the FastAPI backend only (API + /docs) on loopback. It does not need web/dist. $ErrorActionPreference = 'Stop' Set-Location $PSScriptRoot -& .\.venv\Scripts\python.exe -m uvicorn engine.api:app --host 127.0.0.1 --port 8000 +$py = Join-Path $PSScriptRoot '.venv\Scripts\python.exe' +if (-not (Test-Path $py)) { throw "Missing .venv. Run: python -m venv .venv; .\.venv\Scripts\python.exe -m pip install -r requirements.txt" } +& $py -m uvicorn engine.api:app --host 127.0.0.1 --port 8000 diff --git a/tests/test_api.py b/tests/test_api.py index 6e5d79e..3df6a0d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -44,3 +44,37 @@ def test_cross_origin_blocked(): ).status_code == 403 ) + + +def test_api_is_independent_of_frontend_build(monkeypatch): + # Phase 2: the API never serves the frontend; there is no static mount. + from starlette.staticfiles import StaticFiles + + assert not any( + isinstance(getattr(r, "app", None), StaticFiles) for r in api.app.routes + ) + client = TestClient(api.app) + h = client.get("/api/health").json() + assert h["status"] == "ok" and h["cloud_apply"] is False + assert client.get("/openapi.json").status_code == 200 + assert client.get("/docs").status_code == 200 + + +def test_unknown_routes_are_json_404_not_html(): + client = TestClient(api.app) + for path in ["/api/nope", "/api/tasks/x/nope", "/", "/index.html"]: + r = client.get(path) + assert r.status_code == 404, path + assert r.headers["content-type"].startswith("application/json"), path + assert "=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", @@ -1614,6 +1758,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -1635,6 +1789,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1660,6 +1841,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1691,6 +1882,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1743,6 +1941,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2109,6 +2327,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2174,6 +2399,23 @@ "node": ">=18" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2332,6 +2574,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2342,6 +2591,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2363,6 +2646,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2380,6 +2677,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -2486,6 +2813,119 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index da6f8c2..3d17ba6 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", - "build": "vite build" + "build": "vite build", + "test": "vitest run" }, "dependencies": { "lucide-react": "^0.468.0", @@ -17,6 +18,7 @@ "@vitejs/plugin-react": "^4.4.0", "prettier": "^3.9.7", "tailwindcss": "^4.1.0", - "vite": "^6.3.0" + "vite": "^6.3.0", + "vitest": "^3.2.7" } } diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..d5a7563 --- /dev/null +++ b/web/src/api.js @@ -0,0 +1,214 @@ +// Single HTTP client for the PlanReview backend. All requests use relative +// /api paths: Vite proxies them to FastAPI in development. +// +// Every failure is thrown as ApiError with a stable shape: +// { code, message, details, status } +// A failed request is never converted into a success value, and mutating +// requests are never retried automatically. + +export class ApiError extends Error { + constructor(code, message, { details = null, status = null } = {}) { + super(message); + this.name = "ApiError"; + this.code = code; + this.details = details; + this.status = status; + } +} + +const BASE = "/api"; +export const DEFAULT_TIMEOUT_MS = 30_000; +// Terraform / agent stages may legitimately run for minutes. +export const LONG_TIMEOUT_MS = 15 * 60_000; + +const NOT_STOPPED = + " The backend may still be running this operation; check the task state before retrying."; + +const STATUS_CODES = { + 400: "BAD_REQUEST", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "INVALID_STATE", + 422: "VALIDATION_ERROR", +}; + +function text(v) { + if (typeof v === "string") return v; + if (v === null || v === undefined) return ""; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +// Normalize the error shapes the backend emits: +// structured: { detail: { error, message, details } } (PlanReviewError) +// legacy: { detail: "message" } (ValueError, 503, 403, 404) +// pydantic: { detail: [ { loc, msg, type }, ... ] } (422) +export function normalizeErrorBody(status, body, rawText = "") { + const detail = body && typeof body === "object" ? body.detail : undefined; + if (detail && typeof detail === "object" && !Array.isArray(detail)) { + if (detail.error || detail.message) { + return new ApiError( + detail.error || STATUS_CODES[status] || "HTTP_ERROR", + text(detail.message) || `Request failed (HTTP ${status})`, + { details: detail.details ?? null, status }, + ); + } + } + if (Array.isArray(detail)) { + const lines = detail.map((d) => { + const where = Array.isArray(d?.loc) + ? d.loc.filter((p) => p !== "body").join(".") + : ""; + return where ? `${where}: ${text(d?.msg)}` : text(d?.msg ?? d); + }); + return new ApiError("VALIDATION_ERROR", lines.join("; ") || "Invalid request", { + details: detail, + status, + }); + } + if (typeof detail === "string" && detail) { + let code = STATUS_CODES[status] || "HTTP_ERROR"; + if (status === 503) code = "PIPELINE_INCOMPLETE"; + return new ApiError(code, detail, { status }); + } + // The Vite proxy answers with an empty-body 500/502/503/504 when FastAPI is down. + if ([500, 502, 503, 504].includes(status) && !rawText.trim()) { + return new ApiError( + "BACKEND_UNAVAILABLE", + "The PlanReview backend is not reachable. Start FastAPI on 127.0.0.1:8000.", + { status }, + ); + } + const snippet = rawText.trim().slice(0, 200); + return new ApiError( + STATUS_CODES[status] || "HTTP_ERROR", + snippet && !snippet.startsWith("<") + ? `${snippet} (HTTP ${status})` + : `Request failed (HTTP ${status})`, + { status }, + ); +} + +/** + * request(path, { method, body, timeoutMs, signal, fetchImpl }) + * Resolves with parsed JSON (or null for an empty 2xx body). + */ +export async function request( + path, + { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS, signal, fetchImpl } = {}, +) { + const doFetch = fetchImpl || globalThis.fetch.bind(globalThis); + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + const mutating = method !== "GET"; + try { + let response; + try { + response = await doFetch(BASE + path, { + method, + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + } catch (e) { + if (timedOut) { + throw new ApiError( + "TIMEOUT", + `No response within ${Math.round(timeoutMs / 1000)}s.` + + (mutating ? NOT_STOPPED : ""), + ); + } + if (e?.name === "AbortError" || controller.signal.aborted) { + throw new ApiError( + "ABORTED", + "The request was abandoned in the browser." + (mutating ? NOT_STOPPED : ""), + ); + } + throw new ApiError( + "NETWORK_ERROR", + "Could not reach the PlanReview backend. Is FastAPI running on 127.0.0.1:8000?", + ); + } + + let raw = ""; + try { + raw = await response.text(); + } catch { + throw new ApiError( + timedOut ? "TIMEOUT" : "NETWORK_ERROR", + "The response was interrupted before it completed." + + (mutating ? NOT_STOPPED : ""), + { status: response.status }, + ); + } + let parsed; + let isJson = false; + if (raw.trim()) { + try { + parsed = JSON.parse(raw); + isJson = true; + } catch { + isJson = false; + } + } + if (!response.ok) throw normalizeErrorBody(response.status, isJson ? parsed : null, raw); + if (!raw.trim()) return null; + if (!isJson) { + throw new ApiError( + "INVALID_RESPONSE", + "The server returned a non-JSON response. The /api proxy may be misconfigured.", + { status: response.status }, + ); + } + return parsed; + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } +} + +const post = (path, body = {}, opts = {}) => + request(path, { method: "POST", body, ...opts }); + +const enc = encodeURIComponent; + +export const api = { + health: (o) => request("/health", o), + listTasks: (o) => request("/tasks", o), + getTask: (id, o) => request(`/tasks/${enc(id)}`, o), + getAudit: (id, o) => request(`/tasks/${enc(id)}/audit`, o), + // Mutating calls: never auto-retried. + createTask: (task, mode, o) => + post("/tasks", { task, mode }, { timeoutMs: LONG_TIMEOUT_MS, ...o }), + confirm: (id, contract, o) => post(`/tasks/${enc(id)}/confirm`, contract, o), + runAgent: (id, variant, o) => + post(`/tasks/${enc(id)}/agent?variant=${enc(variant)}`, {}, { timeoutMs: LONG_TIMEOUT_MS, ...o }), + plan: (id, o) => + post(`/tasks/${enc(id)}/plan`, {}, { timeoutMs: LONG_TIMEOUT_MS, ...o }), + canonicalize: (id, o) => post(`/tasks/${enc(id)}/canonicalize`, {}, o), + evaluate: (id, o) => post(`/tasks/${enc(id)}/evaluate`, {}, o), + resolve: (id, decisions, o) => post(`/tasks/${enc(id)}/resolve`, decisions, o), + apply: (id, o) => post(`/tasks/${enc(id)}/apply`, {}, o), +}; + +// Render any thrown value as a readable string (never "[object Object]"). +export function describeError(e) { + if (e instanceof ApiError) { + return e.code && !["HTTP_ERROR", "BAD_REQUEST"].includes(e.code) + ? `${e.message} [${e.code}]` + : e.message; + } + return text(e?.message ?? e) || "Unexpected error"; +} diff --git a/web/src/api.test.js b/web/src/api.test.js new file mode 100644 index 0000000..4d0933a --- /dev/null +++ b/web/src/api.test.js @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from "vitest"; +import { request, api, ApiError, describeError, normalizeErrorBody } from "./api"; + +const resp = (status, body, { json = true } = {}) => ({ + ok: status >= 200 && status < 300, + status, + text: async () => (body === undefined ? "" : json ? JSON.stringify(body) : body), +}); +const f = (r) => vi.fn(async () => r); +const fail = async (p) => { + try { + await p; + } catch (e) { + return e; + } + throw new Error("expected rejection"); +}; + +describe("success", () => { + it("parses JSON and uses relative /api", async () => { + const fetchImpl = f(resp(200, { status: "ok" })); + expect(await request("/health", { fetchImpl })).toEqual({ status: "ok" }); + expect(fetchImpl.mock.calls[0][0]).toBe("/api/health"); + }); + it("returns null for empty 2xx", async () => { + expect(await request("/x", { fetchImpl: f(resp(204)) })).toBeNull(); + }); + it("sends JSON body on POST", async () => { + const fetchImpl = f(resp(200, {})); + await request("/tasks", { method: "POST", body: { a: 1 }, fetchImpl }); + const init = fetchImpl.mock.calls[0][1]; + expect(init.method).toBe("POST"); + expect(init.body).toBe('{"a":1}'); + }); +}); + +describe("error shapes", () => { + it("structured PlanReviewError", async () => { + const e = await fail( + request("/tasks", { + fetchImpl: f( + resp(400, { detail: { error: "UNSUPPORTED_OPERATION", message: "Nope", details: { k: 1 } } }), + ), + }), + ); + expect(e).toBeInstanceOf(ApiError); + expect(e).toMatchObject({ code: "UNSUPPORTED_OPERATION", message: "Nope", details: { k: 1 }, status: 400 }); + }); + it("model unavailable keeps its code", async () => { + const e = await fail( + request("/tasks", { + fetchImpl: f(resp(503, { detail: { error: "MODEL_UNAVAILABLE", message: "Ollama down", details: {} } })), + }), + ); + expect(e.code).toBe("MODEL_UNAVAILABLE"); + }); + it("legacy string detail 409 -> INVALID_STATE", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(409, { detail: "Run already applied" })) })); + expect(e).toMatchObject({ code: "INVALID_STATE", message: "Run already applied", status: 409 }); + }); + it("legacy 503 -> PIPELINE_INCOMPLETE", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(503, { detail: "Pipeline incomplete: boom" })) })); + expect(e.code).toBe("PIPELINE_INCOMPLETE"); + }); + it("pydantic 422 list", async () => { + const e = await fail( + request("/x", { + fetchImpl: f(resp(422, { detail: [{ loc: ["body", "task"], msg: "String should have at least 1 character", type: "x" }] })), + }), + ); + expect(e.code).toBe("VALIDATION_ERROR"); + expect(e.message).toBe("task: String should have at least 1 character"); + }); + it("origin block 403", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(403, { detail: "Cross-origin mutation blocked" })) })); + expect(e).toMatchObject({ code: "FORBIDDEN", status: 403 }); + }); + it("unknown route 404", async () => { + const e = await fail(request("/nope", { fetchImpl: f(resp(404, { detail: "Not Found" })) })); + expect(e).toMatchObject({ code: "NOT_FOUND", message: "Not Found" }); + }); + it("proxy 502 with empty body -> BACKEND_UNAVAILABLE", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(502)) })); + expect(e.code).toBe("BACKEND_UNAVAILABLE"); + }); + it("proxy 500 with empty body (Vite, backend down) -> BACKEND_UNAVAILABLE", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(500)) })); + expect(e.code).toBe("BACKEND_UNAVAILABLE"); + }); + it("non-JSON HTML error does not leak markup", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(500, "boom", { json: false })) })); + expect(e.message).toBe("Request failed (HTTP 500)"); + }); + it("plain-text error body is kept", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(400, "Invalid host header", { json: false })) })); + expect(e.message).toBe("Invalid host header (HTTP 400)"); + }); + it("non-JSON 200 is INVALID_RESPONSE, not success", async () => { + const e = await fail(request("/x", { fetchImpl: f(resp(200, "", { json: false })) })); + expect(e.code).toBe("INVALID_RESPONSE"); + }); + it("never yields [object Object]", () => { + const e = normalizeErrorBody(400, { detail: { error: "X", message: { a: 1 } } }); + expect(e.message).not.toContain("[object Object]"); + expect(describeError({ message: { a: 1 } })).not.toContain("[object Object]"); + expect(describeError(new ApiError("X", "msg"))).toBe("msg [X]"); + }); +}); + +describe("transport failures", () => { + it("network failure", async () => { + const e = await fail(request("/x", { fetchImpl: vi.fn(async () => { throw new TypeError("Failed to fetch"); }) })); + expect(e.code).toBe("NETWORK_ERROR"); + }); + it("timeout warns backend may still run for POST", async () => { + const fetchImpl = vi.fn((_u, { signal }) => new Promise((_, rej) => signal.addEventListener("abort", () => rej(Object.assign(new Error("a"), { name: "AbortError" }))))); + const e = await fail(request("/x", { method: "POST", body: {}, timeoutMs: 10, fetchImpl })); + expect(e.code).toBe("TIMEOUT"); + expect(e.message).toMatch(/may still be running/); + expect(e.message).not.toMatch(/cancel/i); + }); + it("caller abort never claims cancellation", async () => { + const ctl = new AbortController(); + const fetchImpl = vi.fn((_u, { signal }) => new Promise((_, rej) => signal.addEventListener("abort", () => rej(Object.assign(new Error("a"), { name: "AbortError" }))))); + const p = request("/x", { method: "POST", body: {}, signal: ctl.signal, fetchImpl }); + ctl.abort(); + const e = await fail(p); + expect(e.code).toBe("ABORTED"); + expect(e.message).toMatch(/may still be running/); + expect(e.message).not.toMatch(/cancelled/i); + }); + it("GET abort has no backend-running warning", async () => { + const ctl = new AbortController(); + ctl.abort(); + const fetchImpl = vi.fn(async (_u, { signal }) => { if (signal.aborted) throw Object.assign(new Error("a"), { name: "AbortError" }); }); + const e = await fail(request("/x", { signal: ctl.signal, fetchImpl })); + expect(e.code).toBe("ABORTED"); + expect(e.message).not.toMatch(/may still/); + }); +}); + +describe("no automatic retry", () => { + it("failed POST is sent exactly once", async () => { + const fetchImpl = vi.fn(async () => { throw new TypeError("x"); }); + await fail(request("/tasks", { method: "POST", body: {}, fetchImpl })); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + +describe("endpoint helpers", () => { + it("encode ids and paths", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => resp(200, {})); + await api.runAgent("a/b", "poisoned"); + expect(spy.mock.calls[0][0]).toBe("/api/tasks/a%2Fb/agent?variant=poisoned"); + await api.createTask("t", "replay"); + expect(JSON.parse(spy.mock.calls[1][1].body)).toEqual({ task: "t", mode: "replay" }); + spy.mockRestore(); + }); +}); diff --git a/web/src/main.jsx b/web/src/main.jsx index fc5049d..f39c5f4 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -18,6 +18,7 @@ import { Copy, ExternalLink, } from "lucide-react"; +import { api, describeError } from "./api"; import "./styles.css"; const steps = [ @@ -30,16 +31,6 @@ const steps = [ const icons = [Plus, Lock, Layers, Check, History]; const short = (v) => v === null ? "null" : typeof v === "object" ? JSON.stringify(v) : String(v); -async function api(path, body) { - const r = await fetch("/api" + path, { - method: body === undefined ? "GET" : "POST", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const data = await r.json(); - if (!r.ok) throw Error(data.detail || "Request failed"); - return data; -} function Badge({ value }) { return ( @@ -69,10 +60,15 @@ function App() { const unresolved = verdicts.filter( (v) => v.verdict === "REVIEW" && run.resolutions?.[v.address] !== "approve", ).length; + const evalErrors = verdicts.filter( + (v) => v.verdict === "EVALUATION_ERROR", + ).length; const expired = task && new Date(task.contract.expires_at) <= new Date(); const blocked = expired ? "Contract expired" - : counts[2] + : evalErrors + ? `${evalErrors} technical evaluation ${evalErrors === 1 ? "error" : "errors"}: not approvable; repair evaluation and re-run` + : counts[2] ? `${counts[2]} explicit ${counts[2] === 1 ? "DENY" : "DENYs"} must be removed from the plan` : unresolved ? `${unresolved} REVIEW ${unresolved === 1 ? "requires" : "require"} a decision` @@ -88,13 +84,14 @@ function App() { try { await fn(); } catch (e) { - setError(e.message); + setError(describeError(e)); } finally { setBusy(""); } } useEffect(() => { - api("/tasks") + api + .listTasks() .then((ts) => { setTasks(ts); if (ts[0]) { @@ -109,13 +106,14 @@ function App() { setEditor(JSON.stringify(ts[0].contract, null, 2)); } }) - .catch((e) => setError(e.message)); + .catch((e) => setError(describeError(e))); }, []); useEffect(() => { if (screen === 4 && task) - api(`/tasks/${task.id}/audit`) + api + .getAudit(task.id) .then(setAudit) - .catch((e) => setError(e.message)); + .catch((e) => setError(describeError(e))); }, [screen, task]); useEffect(() => { window.scrollTo({ top: 0, behavior: "instant" }); @@ -126,7 +124,7 @@ function App() { }; async function generate() { await work("Drafting contract", async () => { - let t = update(await api("/tasks", { task: text, mode })); + let t = update(await api.createTask(text, mode)); setEditor(JSON.stringify(t.contract, null, 2)); setScreen(1); }); @@ -134,13 +132,13 @@ function App() { async function evaluate(variant = "poisoned") { await work("Preparing Terraform edits", async () => { let id = task.id; - update(await api(`/tasks/${id}/agent?variant=${variant}`, {})); + update(await api.runAgent(id, variant)); setBusy("Running Terraform plan"); - update(await api(`/tasks/${id}/plan`, {})); + update(await api.plan(id)); setBusy("Canonicalizing changes"); - update(await api(`/tasks/${id}/canonicalize`, {})); + update(await api.canonicalize(id)); setBusy("Evaluating Cedar policies"); - update(await api(`/tasks/${id}/evaluate`, {})); + update(await api.evaluate(id)); setScreen(2); setDecisions({}); }); @@ -389,10 +387,7 @@ function App() { onClick={() => work("Confirming", async () => { update( - await api( - `/tasks/${task.id}/confirm`, - JSON.parse(editor), - ), + await api.confirm(task.id, JSON.parse(editor)), ); setEditor( JSON.stringify( @@ -517,6 +512,17 @@ function App() { ) : (
+ {evalErrors > 0 && ( +
+ + + {evalErrors} resource + {evalErrors === 1 ? "" : "s"} could not be evaluated + (technical failure, not a REVIEW). These cannot be + approved; repair evaluation and run a fresh plan. + +
+ )} {verdicts.map((v, i) => (
work("Checking server gate", async () => - update(await api(`/tasks/${task.id}/apply`, {})), + update(await api.apply(task.id)), ) } > @@ -658,7 +664,7 @@ function App() { onClick={() => work("Recording decisions", async () => { update( - await api(`/tasks/${task.id}/resolve`, decisions), + await api.resolve(task.id, decisions), ); setDecisions({}); }) diff --git a/web/src/styles.css b/web/src/styles.css index be39689..4dbbf88 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -499,6 +499,12 @@ select { border-color: #ff4d4f30; background: #ff4d4f0a; } +.badge.evaluation_error { + color: var(--deny); + border-color: #ff4d4f60; + border-style: dashed; + background: #ff4d4f14; +} .task-strip { display: flex; align-items: center; @@ -625,6 +631,9 @@ select { .verdict-row.review { --verdict: var(--review); } +.verdict-row.evaluation_error { + --verdict: var(--deny); +} .verdict-row.deny { --verdict: var(--deny); animation: diff --git a/web/vite.config.js b/web/vite.config.js index c373afe..0d44e51 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,7 +1,18 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; + +// Development: Vite serves the UI on 127.0.0.1:5173 and proxies the API to +// FastAPI on 127.0.0.1:8000. Override the target with PLANREVIEW_API_URL. +const api = process.env.PLANREVIEW_API_URL || "http://127.0.0.1:8000"; + export default defineConfig({ plugins: [react(), tailwindcss()], - server: { proxy: { "/api": "http://127.0.0.1:8000" } }, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + proxy: { "/api": api, "/docs": api, "/openapi.json": api }, + }, + test: { environment: "node", include: ["src/**/*.test.js"] }, }); From c3c2c55dca20be7b305a54fe756406f1ad375065 Mon Sep 17 00:00:00 2001 From: Afnan Date: Sun, 20 Sep 2026 03:26:19 +0530 Subject: [PATCH 2/2] Phase 2 review: add real-process smoke script, OpenAPI test, observed API examples Gaps found by comparing with the separate C:\Dev attempt (read-only): - tools/test_phase2_smoke.py: separate FastAPI + Vite process smoke test - tests/test_api.py: OpenAPI schema lists task endpoints - docs/api_contract.md: observed real error/gate examples - README: document the smoke script Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + docs/api_contract.md | 20 +++++++++ tests/test_api.py | 7 ++++ tools/test_phase2_smoke.py | 83 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 tools/test_phase2_smoke.py diff --git a/README.md b/README.md index 1b4ab2f..9b9df41 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ On Linux/macOS use `.venv/bin/python -m uvicorn engine.api:app --host 127.0.0.1 ```powershell .\.venv\Scripts\python.exe -m pytest -q # backend (slow: real Terraform) cd web; npm test # frontend API-client tests (mocked fetch) +.\.venv\Scripts\python.exe tools\test_phase2_smoke.py # real FastAPI + Vite processes and /api proxy (ports 8000/5173 must be free) .\run-demo.ps1 # Reproducible automation; approvals are marked as scripted demo actions: .\run-demo.ps1 -Scripted diff --git a/docs/api_contract.md b/docs/api_contract.md index f1fa34d..82666a3 100644 --- a/docs/api_contract.md +++ b/docs/api_contract.md @@ -121,3 +121,23 @@ Anything else (RDS, IAM, security groups, networking, deletes, compound requests - No endpoint to read Cedar policy text or per-policy explanations beyond `determining_policies` ids and `reason`. - No resolver identity on `resolutions`; no evidence export endpoint (audit list only). - No endpoint listing supported operations (the capability registry is only visible through errors). + +## Observed examples + +Unsupported request (`POST /api/tasks`, HTTP 400): + +```json +{"detail":{"error":"UNSUPPORTED_OPERATION","message":"The requested operation or service is outside genuinely supported capabilities. Matched unsupported pattern: \brds\b. Genuine supported operations: update_memory, update_tags.","details":{"task":"Delete the production RDS database and open port 22 to the world","pattern":"\brds\b"}}} +``` + +Live-model proposal rejected by the deterministic validator (`mode:"ollama"`, HTTP 400). The model returned a free-text resource name; the capability registry accepts only the canonical address: + +```json +{"detail":{"error":"UNSUPPORTED_OPERATION","message":"Resource 'dev-api-Lambda' is not supported for operation 'update_memory'. Permitted resource: 'aws_lambda_function.dev_api'.","details":{"proposal":{"operation":"update_memory","resource_address":"dev-api-Lambda","attribute":"memory_size","requested_value":512}}}} +``` + +Empty task (HTTP 422): `{"detail":[{"type":"string_too_short","loc":["body","task"],"msg":"String should have at least 1 character"}]}` + +Apply blocked by the gate (HTTP 200; `run.apply_result`): `{"status":"BLOCKED","reason":"2 unresolved DENY; edit configuration and create a new plan","spawned":false}` + +Model unavailable and evaluation-error bodies follow the structured/`verdict:"EVALUATION_ERROR"` shapes above; they are exercised by mocked tests in `tests/test_phase1.py`, not captured from a live run. diff --git a/tests/test_api.py b/tests/test_api.py index 3df6a0d..3da8d3c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -78,3 +78,10 @@ def test_vite_origin_allowed_for_mutation(tmp_path, monkeypatch): headers={"Origin": "http://127.0.0.1:5173"}, ) assert r.status_code == 200 + + +def test_openapi_lists_task_endpoints(): + schema = TestClient(api.app).get("/openapi.json").json() + assert schema["info"]["title"] == "PlanReview" + for path in ["/api/health", "/api/tasks", "/api/tasks/{id}/apply", "/api/tasks/{id}/audit"]: + assert path in schema["paths"], path diff --git a/tools/test_phase2_smoke.py b/tools/test_phase2_smoke.py new file mode 100644 index 0000000..50a1638 --- /dev/null +++ b/tools/test_phase2_smoke.py @@ -0,0 +1,83 @@ +"""Separate-process smoke test: FastAPI (8000) + Vite (5173) + /api proxy. + +Run from the repo root with the ports free: python tools/test_phase2_smoke.py (venv python) +Real processes and a real Terraform-free request path only (task creation); +it does not run Terraform, Ollama or AWS. +""" + +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NPM = "npm.cmd" if sys.platform == "win32" else "npm" + + +def req(url, data=None, timeout=15): + r = urllib.request.Request( + url, + data=None if data is None else json.dumps(data).encode(), + headers={"Content-Type": "application/json"}, + method="GET" if data is None else "POST", + ) + try: + with urllib.request.urlopen(r, timeout=timeout) as x: + return x.status, x.headers.get("content-type", ""), x.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.headers.get("content-type", ""), e.read().decode() + + +def wait(url, tries=60): + for _ in range(tries): + try: + if req(url)[0] == 200: + return + except Exception: + pass + time.sleep(0.5) + raise TimeoutError(url) + + +def check(name, cond): + print(("PASS " if cond else "FAIL ") + name) + if not cond: + raise SystemExit(1) + + +def main(): + procs = [] + try: + procs.append(subprocess.Popen( + [str(ROOT / ".venv/Scripts/python.exe"), "-m", "uvicorn", "engine.api:app", + "--host", "127.0.0.1", "--port", "8000"], + cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + wait("http://127.0.0.1:8000/api/health") + procs.append(subprocess.Popen([NPM, "run", "dev"], cwd=ROOT / "web", + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + wait("http://127.0.0.1:5173/") + for base in ["http://127.0.0.1:8000", "http://127.0.0.1:5173"]: + s, _, b = req(base + "/api/health") + check(f"{base}/api/health", s == 200 and json.loads(b)["cloud_apply"] is False) + s, ct, b = req(base + "/api/nope") + check(f"{base}/api/nope is JSON 404", s == 404 and "json" in ct and " structured error via proxy", + s == 400 and json.loads(b)["detail"]["error"] == "UNSUPPORTED_OPERATION") + s, _, b = req("http://127.0.0.1:5173/api/tasks", {"task": "Increase memory for dev-api Lambda"}) + t = json.loads(b) + check("supported task created via proxy", s == 200 and t["stage"] == "draft") + print("Smoke test passed.") + finally: + for p in procs: + subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True) \ + if sys.platform == "win32" else p.terminate() + + +if __name__ == "__main__": + main()