diff --git a/docs/superpowers/specs/2026-07-25-executions-card-design.md b/docs/superpowers/specs/2026-07-25-executions-card-design.md new file mode 100644 index 0000000..d13e81b --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-executions-card-design.md @@ -0,0 +1,231 @@ +# Executions card — design (2026-07-25) + +Replace the dashboard's "Live VMs" card with an **Executions** card that lists +the most recent CI runs — including the ones running right now — each with its +status, and with the live build VM(s) attached to the run that spawned them. + +## Problem + +The dashboard shows only what is powered on *this instant* +(`partials/vms.html`): one row per running clone, job id scraped out of the +clone name. Anything that already finished is invisible, so the dashboard +cannot answer "did the last build pass?" — the user has to switch to Gitea. + +No local execution history exists to draw on: + +- `report job` reads `F:\CI\Logs\\invoke-ci.jsonl`, a path written by + the retired PowerShell `Invoke-CIJob.ps1`. Nothing writes it on this host; + `/var/lib/ci/logs/` holds only `benchmark.jsonl` and `web-audit.jsonl`. +- `commands/job.py` logs to stdout only. +- `/var/lib/ci/artifacts/-/` exists per job but the recent + ones are empty — not even `manifest.json` — so it is not a usable ledger. + +## Data source: Gitea Actions API + +Gitea on this host is **1.26.2**, which exposes: + +- `GET /api/v1/admin/actions/runs?limit=N` — every run across every repo in + one call (admin token required). +- `GET /api/v1/user/actions/runs?limit=N` — fallback for a non-admin token: + runs of the repos that user can see. + +`ActionWorkflowRun` carries everything the card needs: `id`, `run_number`, +`run_attempt`, `status`, `conclusion`, `started_at`, `completed_at`, +`head_branch`, `head_sha`, `event`, `path`, `display_title`, `repository`, +`html_url`. + +Per-run job/step detail (`/runs/{run}/jobs`) is deliberately **not** fetched: +one list call per refresh, no fan-out. + +### Authentication: reuse the OIDC login token + +`auth.py` already exchanges the OIDC code for a Gitea access token +(`authorize_access_token`, `auth.py:153`) and then discards it, keeping only +`session["user"]`. That token is valid for `/api/v1` as a bearer token, so no +new PAT is required. Two changes make it usable: + +1. `[web.oidc] scopes` gains the API read scopes (`read:repository`, plus + `read:admin` for the cross-repo admin endpoint). Existing sessions keep + working; the new scopes take effect after one re-login (consent screen). +2. The callback keeps `access_token`, `refresh_token` and `expires_at`. + +**Where the token is kept — security requirement.** `SessionMiddleware` +(`app.py:604`) stores the session in a *signed but unencrypted* cookie, and +this deployment serves plain HTTP on the LAN (`https_only=False`). A bearer +token placed in the session would therefore be readable in the browser cookie +and on the wire. So the token is **never** put in the session: it lives in a +server-side map on `app.state`, keyed by username (the allowlist is a handful +of users), and `logout` removes its entry. The cookie keeps carrying the +username alone, exactly as today. + +Consequences, accepted: + +- A service restart empties the map while the session cookie stays valid, so + the card degrades to "re-authenticate" until the next login. +- An optional PAT in the keyring (`[web] gitea_token_cred`, unset by default) + is used when no session token is available. This covers restarts and + non-admin users, and keeps a working path if the OAuth app cannot be given + API scopes. +- On `401`, one refresh attempt via the stored refresh token; if that fails, + the card degrades. + +### Endpoint selection + +Try `/admin/actions/runs`; on `403`/`404` fall back to `/user/actions/runs`. +The chosen source is reported in the view (`source: "admin" | "user"`) so the +template can say which scope the list covers. + +## Module: `operations/executions.py` + +Pure logic layer, same rules as its siblings: dataclasses only, no `click`, no +`print`, callable from both the CLI and the web UI. + +```python +@dataclass(frozen=True) +class Execution: + run_id: int + number: int + attempt: int + workflow: str # basename of ``path`` + repo: str # "owner/name" + branch: str + commit: str # head_sha, full; template shortens + event: str + actor: str + status: str # queued | running | success | failure + # | cancelled | skipped | unknown + started: datetime | None + completed: datetime | None + duration_sec: int | None + html_url: str + vms: list[LiveVm] + +@dataclass(frozen=True) +class ExecutionsView: + runs: list[Execution] + unmatched_vms: list[LiveVm] + source: str # "admin" | "user" | "none" + error: str | None +``` + +`list_executions(config, vms, *, token, limit) -> ExecutionsView` + +- HTTP via `urllib.request` (stdlib — same approach as + `commands/monitor.py:303`; no new dependency). +- Base URL from `config.gitea_base_url(config.web)`. +- Status mapping: `in_progress` → `running`; `waiting`/`queued`/`blocked` → + `queued`; otherwise `conclusion` (`success`, `failure`, `cancelled`, + `skipped`), anything unrecognised → `unknown`. +- `duration_sec`: `completed - started`; for a running run, `now - started`. +- **Never raises.** Network error, HTTP error, malformed JSON, missing token — + all degrade to `ExecutionsView(runs=[], unmatched_vms=vms, source="none", + error="…")`. The dashboard must never break because Gitea is unreachable. +- **10 s TTL cache** in-process, keyed by `(limit, token identity)`: htmx polls + every 5 s, Gitea sees at most one call per 10 s. VM data is read fresh on + every poll, so live rows stay live. + +### VM ↔ run correlation + +The job id is `{run_id}-{run_attempt}[-suffix]` +(`.gitea/actions/local-ci-build/action.yml:293`) and a clone is named +`Clone____` +(`operations/vms.py:134`). So `parse_clone_name(vm.name).job_id` is matched +against `f"{run_id}-{run_attempt}"` as a prefix; a matrix run therefore +attaches several VMs to one row. + +VMs that match no listed run (run older than the window, or a genuine orphan) +go to `unmatched_vms` and are rendered as trailing rows flagged `no run`, each +keeping its Remove button — otherwise the only per-VM action on the dashboard +would be lost. Orphan handling elsewhere is unchanged and not duplicated here: +the sweep stays in the Maintenance card +(`partials/maintenance.html:17`) and the count stays in the status-header VMs +tile (`partials/status_header.html:17`). + +## Card + +`partials/executions.html`, a `.card--wide` table titled **Executions**: + +| Run | Workflow | Branch / commit | Trigger | Status | Duration | VM | Actions | +|---|---|---|---|---|---|---|---| +| `#412` ↗
owner/repo | `build-ns7zip.yml` | `main` `a1b2c3d` | `push` · Simone | ● running | 02:41 | `Clone_412-1_…`
192.168.79.201 · up 2m | Remove | + +- `#`, plus `.` only when `attempt > 1`; links to `html_url` + with the existing `#i-external` icon. Repo in `text-dim` underneath. +- Commit shortened to 7 chars; duration via the existing `duration` filter. +- One row per run; several VMs stack inside the VM cell, each with its own + Remove form (`/vms/{vmx_id}/remove`, unchanged). +- Card icon: a new `#i-play` in the `partials/icons.html` sprite. + +**Badges.** Only `.badge-success`, `.badge-warn`, `.badge-danger` exist, so +`.badge-info` (accent) and `.badge-muted` (dim) are added to `app.css`. +Mapping: `queued` → muted, `running` → info, `success` → success, `failure` → +danger, `cancelled`/`skipped` → muted, `unknown` → muted. Never colour-only: +every badge pairs its colour with the status word, matching the health tiles. + +Degraded states render as a `notice-warn` above the table, and the VM rows are +still listed, so the card is never less useful than today's: + +- no token at all → "Gitea token not configured — showing running VMs only"; +- expired/refused token → "Gitea session expired — re-login" with a link; +- transport error → the error text, truncated. + +## Web wiring + +- `web/app.py`: `_fetch_executions(config, vms, token)` helper in the style of + `_fetch_cache_status`, wrapped so it cannot raise; `executions` context key + in `dashboard()`; `executions_partial()` serving `GET /executions`, + registered with `require_user` alongside the other partial routes + (`app.py:561`). The token is read from the server-side map, falling back to + `_read_secret(config.web.gitea_token_cred)` when configured. +- `web/auth.py`: the callback stores the token triple in the map; `logout` + pops it. The session payload is unchanged (username only). +- `partials/executions.html` replaces the `partials/vms.html` include in + `dashboard.html`. `partials/vms.html` and the `GET /vms` route are deleted; + `POST /vms/{id}/remove` and `POST /vms/cleanup` stay exactly as they are. +- `web/filters.py`: `run_badge_class(status)`, registered like the rest. +- Poll: `hx-get="/executions" hx-trigger="every 5s, ci:refresh from:body"`, + the same cadence as the card it replaces. +- `config.py`: `WebConfig.gitea_token_cred: str | None = None`, + `WebConfig.executions_limit: int = 15`, both read from the `[web]` section. + +## Testing + +New `tests/python/test_operations_executions.py`, with `urlopen` +monkeypatched (no network in tests): + +- admin payload parsed into `Execution` rows, field by field; +- `403` on the admin endpoint falls back to `/user/actions/runs` + (`source == "user"`); +- network error, HTTP 500, malformed JSON, absent token → degraded view with + `error` set and `unmatched_vms` populated, no exception; +- status/conclusion mapping table, including an unknown value; +- duration for finished vs running runs; +- VM correlation: single, matrix (several VMs on one run), unmatched; +- TTL cache: two calls within the window issue one HTTP request, and the + window expiring issues a second. + +Extended web tests: the partial renders run rows and their VM sub-rows; the +degraded notice appears without a token while VM rows still render; +`/executions` requires login; `GET /vms` is gone (404); `logout` drops the +token from the map. + +Gate: `ruff`, `mypy --strict`, coverage ≥ 90 % (currently 94 %). + +## Documentation + +- `docs/WEB-UI.md`: new section for the Executions card — required OAuth + scopes and the one-off re-login, the optional `gitea_token_cred` fallback + and why the token is kept server-side, plus the degraded states. Also + correct §4's health check, which uses `127.0.0.1` where nothing listens + (the service binds `192.168.10.11:8800`), and record that orphan VMs are + handled by the Maintenance sweep and the VMs tile, not inside the card. +- `config.example.toml`: `gitea_token_cred`, `executions_limit`, and the + scopes note in `[web.oidc]`. +- `dashboard.html` layout comment, the `web/` row in `CLAUDE.md`, and a + `TODO.md` entry. + +## Out of scope + +A twin CLI command (`ci report runs`), rerun/cancel actions from the UI, and +in-page job logs. The operations module stays CLI-callable, so the first of +these is cheap to add later.