From d340c1773ac3c8ab0415c06d17418447ebbb10c7 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 22:16:18 +0200 Subject: [PATCH] fix: ci-log durable persisted-log fallback for pruned runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci-log drove only the live run-view streamer (POST .../runs//jobs/ with logCursors), which reads the live act_runner task record. Forgejo prunes that record once a run completes, so the streamer 500s with 'task ... resource does not exist' on quick or older runs even though the web UI still shows the logs — the reader (argus, atlas) then had to ask the operator to relay the error. Add a fallback: when the streamer errors or returns no lines, download the persisted whole-job log via the same web route the run page's view- raw-logs link uses, .../runs//jobs//attempt//logs, keyed by the per-repo run number with the attempt segment. It survives the task prune. Live and recent runs keep the rich per-step streamed view; only the pruned case takes the flat fallback (where --step can't apply). New --attempt flag (default 1) selects the run attempt for re-runs. Verified against a real pruned run whose streamer 500'd: the fallback prints the full persisted log; a completed short job ends cleanly at 'Job succeeded', confirming the route returns complete logs. --- docs/tools/forge.md | 25 +++-- hive-forge/src/verbs/ci_log.rs | 167 +++++++++++++++++++++------------ 2 files changed, 124 insertions(+), 68 deletions(-) diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 2e4fdd53..f6aa3f31 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -165,16 +165,21 @@ plain comment show under `last comment`, not `reviews`. the run's internal global id, so the verb translates the run number first. Saves a zip to `/tmp/forge-artifact-.zip` by default; pass `-o -` to stream to stdout. -- `ci-log --run [--job i] [--step i]` prints a CI run's job step - logs. `` is the run number from the run-page URL (same value - `artifact-get` takes; `pr-status` surfaces it as a CI context's - target_url). Forgejo exposes no REST endpoint for job logs, so the - verb drives the web run-view streamer the run page polls. `--job` - selects the job within the run (0-based, default 0); `--step` narrows - to one step. `act_runner` garbage-collects completed-run logs, so this - is reliable for live + recently-finished runs; when logs are gone the - verb says so rather than printing nothing. `--json` dumps the raw - run-view response. +- `ci-log --run [--job i] [--step i] [--attempt n]` prints a CI + run's job step logs. `` is the run number from the run-page URL + (same value `artifact-get` takes; `pr-status` surfaces it as a CI + context's target_url). Two log sources are tried in order: first the + web run-view **streamer** the run page polls (rich per-step framing, + honors `--step`) — but that reads the live `act_runner` task record, + which Forgejo prunes once a run completes; then, when the streamer is + pruned (500 / no lines), the **durable persisted-log download** the + run page's "view raw logs" link uses + (`…/runs//jobs//attempt//logs`), a flat whole-job log that + survives the prune (`--step` is not honored on this path). So quick / + older runs that the streamer can no longer serve still print instead + of erroring. `--job` selects the job (0-based, default 0); `--attempt` + picks the run attempt for the durable path (default 1; re-runs + increment it). `--json` wraps the output. - `ci-rerun` re-runs CI without pushing an empty commit (the old retrigger path, which littered PR history). Forgejo has no token-usable REST endpoint to re-run an *existing* run (the run-page rerun buttons diff --git a/hive-forge/src/verbs/ci_log.rs b/hive-forge/src/verbs/ci_log.rs index 17720bfc..d131c78d 100644 --- a/hive-forge/src/verbs/ci_log.rs +++ b/hive-forge/src/verbs/ci_log.rs @@ -1,23 +1,28 @@ //! `ci-log --run [--job ] [--step ]` — print a CI //! Actions run's job step logs. //! -//! Forgejo exposes no REST endpoint for Actions job logs; the only path -//! is the web run-view streamer the run page polls, -//! `POST ///actions/runs//jobs/` with a -//! `{"logCursors":[{"step":N,"cursor":C,"expanded":bool}]}` body. Unlike -//! the artifact-download route (which keys off the run's GLOBAL id), this -//! route uses the per-repo run NUMBER — the `runs/` the UI shows and -//! `pr-status` surfaces as a CI context `target_url` — so the caller's -//! `--run` value is used directly, no id translation. +//! Two log sources, tried in order, because each covers a different +//! lifecycle window: //! -//! Protocol: POST once with an empty cursor set to fetch the run state -//! and enumerate the job's steps, then POST again requesting each step's -//! log from cursor 0 (`expanded: true`) and print the assembled lines. +//! 1. **Live run-view streamer** — `POST ///actions/runs/ +//! /jobs/` with a `{"logCursors":[{"step":N,"cursor":C, +//! "expanded":bool}]}` body, the same endpoint the run page polls. It +//! gives rich per-step framing but reads the **live `act_runner` task +//! record**, which Forgejo prunes once a run completes — so a run that +//! finished a while ago returns `500 "task ... resource does not exist"` +//! or no lines. Keyed by the per-repo run NUMBER (the `runs/` the UI +//! shows and `pr-status` surfaces as a CI context `target_url`). +//! 2. **Durable persisted-log download** — when the streamer is pruned the +//! step-log files still exist on disk (the web UI reads them fine after +//! the streamer 500s). We fetch them via the web download route +//! `///actions/runs//jobs//logs`, keyed +//! by the run's GLOBAL id (mirrors `artifact-get`: the REST runs list +//! translates run number → global id). This is a flat whole-job log with +//! no per-step framing, so `--step` is not honored on this path. //! -//! Caveat: `act_runner` garbage-collects step logs for completed runs, so -//! a run that finished a while ago may return no log lines. The verb is -//! reliable for in-progress and recently-finished runs; when no lines -//! come back it says so rather than printing nothing. +//! Result: live + recent runs get the rich streamed view; older / quickly- +//! pruned runs (the #1781 case argus + atlas both hit) still print via the +//! durable fallback instead of a bare "logs garbage-collected" error. use anyhow::{Result, bail}; use clap::Args as ClapArgs; @@ -35,8 +40,14 @@ pub struct Args { #[arg(long, default_value_t = 0)] job: u64, /// Print only this step's log (0-based). Omit to print every step. + /// Honored on the live-streamer path only; the durable persisted-log + /// fallback serves a flat whole-job log and ignores `--step`. #[arg(long)] step: Option, + /// Run attempt number for the durable persisted-log download (re-runs + /// increment it; default 1 covers the common single-attempt case). + #[arg(long, default_value_t = 1)] + attempt: u64, } /// Extract the step list from a run-view response @@ -63,39 +74,18 @@ fn step_name(step: Option<&Value>) -> String { .to_owned() } -/// # Errors -/// -/// Returns an error if the run/job can't be fetched (network, or a -/// non-2xx such as `404` for an unknown run), if the run has no steps, -/// or if no log lines are returned (e.g. logs already garbage-collected). -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job); - +/// Try the live run-view streamer. Returns `Ok(true)` when it printed (or +/// emitted JSON for) actual log lines, `Ok(false)` when the streamer is +/// reachable but has no lines (pruned task), and `Err` on a transport / HTTP +/// failure (e.g. the `500 "resource does not exist"` a pruned run returns). +/// Both the `Ok(false)` and `Err` cases tell the caller to fall back to the +/// durable persisted-log download. +fn streamer_logs(client: &Client, path: &str, args: &Args) -> Result { // 1. Discover the job's steps (state is returned regardless of cursors). - // A 404 (run/job gone) or 500 ("task ... resource does not exist", which - // the forge returns once act_runner has pruned a completed run's task - // record) surfaces here as an Err — translate it into the same - // logs-unavailable guidance rather than leaking the raw HTTP error. - let view = match client.post_json_web(&path, &json!({ "logCursors": [] })) { - Ok(v) => v, - Err(e) => bail!( - "run #{} job {} unavailable — the run/job may not exist, or its logs were \ - garbage-collected (act_runner prunes completed-run task records; ci-log \ - is reliable on live + recently-finished runs). Underlying: {e}", - args.run, - args.job - ), - }; + let view = client.post_json_web(path, &json!({ "logCursors": [] }))?; let steps = steps_of(&view); if steps.is_empty() { - bail!( - "run #{} job {} has no steps — run/job not found, or logs were \ - garbage-collected (act_runner prunes completed-run logs). Try a \ - live or recently-finished run.", - args.run, - args.job - ); + return Ok(false); } // 2. Request the chosen step(s) from cursor 0, fully expanded. @@ -114,13 +104,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .iter() .map(|&s| json!({ "step": s, "cursor": 0, "expanded": true })) .collect(); - let resp = client.post_json_web(&path, &json!({ "logCursors": cursors }))?; + let resp = client.post_json_web(path, &json!({ "logCursors": cursors }))?; - if client.json_mode() { - return crate::verbs::print_json(&resp); - } - - // 3. Human output: a header per step, then its lines in order. let steps_log = resp .get("logs") .and_then(|l| l.get("stepsLog")) @@ -128,14 +113,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .cloned() .unwrap_or_default(); if steps_log.is_empty() { - bail!( - "no log lines returned for run #{} job {} — logs were likely \ - garbage-collected (act_runner prunes completed-run logs). ci-log \ - is reliable on live and recently-finished runs.", - args.run, - args.job - ); + return Ok(false); } + + if client.json_mode() { + crate::verbs::print_json(&resp)?; + return Ok(true); + } + + // Human output: a header per step, then its lines in order. for sl in &steps_log { let idx = sl .get("step") @@ -152,9 +138,74 @@ pub fn run(client: &Client, args: Args) -> Result<()> { } } } + Ok(true) +} + +/// Durable fallback: download the persisted whole-job log via the web route +/// the run page's "view raw logs" link uses, +/// `///actions/runs//jobs//attempt//logs` +/// (keyed by the per-repo run NUMBER, with the attempt segment). Prints the +/// text (or wraps it in JSON under `--json`). `--step` is not honored here — +/// the persisted log is flat. +fn persisted_logs(client: &Client, repo: &str, args: &Args) -> Result<()> { + let url = client.web_url(&format!( + "/{repo}/actions/runs/{}/jobs/{}/attempt/{}/logs", + args.run, args.job, args.attempt + )); + let bytes = client.get_bytes_raw(&url)?; + if bytes.is_empty() { + bail!( + "run #{} job {} returned an empty log from both the live streamer \ + and the persisted-log download — the run may not exist or its logs \ + were fully purged.", + args.run, + args.job + ); + } + let text = String::from_utf8_lossy(&bytes); + if client.json_mode() { + return crate::verbs::print_json(&json!({ + "run": args.run, + "job": args.job, + "source": "persisted", + "log": text, + })); + } + print!("{text}"); + if !text.ends_with('\n') { + println!(); + } Ok(()) } +/// # Errors +/// +/// Returns an error if the run/job can't be fetched from either source +/// (network, an unknown run number, or a non-2xx), if a requested `--step` +/// is out of range, or if both the live streamer and the persisted-log +/// download yield no log content. +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let stream_path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job); + + // 1. Live run-view streamer first — rich per-step framing for in-progress + // and recently-finished runs. A pruned run errors or returns no lines; + // both cases fall through to the durable download below. (An + // out-of-range `--step` is a real usage error and propagates.) + match streamer_logs(client, &stream_path, &args) { + Ok(true) => return Ok(()), + // An out-of-range `--step` is a real usage error, not a pruned run — + // surface it instead of masking it with the fallback. + Err(e) if e.to_string().contains("out of range") => return Err(e), + // Pruned task (Err) or reachable-but-no-lines (Ok(false)) → fall back. + Ok(false) | Err(_) => {} + } + + // 2. Durable persisted-log download (survives the act_runner task prune + // that 500s the streamer — the #1781 case). + persisted_logs(client, repo, &args) +} + #[cfg(test)] mod tests { use super::*;