Skip to content

Agent-readable CI logs: a server-credentialed proxy of Forgejo's web log route, redacted, advertised at the point of need

ADR-0032 gave an agent labctl pr checks and deferred the logs behind the red rows, betting that a failing check's name was enough to reproduce the failure locally. It wasn't: agents that see red but not why work around the gap — guessing at the failure, re-pushing blind, or skipping validation outright. This ADR closes 0032's deferral: the Tracker seam grows CheckLog(ctx, number, name) (CheckLogResult, error), reachable as labctl pr logs <n> [--check <context>] over the run-token agent surface (ADR-0014), so the fix-the-red loop's diagnose leg runs on the actual failure text instead of a job name.

Forgejo's REST API (15.0.5, verified against this instance's swagger) has no job-log endpoint — /actions/runs and /actions/tasks exist, job logs don't. So lab proxies the undocumented web route the UI's "Download logs" button uses: the commit-status rows Checks() already decodes carry a target_url of exactly /{owner}/{repo}/actions/runs/{run}/jobs/{job}, and GET {target_url}/attempt/{k}/logs answers 200 text/plain with the full plaintext log. That join — target_url to log route — is the whole adapter, and it is deliberately version-coupled to Forgejo internals; the GitHub binding does not share that coupling (amended by #20 — see Status).

The pins, decided:

  • Version-coupling is loud, never silent. Any answer outside the adapter's expected shape — a non-200/404/5xx status (amended by #259 — see Status), a 200 that isn't text/plain (a login page), a 404 on attempt 1, a target_url that stops matching the Actions job shape — wraps a new sentinel tracker.ErrLogAdapterMismatch whose message says what was asked, what came back, and what to do ("file an issue on coding-lab, then debug from local repro"). An agent must always be able to tell "no logs exist" from "lab is broken"; empty output with exit 0 is the one forbidden outcome.
  • Latest attempt by probing. A rerun bumps the attempt counter and the run page links attempt 1, so the adapter probes attempt/1, 2, … until the first 404 and serves the last 200 (capped; the cap is a mismatch error — amended by #259, see Status). Attempts above 1 are rare, so the extra requests are noise.
  • The server's forge credential rides the log request even though the route answers anonymously on public repos today — a private repo must fail loudly at the adapter seam, not silently once the repo flips private. Session-cookie machinery is explicitly out of this slice.
  • CheckLog is a primitive; selection is policy, held server-side. The tracker method fetches one named check's log, any state — a still-running job yields the partial log so far. Which checks to fetch (default: the failing subset of the same Checks() read; ?check= names one) is decided in the agentapi handler, mirroring 0032's lab-side aggregate: backends fetch, lab decides. A check whose target_url doesn't point at a Forgejo Actions job (external CI posting statuses) wraps ErrUnsupported — truthful "this forge does not serve that check's logs", not an adapter bug. The GitHub backend now implements CheckLog in its own right, proxying GitHub Actions job logs (amended by #20 — see Status); builtin's CheckLog still refuses outright with ErrUnsupported and remains unreachable through the handler, since builtin's zero-row Checks means default mode answers "no failing checks" first (0032's truthful-empty stance).
  • Wire: GET /agent/v1/prs/{n}/logs[?check=<context>], run-token scoped like every agent read. 200 text/plain carries the logs — default mode separates sections with a === logs: <name> === header line, ?check= dumps that one log bare; 204 is default mode finding no failing checks; 404 for an unknown context carries the available context names in the error body; 409 unsupported, 502 adapter mismatch or forge-side log-route error (amended by #259 — see Status), 503 rate-limited. All logs are fetched and buffered before the first body byte, so every failure is a real status code, never a truncated 200.
  • Redaction ships in this slice — no unredacted version ever exists. The response body passes through the internal/secrets derived-forms redactor (secret exec's kernel): every lab-vault secret value for the repo, in every derived encoding, becomes [REDACTED:NAME] before a byte leaves the server. Fail-closed: a redactor that cannot be built (store or vault error) is a 500, never a raw-log fallback. Forgejo masks its own Actions secrets; this is defense in depth for lab-managed values that leak into CI output some other way.
  • CLI: labctl pr logs <n> dumps the raw full logs of every failing check to stdout; no server-side truncation exists to design or get wrong — agents pipe through tail/grep themselves. --check <context> fetches one check by the context name pr checks prints, green or pending included. Exit codes follow the binary-wide convention (not pr checks' deliberate override): 0 logs printed · 1 nothing to print (no failing checks, or unknown context with the available contexts on stderr) or transport/API error · 2 usage.
  • Advertised at the point of need, because the feature exists to change behavior: a red pr checks report (single-shot or --wait's red exit) appends logs: labctl pr logs <n> to stderr — stderr, because 0032 pinned stdout as the parseable row surface; the seeder's CLAUDE.local.md usage table documents the verb; the AFK lander and fix prompts prescribe fetching logs as the first move on red checks, before local repro or re-push.

Status

Accepted. Closes the deferral ADR-0032 named as its natural successor, and extends ADR-0014's run-token surface and ADR-0024's labctl pr family (logs joins create | view | list | merge | checks). Applies the fail-closed redaction stance of the internal/secrets seam. The adapter, handler, and verb are each fixture-tested where they live; the mismatch error path is tested as deliberately as the happy path.

Amended by issue #259 (2026-08-03): the loud-never-silent pin listed "a non-200/404 status" among the shapes that wrap ErrLogAdapterMismatch, and for 5xx that is now false — a forge-side server error on the log route is not evidence that the adapter drifted from the forge, and claiming it was sent the reporter hunting a Forgejo version incompatibility that did not exist, when the real cause was a run retried during a forge outage whose attempt 2 has no stored log blob and whose route therefore 500s while attempt 1 serves 200 with the full log. ErrLogAdapterMismatch narrows to genuine shape surprises — a 200 that isn't text/plain, a 404 on attempt 1, a target_url that stops matching the Actions job shape, any other unexpected status, the attempt cap — and a new sibling sentinel tracker.ErrLogUpstream carries the forge-side error, naming the route and the raw upstream status and saying plainly that the forge failed to serve this log rather than that lab's adapter does not match this forge version; both still map to 502 with their message verbatim, so the loudness pin is intact and only the attribution is now honest. The latest-attempt-by-probing pin gains a fallback: a 5xx no longer aborts the probe — the first 404 remains the single terminator — and the adapter serves the newest attempt that answered 200 text/plain, because serving an older attempt's logs beats serving none, and an agent that cannot read a red check's logs has no triage path at all. That fallback is LOUD, never silent, by the same pin: CheckLog returns tracker.CheckLogResult{Log, Attempt, FallbackFrom, FallbackStatus} instead of a bare []byte, so WHICH attempt was served and which newer attempt errored with what status survives the seam to the handler, which turns a fallback into a repeated X-Lab-Log-Notice response header on the 200 path, which labctl pr logs prints to stderr — out of band of the log body, because ADR-0032 pinned stdout as the parseable surface and this ADR's own no-server-side-truncation pin (agents pipe through tail/grep themselves) means the body must stay byte-clean. The cap stays a mismatch whenever the probe saw a 200 — a probe that never meets its terminating 404 is exactly the shape surprise the pin covers — and is the upstream error only when no attempt was served and a 5xx was seen. The wire's status list gains nothing new: 502 now carries both classes, distinguished by honest message text, and grows only the X-Lab-Log-Notice header on the 200 path. Redaction is untouched — fallback-served logs pass through the same fail-closed redactor, on no new byte route.

Amended by issue #20 (2026-08-05): the GitHub backend's CheckLog is no longer a deferral — it proxies GitHub Actions job logs over GitHub's own documented REST API, GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs, so the fix-the-red loop's diagnose leg works on both bindings and this ADR's own premise — an agent that sees red but not why has no triage path — stops depending on which forge a repo happens to be bound to. The version-coupling stance the pins above describe INVERTS on this binding, and that inversion is the point: Forgejo's adapter proxies an undocumented web route and is deliberately version-coupled to Forgejo internals, while GitHub's adapter calls a documented, versioned REST endpoint with a pinned X-GitHub-Api-Version, so on the GitHub binding ErrLogAdapterMismatch means the join lab makes between a check run and an Actions job has stopped holding, never that a scraped web route moved under a forge upgrade. That join is the adapter's central decision: a check-run row's id is matched against the Actions jobs' check_run_url — walking actions/runs?head_sha= for the head commit's runs, then runs/{id}/jobs for each — and never against the job's NAME, because two workflows can each contribute a job of the same name to one commit and matching on name would silently serve the wrong log, exactly the outcome this ADR forbids; a name that resolves to more than one check run on the head is refused loudly with ErrUnsupported rather than resolved by guessing, since name is the only selector the CheckLog seam offers the caller. What GitHub cannot serve a log for is the analogue of Forgejo's external-target_url case: a legacy commit status (external CI reporting via commits/{sha}/status, which GitHub serves no log for at all) and a check run posted by a non-Actions GitHub App both wrap ErrUnsupported — truthful "no log exists for lab to serve", not an adapter bug. Attempt semantics follow GitHub's own notion of one: the workflow run's run_attempt, and this slice serves the LATEST attempt only, filling CheckLogResult.Attempt and always leaving FallbackFrom/FallbackStatus zero — the #259 fallback walk exists for a specific Forgejo behaviour (a newer attempt's log route answering 500 while an older attempt still serves 200) that GitHub is not known to exhibit, and per-attempt reads exist under actions/runs/{run_id}/attempts/{k}/… if that ever changes; because no older attempt is ever served on this binding, the #259 loudness pin cannot be violated here — there is no silent fallback to hide. The job-log route itself answers with a redirect to a short-lived signed URL on a foreign host, so lab follows that redirect explicitly rather than letting the shared HTTP client do it implicitly, strips the Authorization header on any hop that leaves the API host so the forge token never reaches the blob host, and treats the signed URL itself as a credential that must never appear in an error message — an error names the API route and the status it answered with, never the URL it redirected to. Nothing else about the slice moves: the handler's failing-subset default, the 204 on an all-green head, the 404 with available contexts, the fail-closed redaction pass, and the whole wire contract are backend-generic and needed no change to carry GitHub — the GitHub binding simply stopped answering 409 unsupported for every check. The sentinel doc comments in internal/tracker/tracker.go that named the built-in and GitHub backends together as the unsupported-by-default bindings, and named Forgejo as the sole producer of ErrLogAdapterMismatch and ErrLogUpstream, were corrected with it — the built-in binding remains the one true deferral, GitHub does not.

Considered options

  • Wait for a Forgejo REST log endpoint instead of proxying the web route. Rejected: no such endpoint exists in the deployed version, and the gap is hurting agent behavior now. The web route is undocumented, so the adapter treats every unexpected response as a loud, actionable mismatch rather than pretending the coupling is stable.
  • Server-side truncation or size limits on the log body. Rejected: any cap lab picks is wrong for someone — a grep needs the whole file, a tail needs the end. Raw full logs with client-side piping has no parameter to mis-tune.
  • A streaming CheckLog (io.ReadCloser) through tracker and handler. Rejected: buffering keeps the error contract honest (a fetch failure becomes a status code, not a torn 200) and the interface shape simple; CI logs are megabytes, not gigabytes.
  • Selection policy (which checks) inside each backend. Rejected: it would be re-implemented per forge and drift; the handler already holds the Checks() rows and the normalized states, so the failing-subset default lives once, server-side — the 0032 pattern.
  • Session-cookie auth for private repos if token auth is refused by the web route. Rejected for this slice: the token is sent, and a refusal surfaces as the adapter's loud error. Cookie machinery is its own decision if the need materializes.
  • Ship unredacted first, redact later. Rejected outright: the redaction pass is in this slice's definition of done, fail-closed, because a log endpoint that leaks vault values even once poisons the well.