Files
local-ci-cd-system/docs/superpowers/specs/2026-07-25-executions-card-design.md
T
Simone 0635236391 docs(spec): Executions card design
Replaces the Live VMs card with a run-centric Executions card fed by the
Gitea Actions API, reusing the OIDC login token (kept server-side, never in
the unencrypted session cookie) with an optional keyring PAT fallback.
2026-07-25 14:38:16 +02:00

11 KiB

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\<jobId>\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/<run_id>-<attempt>/ 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.

@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_<jobId>_<YYYYmmdd>_<HHMMSS>_<suffix> (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
  • #<number>, plus .<attempt> 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.