diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8f17501 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.sh text eol=lf +*.png binary +*.jpg binary diff --git a/Makefile b/Makefile index d2f494a..7f1b3fd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down logs test seed reset lint +.PHONY: up down logs test lint seed reset n8n-setup demo e2e up: docker compose up --build -d @@ -21,3 +21,19 @@ seed: reset: docker compose down -v docker compose up --build -d + +# One-time per environment: imports and activates the n8n return-processing workflow. +# The n8n owner account itself cannot be scripted safely and must be created once at +# http://localhost:5678/setup (any email/password, no verification required) before +# this target's activation takes effect. See docs/17-runbook.md. +n8n-setup: + docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json + docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing + docker compose restart n8n + +# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed. +demo: up + docker compose exec api python -m app.cli seed --reset + +e2e: + cd frontend && npx playwright test diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c5e85ae..59d8a97 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,7 +2,7 @@ ## Current milestone -M6 — complete. Starting M7 next. +M7 — complete. All milestones (M0–M7) done. See `artifacts/evidence/final-summary.md` for the acceptance evidence. ## Locked decisions @@ -126,4 +126,20 @@ None. External service credentials may be absent; use the documented demo/degrad ## Exact next action -Start M7 (portfolio polish and final acceptance): read `docs/06-ui-ux.md`, `docs/14-testing-and-acceptance.md`, `docs/16-portfolio-case-study.md`. Work through `docs/14-testing-and-acceptance.md`'s clean-checkout acceptance list item by item from a genuinely fresh checkout (new clone, `cp .env.example .env`, documented bootstrap commands only) rather than this session's already-running/already-seeded stack — several manual one-time steps accumulated this session (n8n owner setup + workflow import/publish/restart, see M4 notes) that must either be scripted into the bootstrap or written up precisely in `docs/17-runbook.md`, since a true clean checkout won't have them done yet. Also needed: Playwright end-to-end test covering the full documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S4 overlap already-visible → S6 knowledge question → audit → responsive nav at 360px — the ≤360px layout was flagged as visually unverified back in M1 and should finally be checked here), screenshots of the seven main pages, an architecture diagram, `artifacts/evidence/final-summary.md` per the doc's exact required contents (commit/tag, commands, test counts — currently 66 backend tests across M0–M6 — screenshots, RAGcore success/unavailable evidence, n8n success/retry evidence, MCP sample calls, known limitations, truthful portfolio wording), and a final pass removing any rough edges/placeholders across the UI. +### M7 — Portfolio polish and final acceptance +- **Automated clean-checkout migrations**: `backend/entrypoint.sh` now runs `alembic upgrade head` before starting uvicorn (Dockerfile `CMD` changed from `uvicorn ...` to `./entrypoint.sh`). Verified with a true `docker compose down -v` (all volumes wiped) → `docker compose up --build -d` → all 11 tables present, `/health` and web both green, all 66 backend tests pass, with zero manual migration step. +- **n8n one-time setup scripted where it can be**: `make n8n-setup` runs the import/publish/restart sequence (previously three manual commands discovered ad hoc in M4). The owner-account creation itself cannot be scripted safely (it's an interactive one-time step in n8n 2.x's own onboarding, not a MobilityOps concern) — documented precisely in the rewritten `docs/17-runbook.md`, including the exact URL and that no email verification is required. Re-ran this full sequence from the wiped-volumes state this session and confirmed the S1 return → outbox → live n8n → callback → `succeeded` round trip works on a genuinely clean checkout, not just the already-provisioned stack from M0–M6. +- **Playwright E2E** (`frontend/e2e/demo.spec.ts`, `frontend/playwright.config.ts`): one test automating the full 9-step documented demo script end-to-end against the live stack — login, dashboard metrics, open `BK-DEMO-RETURN`, register an odometer-regression return (S1), verify the quality issue + queued automation event, merge the duplicate-customer scenario (S2), ask the damage question and verify both expected source citations (S6), inspect audit entries, and verify responsive nav + no horizontal overflow at 360px width. **Passing.** This also resolves the "≤360px layout visually unverified" gap flagged back in M1 — verified both by this test's overflow assertion and by the `9-mobile-dashboard.png` screenshot (nav wraps into rows, metric tiles collapse to a 2-column grid, no horizontal scroll). +- Added `frontend/e2e/_capture-screenshots.spec.ts` as evidence-generation tooling (underscore-prefixed, excluded from the default `playwright test` / `make e2e` run via `testIgnore` in the config — it calls `demo/reset`, which a real regression test shouldn't do as a side effect). Captured all 9 screenshots into `artifacts/evidence/screenshots/`. +- Wrote `artifacts/evidence/architecture.md` (mermaid, as-built — distinguishes verified-live components from implemented-but-never-reached-a-real-instance ones, i.e. RAGcore and the MCP Hub) and `artifacts/evidence/final-summary.md` (commit, exact commands, test counts, screenshot index, RAGcore success/unavailable evidence — including a live-demonstrated unavailable case against an unreachable host, not just the unit test — n8n success/retry evidence, MCP sample calls, known limitations, truthful portfolio wording per `docs/16-portfolio-case-study.md`'s template). +- Updated `README.md` (dropped stale "minimal bootable scaffold, not the finished application" wording and the old two-line quickstart in favor of `make demo` + a pointer to the runbook) and `docs/17-runbook.md` (full rewrite: exact bootstrap, n8n one-time setup, verification commands, required operational checks, recovery expectations). +- Final placeholder/dead-UI sweep: `grep`'d the full `frontend/src` and `backend/app` trees for scaffold/TODO/FIXME/"must be replaced" markers — none found. `FILE_INDEX.md` was left as-is; it's the original build-pack's archive-completeness manifest (a historical snapshot), not a living index that needs to track every file added since — updating it would misrepresent what it's for. +- Commands run and verified from this checkout (this milestone, cumulative across the whole build): + - `docker compose run --rm api pytest -q` — **66 passed**, ruff clean. + - `cd frontend && npm run build` — clean. + - `cd frontend && npx playwright test` — **1 passed** (full demo script, live stack). + - Full clean-checkout drill: `docker compose down -v` → `docker compose up --build -d` → `docker compose exec api python -m app.cli seed --reset` → `docker compose run --rm api pytest -q` (66 passed) → n8n owner setup + `make n8n-setup` → live S1 return round-tripped through the real n8n instance to `succeeded`. + +## Definition of done + +All eight milestones (M0–M7) are complete. `docs/14-testing-and-acceptance.md`'s clean-checkout acceptance list has been walked item by item against a genuinely wiped-volumes checkout this session (see M7 notes above), and `artifacts/evidence/final-summary.md` contains the final evidence summary this file's own instructions (`CLAUDE.md`) require. The two items not fully closed — a live RAGcore instance and a live ITWorx MCP Hub instance — were never reachable in this environment; both integrations are implemented, unit/contract-tested, and documented as such rather than claimed as verified. diff --git a/README.md b/README.md index f85cae6..458e787 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,25 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o - `knowledge/` — fictitious source documents for the MobilityOps RAGcore workspace. - `seed/` — deterministic synthetic dataset and generator. - `n8n/` — importable workflow definitions. -- `backend/` and `frontend/` — minimal bootable scaffold, not the finished application. +- `backend/` — FastAPI/SQLAlchemy/Alembic API. +- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`). +- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`). -## Local scaffold +## Quickstart ```bash cp .env.example .env -docker compose up --build +make demo ``` -Expected scaffold endpoints: +This builds and starts the full stack (migrations run automatically) and loads the +deterministic demo dataset. See `docs/17-runbook.md` for the one-time n8n workflow setup +required for the automation demo, and the full operational runbook. + +Endpoints: - Web: `http://localhost:1228` - API health: `http://localhost:8128/health` - n8n: `http://localhost:5678` -The finished project must keep these defaults configurable. +All defaults are configurable via `.env` (see `.env.example`). diff --git a/artifacts/evidence/architecture.md b/artifacts/evidence/architecture.md new file mode 100644 index 0000000..398ae32 --- /dev/null +++ b/artifacts/evidence/architecture.md @@ -0,0 +1,63 @@ +# MobilityOps — as-built architecture + +```mermaid +flowchart TB + subgraph Browser + UI["MobilityOps Web
React + TypeScript"] + end + + subgraph MobilityOps["MobilityOps (this repo)"] + API["FastAPI backend
/api/v1/*"] + DISPATCH["Outbox dispatcher
background thread"] + DB[(PostgreSQL)] + API --> DB + DISPATCH --> DB + end + + subgraph External["External central services"] + N8N["n8n
return-processing workflow"] + RAGDEMO["Demo KnowledgeProvider
TF-IDF extractive, local files"] + RAGCORE["RAGcore
(adapter built, no live instance)"] + HUB["ITWorx MCP Hub
(endpoints built, no live instance)"] + end + + UI -->|session cookie| API + API -->|GroundedAnswer| RAGDEMO + API -.->|configurable, unavailable-safe| RAGCORE + DISPATCH -->|POST vehicle.returned.v1| N8N + N8N -->|callback, X-Service-Token| API + HUB -.->|X-Service-Token, read-only| API + + classDef unverified stroke-dasharray: 5 5; + class RAGCORE,HUB unverified; +``` + +Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised +against a live instance in this environment (no reachable RAGcore or ITWorx MCP Hub). +Solid boxes were verified end-to-end, including a real n8n instance. + +## Component responsibility (unchanged from `docs/03-architecture.md`) + +| Component | Owns | +|---|---| +| MobilityOps | vehicles, customers, bookings, inspections, data-quality issues, audit, outbox/delivery state | +| RAGcore | procedure retrieval and grounded answers (demo provider substitutes locally) | +| ITWorx MCP Hub | MCP transport, tool publication, central tool-call audit | +| n8n | post-commit secondary orchestration only — never the source of truth for vehicle state | + +## Reliability boundaries verified in this build + +1. **Return commits atomically with its outbox event** — `app/services/returns.py`, one + transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row + locking, not mocked). +2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n + callback checks for an existing `AuditEvent` by event ID before recording a second time. +3. **RAGcore failure disables knowledge answers only** — `RAGcoreKnowledgeProvider` degrades + to `unavailable`; the rest of the app is unaffected because the knowledge router is the + only consumer. +4. **MCP Hub failure does not affect the web application** — the four MCP provider + endpoints are a separate authenticated surface (`X-Service-Token`), invisible to the + browser-facing API/UI. +5. **n8n failure leaves events pending with bounded retries** — verified live: a seeded + `failed` event, retried through the UI, was picked up by the background dispatcher and + delivered through the real n8n instance within one poll cycle. diff --git a/artifacts/evidence/final-summary.md b/artifacts/evidence/final-summary.md new file mode 100644 index 0000000..e303d6e --- /dev/null +++ b/artifacts/evidence/final-summary.md @@ -0,0 +1,177 @@ +# MobilityOps — final acceptance evidence + +## Commit + +Built on top of commit `c5b7e21f81694f0339ad31e3bf044db952d0fbe0` (M6, "implement ITWorx +MCP Hub publication"). This evidence file and the rest of M7's polish are committed as +`M7: portfolio polish and final acceptance` — run `git log --oneline` for the exact hash. + +## Exact commands (clean checkout) + +```bash +git clone && cd MobilityOps +cp .env.example .env +make demo # docker compose up --build -d ; migrations run automatically ; seed --reset +``` + +One-time n8n setup (see `docs/17-runbook.md` for full detail — this cannot be scripted +end-to-end because it requires a one-time owner account created through n8n's web UI): + +```bash +# open http://localhost:5678/setup in a browser, create any owner account +make n8n-setup +``` + +Verification: + +```bash +docker compose run --rm api pytest -q # 66 passed +docker compose run --rm api ruff check . # All checks passed +cd frontend && npm run build # clean tsc + vite build +cd frontend && npx playwright test # 1 passed (full 5-minute demo script) +``` + +## Test counts + +- **Backend**: 66 tests passing (`pytest`), 0 skipped, 0 failed. Ruff clean. Coverage by + area: seed determinism (2), auth/roles (4), dashboard (3), vehicles (4), bookings (3), + return workflow incl. real concurrent-submission test (9), data quality incl. S2/S4 + scenarios (10), audit (2), n8n dispatcher incl. malformed-payload regression (6), + n8n callback idempotency (3), workflows/retry (4), knowledge incl. S6 scenario (7), + MCP provider endpoints (8), health (1). +- **Frontend**: `npm run build` — clean TypeScript + Vite build, zero errors. +- **End-to-end**: 1 Playwright test (`frontend/e2e/demo.spec.ts`) automating the full + documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S6 knowledge + question → audit → 360px responsive check) — **passing** against the live stack. + +## Screenshots of the seven main pages + +Captured live against the deterministic seed (`artifacts/evidence/screenshots/`, +via `frontend/e2e/_capture-screenshots.spec.ts`): + +| # | Page | File | +|---|---|---| +| 1 | Login | `1-login.png` | +| 2 | Dashboard | `2-dashboard.png` | +| 3 | Vehicles | `3-vehicles.png` | +| 4 | Bookings | `4-bookings.png` | +| 5 | Data Quality | `5-data-quality.png` | +| 6 | Knowledge (grounded S6 answer) | `6-knowledge.png` | +| 7 | Automation | `7-automation.png` | +| — | Audit (bonus, 8th nav item) | `8-audit.png` | +| — | Dashboard at 360px (responsive proof) | `9-mobile-dashboard.png` | + +## RAGcore evidence + +**Success (demo provider, the one actually satisfying acceptance in this environment)** — +S6 question against the real `/api/v1/knowledge/questions` endpoint: + +```json +{ + "answer": "Per \"Vehicle return procedure\" (v2.0), section \"1. Register the return\": Open the active booking and record the ending odometer, fuel level, cleanliness, visible damage, technical warnings and relevant notes.", + "evidence_state": "grounded", + "sources": [ + {"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "1. Register the return", "excerpt": "..."}, + {"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "3. Determine next state", "excerpt": "..."}, + {"document_id": "damage-procedure", "title": "Damage handling procedure", "version": "1.3", "section": "1. Immediate actions", "excerpt": "..."} + ], + "provider": "demo", + "correlation_id": "b50094b7-1c84-4e39-9055-1dc03e8fd1f8" +} +``` + +**Unavailable (RAGcore adapter, live-demonstrated against an unreachable host)** — +`KNOWLEDGE_PROVIDER=ragcore`, `RAGCORE_BASE_URL=http://ragcore-not-reachable:9999`: + +``` +health: {'provider': 'ragcore', 'available': False, 'detail': 'RAGcore unavailable: ConnectError: ...', 'document_count': 0} +ask: {'answer': '', 'evidence_state': 'unavailable', 'sources': [], 'provider': 'ragcore', 'correlation_id': 'demo-correlation'} +``` + +No live RAGcore instance was reachable in this environment, so the adapter's actual +request/response contract against a real RAGcore is unverified beyond this +degrade-safely behavior — see `contracts/ragcore-contract-assumptions.md` and +`PROJECT_STATE.md`'s M5 notes. + +## n8n evidence + +**Success** — a real return registered on `BK-DEMO-RETURN`, delivered through the actual +n8n instance (not mocked), confirmed via `GET /api/v1/workflows`: + +```json +{"event_id": "aa5dfeee-90ca-452a-bdd1-0a0b6d3dd63f", "event_type": "vehicle.returned.v1", "aggregate_ref": "BK-DEMO-RETURN", "status": "succeeded", "attempts": 2, "last_error": null} +``` + +(`attempts: 2` because the first delivery attempt landed while n8n was mid-restart from +the one-time workflow-activation step — the dispatcher's backoff-and-retry handled it +without any manual intervention, which is itself evidence of the retry behavior working.) + +**Retry (S5 scenario)** — seeded `BK-H-0020` (event `00000000-...-0020`), initially +`failed` after 3 attempts with `"Synthetic connection timeout to n8n"`: + +1. Before: `{"status": "failed", "attempts": 3, "last_error": "Synthetic connection timeout to n8n"}` +2. Operations Manager clicks Retry on `/automation`. +3. Within one ~3s dispatcher poll cycle, delivered through the live n8n instance. +4. After: `{"status": "succeeded", "attempts": 4, "last_error": null}` + +## MCP tool sample calls + +All four provider endpoints, authenticated with `X-Service-Token`: + +``` +$ curl -H "X-Service-Token: " http://localhost:8128/api/v1/integrations/mcp/operations-summary +{"tenant":"northstar-mobility-demo","metrics":{"available":21,"rented":11,"cleaning":6,"maintenance":5,"blocked":7,"open_quality_issues":22,"pending_or_failed_workflows":1}} + +$ curl -H "X-Service-Token: " "http://localhost:8128/api/v1/integrations/mcp/attention-vehicles?minimum_severity=high&limit=3" +[{"vehicle_ref":"MO-016","severity":"high","rule_type":"booking_overlap",...}, + {"vehicle_ref":"MO-016","severity":"high","rule_type":"vehicle_status_conflict",...}, + {"vehicle_ref":"MO-031","severity":"high","rule_type":"missing_required_field",...}] + +$ curl -H "X-Service-Token: " http://localhost:8128/api/v1/integrations/mcp/vehicles/MO-016 +{"public_ref":"MO-016","make":"Hymer","model":"Exsis","model_year":2021,"location":"Geel","operational_status":"available","odometer_km":30497,"next_service_km":40000,"open_quality_issue_count":2,"current_booking_ref":null} + +$ curl -H "X-Service-Token: " -X POST -d '{"question":"What must I do when a vehicle returns with damage?","max_sources":2}' http://localhost:8128/api/v1/integrations/mcp/search-knowledge +{"answer":"Per \"Vehicle return procedure\" ...","evidence_state":"grounded","sources":[...2 items...],"provider":"demo",...} +``` + +Auth verified: missing header → `422`; wrong token → `401`. All four calls confirmed +recorded in `GET /api/v1/audit?action=mcp_tool_request` with `actor_type: "service"`. + +No live ITWorx MCP Hub instance was reachable in this environment — these are direct +calls to MobilityOps's own provider endpoints, not a Hub round trip. + +## Known PoC limitations + +- **RAGcore and ITWorx MCP Hub were never reachable in this build environment.** Both + integrations are implemented against best-effort/documented contracts and are + unit/contract-tested (including their failure-degradation paths), but neither was + verified against a real counterpart service. The demo `KnowledgeProvider` is what + actually satisfies the knowledge-assistant acceptance criteria here. +- **n8n requires a one-time manual owner-account setup** per fresh environment + (`docker compose down -v` wipes it) — this is a property of the n8n 2.x image itself + (`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass. + Documented precisely in `docs/17-runbook.md`; the workflow import/activation itself + *is* scripted (`make n8n-setup`). +- **Inspection public refs are a simple `count+1` sequence**, not gap-safe under true + concurrent writers — acceptable for this single-tenant demo, would need a DB sequence + for a multi-writer production system. +- **The five data-quality rules use simplified idempotency** — `(rule_type, entity_type, + entity_id)` while open, rather than the doc's literal evidence-fingerprint scheme — see + `PROJECT_STATE.md`'s M3 notes for the reasoning (the fingerprint scheme would have let + the scan double-report issues already present in the seeded CSV). +- **No production authentication** — demo login is an HMAC-signed session cookie tied to + two fixed seeded users, appropriate for a PoC, not a real identity provider. + +## Portfolio wording (truthful) + +MobilityOps is a working proof of concept, not a production system and not deployed for +any real company. All customers, vehicles, bookings, and documents are synthetic +(deterministically generated). The application logic it demonstrates is real: a +transactional vehicle-return workflow with idempotency and concurrency control tested +against real concurrent database transactions; five explainable, deterministic +data-quality rules with a working customer-merge UI; a background outbox dispatcher +verified end-to-end against a real n8n instance including failure/retry; a +TF-IDF-weighted extractive knowledge assistant that never fabricates answers; and four +read-only, audited, service-authenticated integration endpoints. RAGcore and the ITWorx +MCP Hub integrations are implemented and tested in isolation but were not verified +against live instances of those systems in this environment. diff --git a/artifacts/evidence/screenshots/1-login.png b/artifacts/evidence/screenshots/1-login.png new file mode 100644 index 0000000..c513487 Binary files /dev/null and b/artifacts/evidence/screenshots/1-login.png differ diff --git a/artifacts/evidence/screenshots/2-dashboard.png b/artifacts/evidence/screenshots/2-dashboard.png new file mode 100644 index 0000000..5564249 Binary files /dev/null and b/artifacts/evidence/screenshots/2-dashboard.png differ diff --git a/artifacts/evidence/screenshots/3-vehicles.png b/artifacts/evidence/screenshots/3-vehicles.png new file mode 100644 index 0000000..139b528 Binary files /dev/null and b/artifacts/evidence/screenshots/3-vehicles.png differ diff --git a/artifacts/evidence/screenshots/4-bookings.png b/artifacts/evidence/screenshots/4-bookings.png new file mode 100644 index 0000000..ed9f64f Binary files /dev/null and b/artifacts/evidence/screenshots/4-bookings.png differ diff --git a/artifacts/evidence/screenshots/5-data-quality.png b/artifacts/evidence/screenshots/5-data-quality.png new file mode 100644 index 0000000..ad9b91b Binary files /dev/null and b/artifacts/evidence/screenshots/5-data-quality.png differ diff --git a/artifacts/evidence/screenshots/6-knowledge.png b/artifacts/evidence/screenshots/6-knowledge.png new file mode 100644 index 0000000..1439951 Binary files /dev/null and b/artifacts/evidence/screenshots/6-knowledge.png differ diff --git a/artifacts/evidence/screenshots/7-automation.png b/artifacts/evidence/screenshots/7-automation.png new file mode 100644 index 0000000..7a96da8 Binary files /dev/null and b/artifacts/evidence/screenshots/7-automation.png differ diff --git a/artifacts/evidence/screenshots/8-audit.png b/artifacts/evidence/screenshots/8-audit.png new file mode 100644 index 0000000..786431b Binary files /dev/null and b/artifacts/evidence/screenshots/8-audit.png differ diff --git a/artifacts/evidence/screenshots/9-mobile-dashboard.png b/artifacts/evidence/screenshots/9-mobile-dashboard.png new file mode 100644 index 0000000..49840d9 Binary files /dev/null and b/artifacts/evidence/screenshots/9-mobile-dashboard.png differ diff --git a/backend/Dockerfile b/backend/Dockerfile index 2209ef8..a50ba70 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,7 @@ COPY backend/alembic.ini ./ COPY backend/tests ./tests COPY seed ./seed COPY knowledge ./knowledge -RUN pip install --no-cache-dir --no-deps -e . +COPY backend/entrypoint.sh ./entrypoint.sh +RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["./entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..a722165 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +alembic upgrade head +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/docs/17-runbook.md b/docs/17-runbook.md index 2785a7d..f305116 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -1,28 +1,87 @@ # PoC runbook -## Bootstrap +## Bootstrap (clean checkout) + +```bash +cp .env.example .env +make demo +``` + +`make demo` runs `docker compose up --build -d` (migrations run automatically on API +container startup, see `backend/entrypoint.sh`) and then seeds the deterministic dataset. +Equivalently, without `make`: ```bash cp .env.example .env docker compose up --build -d +docker compose exec api python -m app.cli seed --reset ``` -Claude must replace this scaffold runbook with exact migration, seed, test and integration commands after implementation. +Verify: + +```bash +curl http://localhost:8128/health # {"status":"ok",...} +curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200 +docker compose run --rm api pytest -q # all tests pass +docker compose run --rm api ruff check . # clean +``` + +## n8n automation (one-time per environment) + +The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any +workflow — including webhook registration — works reliably; `N8N_BASIC_AUTH_ACTIVE` no +longer gates this. This is a one-time step per fresh `docker compose down -v`: + +1. Open `http://localhost:5678/setup` and create an owner account (any email/password + meeting the 8+ characters / 1 number / 1 capital rule — no email verification is + required). Skip the optional survey/license-key dialogs that follow. +2. Import and activate the return-processing workflow: + + ```bash + make n8n-setup + ``` + + which runs: + + ```bash + docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json + docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing + docker compose restart n8n + ``` + + (`n8n import:workflow` always leaves the workflow deactivated regardless of its + `"active"` field; `publish:workflow` + a restart is what actually activates it.) + +Verify the full round trip: + +```bash +# after logging in and registering any return via the UI or API +curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded +``` + +A failed/offline n8n does not roll back the return — the outbox event simply stays +`pending`/`failed` and is safely retryable from the Automation page. ## Required operational checks -- API and web health; -- database migration level; -- pending/failed outbox count; -- RAGcore provider state; -- n8n connectivity; -- MCP provider endpoint authorization; -- deterministic demo reset. +- API and web health (`GET /health`, web root `200`); +- database migration level (`docker compose exec api alembic current`); +- pending/failed outbox count (Automation page, or `GET /api/v1/workflows?status=failed`); +- RAGcore provider state (`GET /api/v1/knowledge/status`; demo provider is always + `available`, RAGcore adapter reports `unavailable` when unreachable); +- n8n connectivity (`docker compose logs n8n`, or submit a return and watch `/automation`); +- MCP provider endpoint authorization (`curl` the four `/api/v1/integrations/mcp/*` + routes with and without a valid `X-Service-Token` — see + `artifacts/evidence/final-summary.md` for sample calls); +- deterministic demo reset (`POST /api/v1/demo/reset` as Operations Manager, or `make seed`). ## Recovery expectations -- database restart: application reconnects; -- n8n outage: events remain pending and retryable; -- RAGcore outage: knowledge shows unavailable, operations continue; -- MCP Hub outage: web application unaffected; -- failed demo experiment: Operations Manager reset restores the seed. +- database restart: application reconnects (SQLAlchemy connection pool, `pool_pre_ping=True`); +- n8n outage: events remain `pending` and are retried with exponential backoff, then + `failed` after 5 attempts and safely retryable from `/automation`; +- RAGcore outage: `/knowledge` shows `unavailable`, all operational pages continue working; +- MCP Hub outage: the web application is unaffected — MCP endpoints are a separate, + independently-authenticated API surface; +- failed demo experiment: Operations Manager reset (`POST /api/v1/demo/reset`) restores + the deterministic seed, including all named S1–S6 demo scenarios. diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts new file mode 100644 index 0000000..8e62da6 --- /dev/null +++ b/frontend/e2e/_capture-screenshots.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +// One-off tooling to capture evidence screenshots for artifacts/evidence/final-summary.md. +// Not part of the regular test suite (prefixed with `_` and excluded from CI runs). + +const OUT = "../artifacts/evidence/screenshots"; + +test("capture the seven main pages", async ({ page, request }) => { + await request.post("http://localhost:8128/api/v1/demo/login", { + data: { role: "operations_manager" }, + }); + await request.post("http://localhost:8128/api/v1/demo/reset"); + + await page.goto("/login"); + await page.screenshot({ path: `${OUT}/1-login.png` }); + + await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); + await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); + + await page.goto("/vehicles"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/3-vehicles.png`, fullPage: true }); + + await page.goto("/bookings"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/4-bookings.png`, fullPage: true }); + + await page.goto("/data-quality"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/5-data-quality.png`, fullPage: true }); + + await page.goto("/knowledge"); + await page + .getByPlaceholder(/What must I do when a vehicle returns with damage/) + .fill("What must I do when a vehicle returns with damage?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await page.screenshot({ path: `${OUT}/6-knowledge.png`, fullPage: true }); + + await page.goto("/automation"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/7-automation.png`, fullPage: true }); + + await page.goto("/audit"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/8-audit.png`, fullPage: true }); + + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); + await page.screenshot({ path: `${OUT}/9-mobile-dashboard.png` }); +}); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts new file mode 100644 index 0000000..fcb31ca --- /dev/null +++ b/frontend/e2e/demo.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +const API_BASE = process.env.MOBILITYOPS_API_URL ?? "http://localhost:8128"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post(`${API_BASE}/api/v1/demo/login`, { + data: { role: "operations_manager" }, + }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post(`${API_BASE}/api/v1/demo/reset`); + expect(reset.ok()).toBeTruthy(); +} + +test.describe.configure({ mode: "serial" }); + +test("five-minute demo script end to end", async ({ page, request }) => { + // Reset via a throwaway API session so the UI test starts from the deterministic seed + // regardless of what earlier test runs mutated (S1 return, S2 merge, etc.). + await resetDemoData(request); + + await test.step("1. login as Operations Manager", async () => { + await page.goto("/login"); + await expect(page.getByText("Synthetic demo environment")).toBeVisible(); + await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + }); + + await test.step("2. verify dashboard metrics are loaded", async () => { + await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); + const metricValues = page.locator(".metric-value"); + await expect(metricValues.first()).toBeVisible(); + const values = await metricValues.allTextContents(); + expect(values.length).toBeGreaterThan(0); + expect(values.some((v) => Number(v) > 0)).toBeTruthy(); + }); + + await test.step("3. open active demo booking", async () => { + await page.goto("/bookings/BK-DEMO-RETURN"); + await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); + await expect(page.getByText("active", { exact: true })).toBeVisible(); + }); + + await test.step("4. register an odometer-regression return (S1)", async () => { + const vehicleOdometerText = await page + .locator(".detail-grid div", { hasText: "Start odometer" }) + .locator("dd") + .textContent(); + const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10); + const lowReading = Math.max(0, startOdometer - 500); + + await page.getByLabel("End odometer (km)").fill(String(lowReading)); + await page.getByLabel("Fuel level (%)").fill("55"); + await page.getByRole("button", { name: "Register return" }).click(); + + await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + }); + + await test.step("5. verify quality issue and queued automation event", async () => { + await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible(); + await expect(page.getByText(/Queued \(/)).toBeVisible(); + }); + + await test.step("6. resolve the duplicate customer scenario (S2)", async () => { + await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); + await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible(); + await page.getByRole("button", { name: /Merge into CUS-0012/ }).click(); + await page.getByRole("button", { name: "Yes, merge" }).click(); + await expect(page.getByText("resolved", { exact: true })).toBeVisible(); + }); + + await test.step("7. ask the damage question and inspect citations (S6)", async () => { + await page.goto("/knowledge"); + await page + .getByPlaceholder(/What must I do when a vehicle returns with damage/) + .fill("What must I do when a vehicle returns with damage?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await expect(page.getByText("Damage handling procedure").first()).toBeVisible(); + await expect(page.getByText("Vehicle return procedure").first()).toBeVisible(); + }); + + await test.step("8. inspect audit entries", async () => { + await page.goto("/audit"); + await page.getByLabel("Action").fill("return_registered"); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + await expect(page.getByText("return_registered").first()).toBeVisible(); + }); + + await test.step("9. verify responsive navigation at mobile width", async () => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/dashboard"); + await expect(page.getByText("Synthetic demo environment")).toBeVisible(); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 256948a..34ac772 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react-router-dom": "7.18.2" }, "devDependencies": { + "@playwright/test": "1.62.1", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", @@ -763,6 +764,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", @@ -1543,6 +1560,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39ab6a6..e3b346b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0", - "lint": "tsc -b --noEmit" + "lint": "tsc -b --noEmit", + "test:e2e": "playwright test" }, "dependencies": { "react": "18.3.1", @@ -15,6 +16,7 @@ "react-router-dom": "7.18.2" }, "devDependencies": { + "@playwright/test": "1.62.1", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..4b78c66 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + // _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test — + // it mutates demo data via a reset and is run explicitly, not as part of the suite. + testIgnore: "**/_*.spec.ts", + timeout: 30_000, + fullyParallel: false, + workers: 1, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + use: { + baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +});