Skip to content

v0.3.2 (2026-06-27)

v0.3.2 turns the shipped Tauri desktop app into a self-contained way to use The Test Cabinet. Where v0.3.1 made the CLI and the desktop app thin enqueue + watch clients of a backend run queue — collapsing everything onto the single driver execution path — it left the packaged desktop app with nothing to talk to: without an externally-run backend it had no catalog and no way to launch a run. v0.3.2 closes that gap. When no external backend is configured (TCAB_BACKEND_URL unset), the desktop shell now stands up the entire run topology itself on a local k3d cluster — from the published GHCR service images, never building anything — and ingests the test-case catalog the app bundles. The only host prerequisite is a running container runtime.

This keeps the v0.3.1 topology intact: there is still one execution path, and the developer workflow (a thin client of a manually-run backend) is preserved exactly — the self-hosted cluster only kicks in when no backend is pointed at. v0.3.2 also hardens the publish path so a run’s data is scrubbed of leaked provider API keys wherever it crosses to the open internet, and adds run deletion, an in-cluster observability stack, and managed-PostgreSQL deployment overlays.

This is still pre-1.0 software intended primarily for project developers.

Test-case runtime caps are now authored in fractional hours

Section titled “Test-case runtime caps are now authored in fractional hours”

The end-to-end manifest field max_runtime_seconds (integer seconds) is replaced by max_runtime_hours (float, default 1), and tcab run’s --max-runtime flag now takes hours. Every cap is long enough that seconds added no useful precision, so authoring in hours (e.g. 0.5, 1.5) is clearer. Hours are parsed at the edges and converted to seconds internally, so the run pipeline (run records, the job API, the backend) is unchanged and no contract artifacts move. All existing manifests were converted (18000.5, 36001, 72002, and so on); a case that still declares max_runtime_seconds must be updated.

The desktop app self-hosts a bundled cluster

Section titled “The desktop app self-hosts a bundled cluster”

When launched with no TCAB_BACKEND_URL, the desktop shell now bootstraps the whole run topology on a local k3d cluster — preflight → stage the bundled inputs → create the cluster → apply secrets + overlay → wait for rollouts → port-forward → ingest the bundled catalog → ready. It pulls the published GHCR service images built for the app’s own commit (it never builds images and never needs a checkout), runs everything under a tcab-desktop namespace via a new deployments/k8s/overlays/app overlay (the local overlay’s twin), and reaps its port-forwards on exit. A BootGate loading screen holds the console hidden until the cluster is ready, with a per-step tracker and retry-on-failure.

Supporting work that makes the self-hosted path usable in practice:

  • Live bootstrap output. The long-running k3d/kubectl steps and the catalog ingest stream their stdout line by line into the boot gate as a live terminal tail (the catalog ingest drains the backend’s NDJSON progress feed and shows a running version count), so a slow first launch no longer looks like a hang. A captured tail also becomes the step’s error message on failure.
  • Per-harness authentication from Settings. A new desktop-only Settings → Authentication section configures how the self-contained cluster authenticates each harness’s runs — auth method (auto/subscription/api-key), API key, and subscription refresh — persisted to harness-auth.json, built into the driver secrets, and re-applied on every launch. The core gained a per-harness key override (TCAB_API_KEY_<SLUG>) so harnesses sharing a provider (the OpenRouter harnesses) can hold independent keys.
  • Multi-arch service images. CI now publishes each service image for both linux/amd64 and linux/arm64 (each built on its own native runner, stitched into one manifest list), so the self-hosted cluster comes up on an Apple-Silicon Mac instead of stalling in ImagePullBackOff.
  • Skip re-rendering an unchanged catalog. A whole-catalog ingest is tagged with the client’s build commit (the new core::COMMIT); when the next boot carries the same token the backend skips re-rendering every reference mockup, so restarts after the first no longer pause on an identical catalog. A -dirty or commit-less build is never trusted as an identity and forces a full re-ingest.

A run executes with a real provider API key in its container, so a model that dumps its environment can leak that key into recorded events, a failure detail, or a file it writes. The private backend keeps full fidelity; the exposure is only where a run’s data is published to the open internet. A shared SecretScrubber (core::redact) now scrubs a run’s outbound data at every public-egress surface:

  • GitHub — every staged file is scrubbed before the push to the run’s public source repo, matching the exact host env key values as well as any sk-…-shaped token.
  • Cloudflare Pages — the built static output is walked and any leaked key redacted in place before wrangler pages deploy, since a key written into a source file can be carried through the build into an emitted asset.
  • R2 snapshot — the backend scrubs each per-run document (record + events) by sk-… shape before upload, while leaving its private stored copy intact.

Matching is conservative — anchored to the sk- prefix with a length floor — so ordinary prose and short identifiers survive, and every seam logs a warning naming what it redacted. See results and snapshot.

A new auth-gated DELETE /runs/{id} removes a run’s record, reviews, links, and stored media, and asks the artifact service to drop the run’s stored tree as well (via a new DELETE /runs/{id}/artifacts, gated by the shared control-plane service token), so deletion leaves no orphaned bytes behind. It refuses a published run (422) — a public run is in the snapshot and gallery and can never be deleted. The shared console (web + desktop) surfaces a Delete run control on the run detail page, shown only for an unpublished run the active worker produced.

