# 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.