The Grafana LGTM stack moves out of the devcontainer’s docker-compose and into the cluster as a reusable components/observability kustomize component (tcab-lgtm Service + StatefulSet, plus a NetworkPolicy admitting the services’ and driver Jobs’ OTLP), wired into the local, staging, prod, and Azure overlays. Local, staging, and prod now observe telemetry through one identical stack; the dispatcher forwards the OTLP env into every per-run driver Job so run/driver spans export too. See observability.

A reusable components/postgres component converts the backend and auth service from their single-replica SQLite StatefulSet shape to stateless Deployments wired to a managed database via Secret, applied by two thin overlays (azure-staging, azure-prod) that point at an Azure Database for PostgreSQL (Flexible Server) instance. The Postgres shape is defined once, so staging keeps rehearsing prod; the base and the SQLite overlays are untouched. See deployment.

The Pacman case is built around real seeded assets

Section titled “The Pacman case is built around real seeded assets”

The end-to-end Pacman case (Fathom) used to have the model draw all of its art in code. It now seeds a fixed set of pre-drawn sprite-sheet assets under assets/ — the forager, the three predators, the sonar pulse, the flare bloom, and the trench tileset, all themselves generated by The Test Cabinet’s own asset-generation cases — that the build is required to render the game with, while still designing its own maze. A new specs/assets.md defines the frame layouts, compositing, and what is left to draw in code, and the end-to-end manifest’s assets array seeds them. The end-to-end docs and the authoring skill now spell out that seeded assets must be requested by page-relative URLs (no leading /; for a bundler, a relative base such as Vite’s base: './'), because a finished run is played back from a per-run sub-path (/runs/<id>/build/) and the host’s <base> rewrite reaches only the served HTML, never a URL the build constructs at runtime.

The models page is reworked as a table, and the consoles no longer fall back to fabricated sample test cases, runs, and models when their data source is empty: an explicit catalog load state (loading | ready | error) is threaded through the gallery context, so an unreachable or unconfigured backend now renders a distinct error rather than dummy “Carom”/“Phalanx” cases.

The self-hosted path hit a string of real-world host conditions, now handled:

  • Container runtime detection. A Finder/Dock-launched app inherits a truncated PATH, so docker/podman (and k3d’s own runtime lookup) couldn’t be found; tools are now resolved to absolute paths across the inherited PATH plus the well-known install dirs. Preflight detects a running runtime (podman-preferred), points k3d at Podman’s host-reachable API socket via DOCKER_HOST, and treats a rootless Podman machine as k3s-incapable (rootless cgroup v2 withholds the cpuset controller) — falling back to a working Docker when one exists instead of hanging until the create timeout.
  • Leftover clusters. A crash or force-quit mid-bootstrap left a registered k3d cluster whose API server was unreachable; ensure_cluster now recovers it — start it if only stopped, delete + recreate if it can’t be brought up, and probe the API server after start/create, recreating once if a partial cluster reports up but never answers. --wait now carries a bounded --timeout so a stuck bring-up fails legibly.
  • Port-forward conflicts. A process already holding a forward’s local port (an editor squatting on 8787) surfaced only as an opaque healthz timeout; each port is now preflighted (reporting the offending pid via lsof), each forward is pinned to 127.0.0.1, and wait_healthz requires the backend’s {"status":"ok",…} body so a foreign listener can’t be mistaken for it.
  • Arena backend resolution. Desktop arena commands (Quick Match, tournaments) now resolve the backend the same way the webview does, so they find the catalog on the self-contained cluster instead of falling back to a test-cases/ checkout the packaged app doesn’t ship.
  • Boot screen. The BootGate was recolored from a clashing teal/pink scheme to the app’s orange/purple palette, and tool failures are ANSI-stripped so k3d’s colorized output renders as plain text.

Live run stream survives idle gaps in the desktop webview

Section titled “Live run stream survives idle gaps in the desktop webview”

The GET /jobs/{id}/live NDJSON stream only emitted bytes on a harness event, so the silent gap while cloning the case and pulling the run-container image sent nothing for over a minute — and WKWebView (backing the Tauri app) timed the fetch out at ~60s as TypeError: Load failed. A 15s newline heartbeat is now interleaved into the live tail (Chromium had no idle-read timeout, so the web console never saw this).

Orphaned jobs are reaped on a single-box restart

Section titled “Orphaned jobs are reaped on a single-box restart”

If a single-box (SQLite) backend is powered off mid-run, the driver dies before reporting and the dispatcher’s in-memory death detection doesn’t survive its own restart, stranding the job in running forever. The backend now reconciles any dispatched/running job to failed at startup — before the router serves, so no driver can be mid-report — gated to SQLite so a remote backend (which can restart while drivers keep running) never reaps live work.

Adversarial replays load in the desktop webview

Section titled “Adversarial replays load in the desktop webview”

Two webview-specific failures broke every replay (quick match, proof matches, tournaments) in the desktop app:

  • The replay player fetch()ed its bundled sprite sheet, but sheet.png falls under Vite’s inline limit and is emitted as a base64 data: URL, which WKWebView cannot fetch(). Such data: URLs are now decoded directly, falling back to fetch() for emitted file URLs.
  • The player instantiates the Foray engine wasm via WebAssembly.instantiate, which the webview refuses without 'wasm-unsafe-eval' in the CSP script-src; an explicit script-src including it was added.

Dropdowns now share uniform styling between the web and desktop consoles, and the notifications drawer’s accent glow no longer bleeds past the right viewport edge while the drawer is closed (the shadow is rendered only in the open state).