Skip to content

Commit

Actions: keep every run attempt, re-run one job, graceful cancel, summaries

A re-run is a new attempt that keeps the one before (actions/0008: run_attempts, job_attempts): its jobs as they ended, their logs and summaries moved under {job}.{attempt}. rerun takes job (that job and those that need it; a matrix or called workflow runs again whole) and debug (RUNNER_DEBUG=1, ACTIONS_STEP_DEBUG, ACTIONS_RUNNER_DEBUG, runner.debug). run, logs and summaries read any attempt; job_log_text and run_logs return whole logs to download. Cancelling marks running jobs cancel_requested_at and tells them in the answer to their next report; they end cancelled after their cleanup steps, or are stopped outright after 5 minutes. Cancelling again, or force, stops them at once. Job summaries are kept per step, up to 1 MiB and 20 steps a job.

syntaqxcommitted Parent6369df1Browse files
8 files+778−630/8 viewed
+111−2
138138 /// The self-hosted runner that took it, by name.
139139 #[serde(default)]
140140 pub runner: Option<String>,
141+ /// It was cancelled and is running its `if: always()` and `cancelled()`
142+ /// steps and its post steps before it ends.
143+ #[serde(default)]
144+ pub cancelling: bool,
141145 }
142146
143147 #[derive(Clone, Debug, Serialize, Deserialize)]
154158 /// The environments whose protection rules hold its jobs, this attempt.
155159 #[serde(default)]
156160 pub pending_deployments: Vec<PendingDeployment>,
161+ /// Every attempt of the run, oldest first, the one shown included.
162+ /// `run.attempt` says which one `jobs` belong to.
163+ #[serde(default)]
164+ pub attempts: Vec<RunAttempt>,
157165 }
158166
167+/// One attempt of a run: the first, or a re-run.
168+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
169+#[serde(rename_all = "camelCase")]
170+pub struct RunAttempt {
171+ /// From 1.
172+ pub attempt: u64,
173+ /// `queued`, `in_progress` or `completed`; earlier attempts are completed.
174+ pub status: String,
175+ pub conclusion: Option<String>,
176+ /// Who started it: whoever caused the run for the first, whoever re-ran
177+ /// it for the rest.
178+ pub actor: Option<String>,
179+ /// It ran with debug logging (`RUNNER_DEBUG=1`).
180+ pub debug: bool,
181+ pub started_at: Option<String>,
182+ pub finished_at: Option<String>,
183+}
184+
185+/// One job's summary: what its steps wrote to `$GITHUB_STEP_SUMMARY`, in
186+/// Markdown, masked.
187+#[derive(Clone, Debug, Serialize, Deserialize)]
188+#[serde(rename_all = "camelCase")]
189+pub struct JobSummary {
190+ /// The job's id, as `RunDetail.jobs` gives it for the attempt.
191+ pub job_id: String,
192+ pub name: String,
193+ pub steps: Vec<StepSummary>,
194+}
195+
196+#[derive(Clone, Debug, Serialize, Deserialize)]
197+#[serde(rename_all = "camelCase")]
198+pub struct StepSummary {
199+ /// The step, from 1 (post steps follow the job's own).
200+ pub step: u32,
201+ pub markdown: String,
202+}
203+
204+/// A job's whole log, for downloading: its steps, to split the text by.
205+#[derive(Clone, Debug, Serialize, Deserialize)]
206+#[serde(rename_all = "camelCase")]
207+pub struct JobLogText {
208+ pub job_id: String,
209+ pub name: String,
210+ pub steps: Vec<StepState>,
211+ pub chunks: Vec<LogChunk>,
212+ /// Whether the job has finished.
213+ pub done: bool,
214+ /// Its log was left out: the run's logs reached `MAX_RUN_LOG_BYTES`.
215+ #[serde(default)]
216+ pub omitted: bool,
217+}
218+
159219 /// A run that needed approval before it started.
160220 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161221 #[serde(rename_all = "camelCase")]
518578 pub repo: RepoPath,
519579 pub viewer: Viewer,
520580 pub id: String,
581+ /// An earlier attempt; the latest when absent.
582+ #[serde(default)]
583+ pub attempt: Option<u64>,
584+}
585+
586+/// `summaries`: the job summaries of a run's attempt (the latest when
587+/// `attempt` is absent), jobs in the run's order, those with none left
588+/// out. Returns `Outcome<Vec<JobSummary>>`.
589+#[derive(Debug, Serialize, Deserialize)]
590+pub struct SummariesArgs {
591+ pub repo: RepoPath,
592+ pub viewer: Viewer,
593+ pub id: String,
594+ #[serde(default)]
595+ pub attempt: Option<u64>,
596+}
597+
598+/// `job_log_text`: one job's whole log, any attempt's (by the id the run
599+/// gave the job). Returns `Outcome<JobLogText>`.
600+#[derive(Debug, Serialize, Deserialize)]
601+pub struct JobLogTextArgs {
602+ pub repo: RepoPath,
603+ pub viewer: Viewer,
604+ pub job: String,
605+}
606+
607+/// `run_logs`: every job's whole log for an attempt of a run (the latest
608+/// when `attempt` is absent), until they add up to `MAX_RUN_LOG_BYTES`;
609+/// jobs past it come `omitted`, with no chunks. Returns
610+/// `Outcome<Vec<JobLogText>>`.
611+#[derive(Debug, Serialize, Deserialize)]
612+pub struct RunLogsArgs {
613+ pub repo: RepoPath,
614+ pub viewer: Viewer,
615+ pub id: String,
616+ #[serde(default)]
617+ pub attempt: Option<u64>,
521618 }
522619
620+/// The most log `run_logs` returns at once, in bytes.
621+pub const MAX_RUN_LOG_BYTES: usize = 24 * 1024 * 1024;
622+
523623 /// `logs`: a job's log after `after`. Returns `Outcome<JobLog>`.
524624 #[derive(Debug, Serialize, Deserialize)]
525625 pub struct LogsArgs {
545645 pub inputs: serde_json::Map<String, Value>,
546646 }
547647
548−/// `cancel` and `rerun` (all jobs, or with `failed_only` the ones that did
549−/// not succeed). Members only. Returns `Outcome<WorkflowRun>`.
648+/// `cancel` and `rerun`: every job, with `failed_only` the ones that did
649+/// not succeed, or with `job` that one job (by its id in the run's latest
650+/// attempt); each with the jobs that need them. `debug` runs the new
651+/// attempt with debug logging. Members only. Returns `Outcome<WorkflowRun>`.
550652 #[derive(Debug, Serialize, Deserialize)]
551653 pub struct RunActionArgs {
552654 pub actor: User,
554656 pub id: String,
555657 #[serde(default)]
556658 pub failed_only: bool,
659+ #[serde(default)]
660+ pub job: Option<String>,
661+ #[serde(default)]
662+ pub debug: bool,
663+ /// `cancel`: stop running jobs outright, without their cleanup steps.
664+ #[serde(default)]
665+ pub force: bool,
557666 }
558667
559668 /// `set_workflow_enabled`. Members only. Returns `Outcome<Workflow>`.
+69−0
1+-- Runs as GitHub keeps them: every attempt with its own jobs and logs, a
2+-- step's job summary ($GITHUB_STEP_SUMMARY), debug re-runs, and cancelling
3+-- that lets a job clean up. See src/plan.rs (rerun, cancel_run, job_report)
4+-- and src/views.rs (run, logs, summaries).
5+
6+-- A job's summary, a step at a time: the Markdown each step wrote to
7+-- $GITHUB_STEP_SUMMARY, masked by the runner. Kept under the same id as the
8+-- job's logs (a live job's, or an earlier attempt's: job_attempts.log_id).
9+CREATE TABLE IF NOT EXISTS job_summaries (
10+ job_id TEXT NOT NULL,
11+ step INTEGER NOT NULL,
12+ markdown TEXT NOT NULL,
13+ PRIMARY KEY (job_id, step)
14+);
15+
16+-- A run's earlier attempts: how each ended, and who started it.
17+CREATE TABLE IF NOT EXISTS run_attempts (
18+ run_id TEXT NOT NULL,
19+ attempt INTEGER NOT NULL,
20+ repo_id TEXT NOT NULL,
21+ conclusion TEXT,
22+ -- Who started this attempt: the run's actor for the first, whoever
23+ -- re-ran it for the rest.
24+ actor TEXT,
25+ debug INTEGER NOT NULL DEFAULT 0,
26+ started_at TEXT,
27+ finished_at TEXT,
28+ PRIMARY KEY (run_id, attempt)
29+);
30+
31+-- Each job of an earlier attempt as it ended. `id` is the snapshot's own
32+-- id, shown as the job's id when that attempt is viewed; `log_id` is where
33+-- its logs and summary are kept: the snapshot's id once the job ran again,
34+-- or the live job's while that one still carries the result (a job a
35+-- "re-run failed jobs" left alone).
36+CREATE TABLE IF NOT EXISTS job_attempts (
37+ id TEXT PRIMARY KEY,
38+ run_id TEXT NOT NULL,
39+ repo_id TEXT NOT NULL,
40+ attempt INTEGER NOT NULL,
41+ job_id TEXT NOT NULL,
42+ log_id TEXT NOT NULL,
43+ key TEXT NOT NULL,
44+ ordinal INTEGER NOT NULL DEFAULT 0,
45+ name TEXT NOT NULL,
46+ needs TEXT NOT NULL DEFAULT '[]',
47+ status TEXT NOT NULL,
48+ conclusion TEXT,
49+ steps TEXT NOT NULL DEFAULT '[]',
50+ annotations TEXT NOT NULL DEFAULT '[]',
51+ reason TEXT,
52+ environment TEXT,
53+ labels TEXT,
54+ runner_name TEXT,
55+ started_at TEXT,
56+ finished_at TEXT
57+);
58+CREATE INDEX IF NOT EXISTS job_attempts_by_run ON job_attempts (run_id, attempt);
59+CREATE INDEX IF NOT EXISTS job_attempts_by_log ON job_attempts (log_id);
60+
61+-- Who started the current attempt (the run's actor until it is re-run),
62+-- and whether it runs with debug logging.
63+ALTER TABLE runs ADD COLUMN triggering_actor TEXT;
64+ALTER TABLE runs ADD COLUMN debug INTEGER NOT NULL DEFAULT 0;
65+
66+-- Cancelling: a running job is told to stop and given time to run its
67+-- `if: always()` and `cancelled()` steps and its post steps. Past the grace
68+-- period it is stopped outright.
69+ALTER TABLE jobs ADD COLUMN cancel_requested_at TEXT;
+3−0
215215 "runs" => reply(&service.runs(args(body)?).await?),
216216 "run" => reply(&service.run(args(body)?).await?),
217217 "logs" => reply(&service.logs(args(body)?).await?),
218+ "job_log_text" => reply(&service.job_log_text(args(body)?).await?),
219+ "run_logs" => reply(&service.run_logs(args(body)?).await?),
220+ "summaries" => reply(&service.summaries(args(body)?).await?),
218221 "dispatch" => reply(&service.dispatch(args(body)?).await?),
219222 "repository_dispatch" => reply(&service.repository_dispatch(args(body)?).await?),
220223 "approve_run" => reply(&service.approve_run(args(body)?).await?),
+313−37
2929 /// The most a single log report may add.
3030 const MAX_CHUNK_BYTES: usize = 256 * 1024;
3131 const MAX_ANNOTATIONS: usize = 50;
32+/// The most one step's job summary keeps, in bytes, and how many steps of a
33+/// job may have one, as on GitHub.
34+pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
35+pub(crate) const MAX_SUMMARIES: u32 = 20;
36+
37+/// Whether a step's summary of `adding` bytes is kept: a job keeps up to
38+/// 20 steps' summaries (`steps` it has, `mine` this step's bytes so far)
39+/// of up to 1 MiB each.
40+pub(crate) fn summary_fits(steps: u32, mine: Option<usize>, adding: usize) -> bool {
41+ if adding == 0 {
42+ return false;
43+ }
44+ match mine {
45+ Some(used) => used + adding <= MAX_SUMMARY_BYTES,
46+ None => steps < MAX_SUMMARIES && adding <= MAX_SUMMARY_BYTES,
47+ }
48+}
3249
3350 /// The repository whose unfinished runs `event` stops, by id: one deleted,
3451 /// or archived (not unarchived).
98115 /// Whether its concurrency group cancels what it replaces.
99116 #[serde(default)]
100117 pub cancel_in_progress: u32,
118+ /// Who started the current attempt, once it is re-run (migration 0008).
119+ #[serde(default)]
120+ pub triggering_actor: Option<String>,
121+ /// The current attempt runs with debug logging.
122+ #[serde(default)]
123+ pub debug: u32,
101124 }
102125
103126 impl RunRow {
192215 pub concurrency_group: Option<String>,
193216 #[serde(default)]
194217 pub cancel_in_progress: u32,
218+ /// When it was told to stop, while it runs its cleanup steps
219+ /// (migration 0008).
220+ #[serde(default)]
221+ pub cancel_requested_at: Option<String>,
222+}
223+
224+/// How long a cancelled job has to run its `if: always()` and `cancelled()`
225+/// steps and its post steps before it is stopped outright, as on GitHub.
226+pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000;
227+
228+/// Which jobs a re-run runs again.
229+#[derive(Clone, Copy, Debug, PartialEq)]
230+pub enum Rerun<'a> {
231+ All,
232+ /// Those that did not succeed.
233+ Failed,
234+ /// One job's key.
235+ Job(&'a str),
236+}
237+
238+/// The keys a re-run runs again, in `order`: those `which` names and,
239+/// transitively, every key that needs one of them. `needs` gives a key's
240+/// needs; `succeeded` whether every job of a key succeeded.
241+pub fn rerun_keys(order: &[&str], needs: impl Fn(&str) -> Vec<String>, succeeded: impl Fn(&str) -> bool, which: Rerun) -> Vec<String> {
242+ let mut again: Vec<String> = Vec::new();
243+ for key in order {
244+ let named = match which {
245+ Rerun::All => true,
246+ Rerun::Failed => !succeeded(key),
247+ Rerun::Job(job) => *key == job,
248+ };
249+ if named || needs(key).iter().any(|need| again.contains(need)) {
250+ again.push((*key).to_owned());
251+ }
252+ }
253+ again
254+}
255+
256+/// The key under `jobs:` a job belongs to: a called workflow's jobs
257+/// (`build/test`) run again with the job that calls it (`build`).
258+pub fn top_key(key: &str) -> &str {
259+ key.split('/').next().unwrap_or(key)
260+}
261+
262+/// Debug logging for a job, as a re-run with it turned on gives GitHub's:
263+/// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and
264+/// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of
265+/// those names (`::debug::` lines shown, and how each step's `if` read).
266+pub fn debug_logging(variables: &mut Map<String, Value>, runner: &mut Value) {
267+ variables.insert("RUNNER_DEBUG".into(), json!("1"));
268+ variables.insert("ACTIONS_STEP_DEBUG".into(), json!("true"));
269+ variables.insert("ACTIONS_RUNNER_DEBUG".into(), json!("true"));
270+ runner["debug"] = json!("1");
195271 }
196272
197273 impl JobRow {
572648 for other in &others {
573649 if cancel_in_progress || other.status == "pending" {
574650 // A newer run replaces a waiting one, as on GitHub.
575− self.cancel_run(other, "A newer run in the same concurrency group replaced it.").await?;
651+ self.cancel_run(other, "A newer run in the same concurrency group replaced it.", false).await?;
576652 }
577653 }
578654 if !cancel_in_progress && others.iter().any(|other| other.status != "pending") {
13631439 Box::pin(self.advance(&job.run_id)).await
13641440 }
13651441
1366− /// Cancels a job, stopping its sandbox if it has one.
1442+ /// Cancels a job. One that is running is told to stop (the answer to
1443+ /// its next report), and runs its `if: always()` and `cancelled()`
1444+ /// steps and its post steps before it ends `cancelled`; one that has
1445+ /// not started is cancelled at once.
13671446 async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
1447+ if job.status == "in_progress" && job.started_at.is_some() {
1448+ self.db
1449+ .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'")
1450+ .bind(&[now().into(), reason.into(), job.id.as_str().into()])?
1451+ .run()
1452+ .await?;
1453+ return Ok(());
1454+ }
1455+ self.hard_stop(job, reason).await
1456+ }
1457+
1458+ /// Cancels a job outright, stopping its sandbox if it has one.
1459+ async fn hard_stop(&self, job: &JobRow, reason: &str) -> Result<()> {
13681460 // A self-hosted runner hears it was cancelled on its next poll.
13691461 if job.status == "in_progress" && job.runner_id.is_none() {
13701462 let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
16001692 Ok(())
16011693 }
16021694
1603− /// Cancels a run: its waiting and queued jobs, and stops its running ones.
1604− pub async fn cancel_run(&self, run: &RunRow, reason: &str) -> Result<()> {
1695+ /// Cancels a run: its waiting and queued jobs at once, and its running
1696+ /// ones gracefully (`stop_job`), or outright when `force`.
1697+ pub async fn cancel_run(&self, run: &RunRow, reason: &str, force: bool) -> Result<()> {
16051698 self.db
16061699 .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'")
16071700 .bind(&[run.id.as_str().into()])?
16081701 .run()
16091702 .await?;
16101703 for job in self.job_rows(&run.id).await?.iter().filter(|job| job.status != "completed") {
1611− self.stop_job(job, reason).await?;
1704+ if force {
1705+ self.hard_stop(job, reason).await?;
1706+ } else {
1707+ self.stop_job(job, reason).await?;
1708+ }
16121709 }
16131710 if run.status == "pending" || run.status == "action_required" {
16141711 self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?;
16271724 .await?
16281725 .results::<RunRow>()?;
16291726 for run in runs {
1630− self.cancel_run(&run, "The repository was archived or deleted.").await?;
1727+ self.cancel_run(&run, "The repository was archived or deleted.", true).await?;
16311728 }
16321729 Ok(())
16331730 }
16401737 if run.status == "completed" {
16411738 return Ok(fail(FailureCode::Conflict, "The run has already finished."));
16421739 }
1643− self.cancel_run(&run, &format!("{} cancelled the run.", a.actor.username)).await?;
1740+ // Cancelling a run that is already cancelling stops its jobs
1741+ // outright, without waiting for their cleanup steps.
1742+ let force = a.force || run.conclusion.as_deref() == Some("cancelled");
1743+ let reason = if force {
1744+ format!("{} stopped the run without waiting for its cleanup steps.", a.actor.username)
1745+ } else {
1746+ format!("{} cancelled the run.", a.actor.username)
1747+ };
1748+ self.cancel_run(&run, &reason, force).await?;
16441749 self.run_summary(&run.id).await
16451750 }
16461751
1647− /// Runs again: every job, or with `failed_only` those that did not
1648− /// succeed and the jobs that need them.
1752+ /// Runs again, as a new attempt: every job, with `failed_only` those
1753+ /// that did not succeed, or with `job` that one; each with the jobs that
1754+ /// need them. The attempt that ends is kept, its jobs and their logs and
1755+ /// summaries with it (`job_attempts`, `run_attempts`).
16491756 pub async fn rerun(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
16501757 if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? {
16511758 return Ok(Outcome::Fail(refused));
16521759 }
1653− let run = check!(self.run_in(&a.repo, &a.id).await?);
1760+ // A job alone (`POST …/jobs/{job}/rerun`) names its run.
1761+ let run_id = match (&a.job, a.id.is_empty()) {
1762+ (Some(job), true) => {
1763+ #[derive(Deserialize)]
1764+ struct Of {
1765+ run_id: String,
1766+ }
1767+ let of = self.db.prepare("SELECT run_id FROM jobs WHERE id = ?").bind(&[job.as_str().into()])?.first::<Of>(None).await?;
1768+ match of {
1769+ Some(of) => of.run_id,
1770+ None => return Ok(fail(FailureCode::NotFound, "No such job.")),
1771+ }
1772+ }
1773+ _ => a.id.clone(),
1774+ };
1775+ let run = check!(self.run_in(&a.repo, &run_id).await?);
16541776 if run.status != "completed" {
16551777 return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first."));
16561778 }
16671789 }
16681790 let jobs = self.job_rows(&run.id).await?;
16691791 let workflow = workflow::parse(&run.source).ok();
1670− // Which keys run again: failed ones and, transitively, those needing them.
1671− let mut again: Vec<String> = Vec::new();
1672− for key in workflow.as_ref().map(|w| w.job_order()).unwrap_or_default() {
1673− let rows: Vec<&JobRow> = jobs.iter().filter(|j| j.key == key).collect();
1674− let failed = rows.iter().any(|row| row.conclusion.as_deref() != Some("success"));
1675− let needs_again = rows.first().is_some_and(|row| row.needs().iter().any(|need| again.contains(need)));
1676− if !a.failed_only || failed || needs_again {
1677− again.push(key.to_owned());
1678− }
1679− }
1792+ let target = match &a.job {
1793+ Some(id) => match jobs.iter().find(|job| &job.id == id) {
1794+ Some(job) => Some(top_key(&job.key).to_owned()),
1795+ None => return Ok(fail(FailureCode::NotFound, "That job is not in the run's latest attempt.")),
1796+ },
1797+ None => None,
1798+ };
1799+ let which = match (&target, a.failed_only) {
1800+ (Some(key), _) => Rerun::Job(key),
1801+ (None, true) => Rerun::Failed,
1802+ (None, false) => Rerun::All,
1803+ };
1804+ let order = workflow.as_ref().map(|w| w.job_order()).unwrap_or_default();
1805+ let again = rerun_keys(
1806+ &order,
1807+ |key| jobs.iter().find(|j| j.key == key).map(JobRow::needs).unwrap_or_default(),
1808+ |key| jobs.iter().filter(|j| j.key == key).all(|j| j.conclusion.as_deref() == Some("success")),
1809+ which,
1810+ );
16801811 if again.is_empty() {
16811812 return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again."));
16821813 }
1683− let mut statements = Vec::new();
1814+ // The jobs that run again, a called workflow's with the job calling it.
1815+ let rerun_ids: Vec<&str> = jobs.iter().filter(|job| again.iter().any(|key| key == top_key(&job.key))).map(|job| job.id.as_str()).collect();
1816+ let ids = serde_json::to_string(&rerun_ids)?;
1817+ let ended = run.attempt.to_string();
1818+ let ended = ended.as_str();
1819+ let mut statements = vec![
1820+ // The attempt that ends, as it ended.
1821+ self.db
1822+ .prepare(
1823+ "INSERT OR REPLACE INTO run_attempts (run_id, attempt, repo_id, conclusion, actor, debug, started_at, finished_at)
1824+ SELECT id, attempt, repo_id, conclusion, COALESCE(triggering_actor, actor), debug, started_at, finished_at FROM runs WHERE id = ?",
1825+ )
1826+ .bind(&[run.id.as_str().into()])?,
1827+ // Earlier attempts that showed a job's logs from where it ran
1828+ // then now find them where they move to.
1829+ self.db
1830+ .prepare("UPDATE job_attempts SET log_id = log_id || '.' || ?1 WHERE run_id = ?2 AND log_id IN (SELECT value FROM json_each(?3))")
1831+ .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
1832+ // Each of its jobs: one that runs again keeps its logs under
1833+ // `{id}.{attempt}`; one left alone is still the live job's.
1834+ self.db
1835+ .prepare(
1836+ "INSERT OR REPLACE INTO job_attempts (id, run_id, repo_id, attempt, job_id, log_id, key, ordinal, name, needs, status, conclusion,
1837+ steps, annotations, reason, environment, labels, runner_name, started_at, finished_at)
1838+ SELECT id || '.' || ?1, run_id, repo_id, ?1, id,
1839+ CASE WHEN id IN (SELECT value FROM json_each(?3)) THEN id || '.' || ?1 ELSE id END,
1840+ key, ordinal, name, needs, status, conclusion, steps, annotations, reason, environment, labels, runner_name, started_at, finished_at
1841+ FROM jobs WHERE run_id = ?2 ORDER BY rowid",
1842+ )
1843+ .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
1844+ self.db
1845+ .prepare("UPDATE logs SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
1846+ .bind(&[ended.into(), ids.as_str().into()])?,
1847+ self.db
1848+ .prepare("UPDATE job_summaries SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
1849+ .bind(&[ended.into(), ids.as_str().into()])?,
1850+ ];
16841851 for key in &again {
1685− statements.push(self.db.prepare("DELETE FROM logs WHERE job_id IN (SELECT id FROM jobs WHERE run_id = ? AND key = ?)").bind(&[run.id.as_str().into(), key.as_str().into()])?);
16861852 statements.push(self.db.prepare("DELETE FROM jobs WHERE run_id = ? AND key = ? AND ordinal > 0").bind(&[run.id.as_str().into(), key.as_str().into()])?);
16871853 // The jobs of a workflow it called are made again when it calls it again.
16881854 statements.push(
16891855 self.db
1690− .prepare("DELETE FROM logs WHERE job_id IN (SELECT id FROM jobs WHERE run_id = ? AND key LIKE ?)")
1691− .bind(&[run.id.as_str().into(), format!("{key}/%").into()])?,
1692− );
1693− statements.push(
1694− self.db
16951856 .prepare("DELETE FROM jobs WHERE run_id = ? AND key LIKE ?")
16961857 .bind(&[run.id.as_str().into(), format!("{key}/%").into()])?,
16971858 );
17011862 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
17021863 matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
17031864 labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL,
1704− concurrency_group = NULL, cancel_in_progress = 0 WHERE run_id = ? AND key = ?",
1865+ concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
17051866 )
17061867 .bind(&[run.id.as_str().into(), key.as_str().into()])?,
17071868 );
17081869 }
17091870 statements.push(
17101871 self.db
1711− .prepare("UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL WHERE id = ?")
1712− .bind(&[run.id.as_str().into()])?,
1872+ .prepare(
1873+ "UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL,
1874+ triggering_actor = ?, debug = ? WHERE id = ?",
1875+ )
1876+ .bind(&[a.actor.username.as_str().into(), u32::from(a.debug).into(), run.id.as_str().into()])?,
17131877 );
17141878 self.db.batch(statements).await?;
17151879 if let Some(run) = self.run_row(&run.id).await? {
18672031 // machine rather than g1t's sandbox.
18682032 let mut variables = info.variables(&job.key);
18692033 variables.insert("GITHUB_RETENTION_DAYS".into(), json!(retention_days.to_string()));
1870− let runner = match &job.runner_id {
2034+ let mut runner = match &job.runner_id {
18712035 Some(id) => self.runner_context_for(id, &mut variables).await?,
18722036 None => runner_context(),
18732037 };
2038+ // A re-run with debug logging: what GitHub sets for one.
2039+ if run.debug != 0 {
2040+ debug_logging(&mut variables, &mut runner);
2041+ }
18742042
18752043 // Where to check out: a pull request's fork, or the repository.
18762044 let clone_url = match run.pull {
20302198 }
20312199 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
20322200 }
2201+ "summary" => {
2202+ // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the
2203+ // step's summary, up to 1 MiB a step and 20 steps a job.
2204+ let step = report["step"].as_u64().unwrap_or(0) as u32;
2205+ let markdown = report["markdown"].as_str().unwrap_or_default();
2206+ #[derive(Deserialize)]
2207+ struct Held {
2208+ steps: u32,
2209+ mine: Option<f64>,
2210+ }
2211+ let held = self
2212+ .db
2213+ .prepare("SELECT COUNT(*) AS steps, MAX(CASE WHEN step = ? THEN LENGTH(markdown) END) AS mine FROM job_summaries WHERE job_id = ?")
2214+ .bind(&[step.into(), job.id.as_str().into()])?
2215+ .first::<Held>(None)
2216+ .await?;
2217+ let (steps, mine) = held.map_or((0, None), |held| (held.steps, held.mine.map(|n| n as usize)));
2218+ if summary_fits(steps, mine, markdown.len()) {
2219+ self.db
2220+ .prepare(
2221+ "INSERT INTO job_summaries (job_id, step, markdown) VALUES (?, ?, ?)
2222+ ON CONFLICT (job_id, step) DO UPDATE SET markdown = job_summaries.markdown || excluded.markdown",
2223+ )
2224+ .bind(&[job.id.as_str().into(), step.into(), markdown.into()])?
2225+ .run()
2226+ .await?;
2227+ }
2228+ self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2229+ }
20332230 "annotation" => {
20342231 let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default();
20352232 if annotations.len() < MAX_ANNOTATIONS {
20472244 .await?;
20482245 }
20492246 }
2247+ // Nothing to tell; the answer says whether to stop.
2248+ "ping" => {
2249+ self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2250+ }
20502251 "done" => {
2051− let conclusion = report["conclusion"]
2052− .as_str()
2053− .filter(|c| matches!(*c, "success" | "failure" | "cancelled"))
2054− .unwrap_or("failure");
2252+ // A job told to stop ends cancelled, however its cleanup went.
2253+ let conclusion = if job.cancel_requested_at.is_some() {
2254+ "cancelled"
2255+ } else {
2256+ report["conclusion"]
2257+ .as_str()
2258+ .filter(|c| matches!(*c, "success" | "failure" | "cancelled"))
2259+ .unwrap_or("failure")
2260+ };
20552261 let outputs = report["outputs"].as_object().cloned();
20562262 Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?;
20572263 }
20582264 other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))),
20592265 }
2060− Ok(Outcome::Ok(json!({ "ok": true })))
2266+ // `cancelled`: the run was cancelled, and the runner should stop
2267+ // the step it is on and run only its cleanup steps.
2268+ Ok(Outcome::Ok(json!({ "ok": true, "cancelled": job.cancel_requested_at.is_some() })))
20612269 }
20622270
20632271 // --- Every minute ---------------------------------------------------------------
20752283 let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str());
20762284 let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000;
20772285 let over = job.started_at.as_deref().is_some_and(|started| started < before(limit).as_str());
2078− if over {
2286+ let cancel_overdue = job.cancel_requested_at.as_deref().is_some_and(|at| at < before(CANCEL_GRACE_MS).as_str());
2287+ if cancel_overdue {
2288+ // Cancelled, and still going after its grace period.
2289+ self.hard_stop(&job, "It was cancelled, and did not finish its cleanup steps within 5 minutes.").await?;
2290+ self.advance(&job.run_id).await?;
2291+ } else if over {
20792292 let reason = format!("It ran longer than its time limit of {} minutes.", job.timeout_minutes);
20802293 // A self-hosted runner is told to stop on its next poll.
20812294 if job.runner_id.is_none() {
22312444 assert_eq!(deployment_outcome(&[None]), None);
22322445 }
22332446 }
2447+
2448+#[cfg(test)]
2449+mod reruns {
2450+ use serde_json::{Map, Value, json};
2451+
2452+ use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, debug_logging, rerun_keys, summary_fits, top_key};
2453+
2454+ /// build ← test ← deploy, and lint on its own.
2455+ fn keys(which: Rerun, failed: &[&str]) -> Vec<String> {
2456+ let order = ["build", "lint", "test", "deploy"];
2457+ let needs = |key: &str| -> Vec<String> {
2458+ match key {
2459+ "test" => vec!["build".into()],
2460+ "deploy" => vec!["test".into()],
2461+ _ => Vec::new(),
2462+ }
2463+ };
2464+ rerun_keys(&order, needs, |key| !failed.contains(&key), which)
2465+ }
2466+
2467+ #[test]
2468+ fn a_rerun_takes_the_jobs_it_names_and_those_that_need_them() {
2469+ assert_eq!(keys(Rerun::All, &[]), ["build", "lint", "test", "deploy"]);
2470+ assert_eq!(keys(Rerun::Failed, &["test"]), ["test", "deploy"]);
2471+ assert_eq!(keys(Rerun::Failed, &["lint"]), ["lint"]);
2472+ assert!(keys(Rerun::Failed, &[]).is_empty());
2473+ // One job, whatever it came to, and what depends on it.
2474+ assert_eq!(keys(Rerun::Job("build"), &[]), ["build", "test", "deploy"]);
2475+ assert_eq!(keys(Rerun::Job("lint"), &["test"]), ["lint"]);
2476+ assert_eq!(keys(Rerun::Job("deploy"), &[]), ["deploy"]);
2477+ assert!(keys(Rerun::Job("missing"), &[]).is_empty());
2478+ }
2479+
2480+ #[test]
2481+ fn a_called_workflows_jobs_run_again_with_the_job_that_calls_it() {
2482+ assert_eq!(top_key("build/test"), "build");
2483+ assert_eq!(top_key("build/inner/test"), "build");
2484+ assert_eq!(top_key("lint"), "lint");
2485+ }
2486+
2487+ #[test]
2488+ fn a_job_keeps_twenty_steps_summaries_of_a_mebibyte_each() {
2489+ assert!(summary_fits(0, None, 10));
2490+ assert!(!summary_fits(0, None, 0), "nothing to keep");
2491+ assert!(!summary_fits(MAX_SUMMARIES, None, 10), "a twenty-first step's summary is dropped");
2492+ assert!(summary_fits(MAX_SUMMARIES, Some(10), 10), "a step that has one may add to it");
2493+ assert!(summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 10));
2494+ assert!(!summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 11));
2495+ assert!(!summary_fits(0, None, MAX_SUMMARY_BYTES + 1));
2496+ }
2497+
2498+ #[test]
2499+ fn a_debug_rerun_sets_what_github_sets() {
2500+ let mut variables = Map::new();
2501+ let mut runner = json!({ "name": "g1t", "debug": "" });
2502+ debug_logging(&mut variables, &mut runner);
2503+ assert_eq!(variables["RUNNER_DEBUG"], "1");
2504+ assert_eq!(variables["ACTIONS_STEP_DEBUG"], "true");
2505+ assert_eq!(variables["ACTIONS_RUNNER_DEBUG"], "true");
2506+ assert_eq!(runner["debug"], Value::String("1".into()));
2507+ assert_eq!(runner["name"], "g1t");
2508+ }
2509+}
+6−0
3232 /// stay with the project.
3333 pub const PURGED: &[&str] = &[
3434 "DELETE FROM logs WHERE job_id IN (SELECT id FROM jobs WHERE repo_id = ?1)",
35+ // Earlier attempts' logs and summaries, kept under their own ids.
36+ "DELETE FROM logs WHERE job_id IN (SELECT id FROM job_attempts WHERE repo_id = ?1)",
37+ "DELETE FROM job_summaries WHERE job_id IN (SELECT id FROM jobs WHERE repo_id = ?1)",
38+ "DELETE FROM job_summaries WHERE job_id IN (SELECT id FROM job_attempts WHERE repo_id = ?1)",
39+ "DELETE FROM job_attempts WHERE repo_id = ?1",
40+ "DELETE FROM run_attempts WHERE repo_id = ?1",
3541 "DELETE FROM jobs WHERE repo_id = ?1",
3642 "DELETE FROM runs WHERE repo_id = ?1",
3743 "DELETE FROM workflows WHERE repo_id = ?1",
+274−22
1−//! Reading: workflows, runs, a run's jobs, and a job's log.
1+//! Reading: workflows, runs (any attempt), a run's jobs, a job's log and
2+//! its steps' summaries.
23
34 use g1t_actions::workflow::{self, Severity};
45 use g1t_contracts::access::Capability;
56 use g1t_contracts::checks::{ActionsChecksArgs, MAX_COMMITS};
67 use g1t_contracts::actions::{
7− Annotation, Job, JobLog, LogChunk, LogsArgs, RunArgs, RunDetail, RunsArgs, SetWorkflowEnabledArgs, StepState, Workflow, WorkflowNote,
8− WorkflowRun, WorkflowsArgs,
8+ Annotation, Job, JobLog, JobLogText, JobLogTextArgs, JobSummary, LogChunk, LogsArgs, MAX_RUN_LOG_BYTES, RunArgs, RunAttempt, RunDetail,
9+ RunLogsArgs, RunsArgs, SetWorkflowEnabledArgs, StepState, StepSummary, SummariesArgs, Workflow, WorkflowNote, WorkflowRun, WorkflowsArgs,
910 };
1011 use g1t_contracts::{FailureCode, Outcome};
1112 use serde::Deserialize;
3738 .unwrap_or_default()
3839 }
3940
41+/// A job of an earlier attempt, as it ended (migration 0008).
42+#[derive(Clone, Deserialize)]
43+pub struct AttemptJobRow {
44+ pub id: String,
45+ pub run_id: String,
46+ pub log_id: String,
47+ pub key: String,
48+ pub name: String,
49+ pub needs: String,
50+ pub status: String,
51+ pub conclusion: Option<String>,
52+ pub steps: String,
53+ pub annotations: String,
54+ pub reason: Option<String>,
55+ pub environment: Option<String>,
56+ pub labels: Option<String>,
57+ pub runner_name: Option<String>,
58+ pub started_at: Option<String>,
59+ pub finished_at: Option<String>,
60+}
61+
62+fn attempt_job_view(row: AttemptJobRow) -> Job {
63+ Job {
64+ needs: serde_json::from_str(&row.needs).unwrap_or_default(),
65+ id: row.id,
66+ run_id: row.run_id,
67+ key: row.key,
68+ name: row.name,
69+ status: row.status,
70+ conclusion: row.conclusion,
71+ steps: serde_json::from_str::<Vec<StepState>>(&row.steps).unwrap_or_default(),
72+ annotations: serde_json::from_str::<Vec<Annotation>>(&row.annotations).unwrap_or_default(),
73+ reason: row.reason,
74+ started_at: row.started_at,
75+ finished_at: row.finished_at,
76+ environment: row.environment,
77+ self_hosted: row.labels.is_some(),
78+ runner: row.runner_name,
79+ cancelling: false,
80+ }
81+}
82+
83+/// An earlier attempt of a run, as it ended.
84+#[derive(Clone, Deserialize)]
85+struct AttemptRow {
86+ attempt: u64,
87+ conclusion: Option<String>,
88+ actor: Option<String>,
89+ debug: u32,
90+ started_at: Option<String>,
91+ finished_at: Option<String>,
92+}
93+
94+/// One attempt's jobs, each with where its logs and summary are kept.
95+struct AttemptJobs {
96+ jobs: Vec<Job>,
97+ log_ids: Vec<String>,
98+}
99+
40100 fn job_view(row: JobRow) -> Job {
41101 let needs = row.needs();
102+ let cancelling = row.cancel_requested_at.is_some() && row.status != "completed";
42103 Job {
104+ cancelling,
43105 id: row.id,
44106 run_id: row.run_id,
45107 key: row.key,
154216 Ok(Outcome::Ok(rows.iter().map(RunRow::summary).collect()))
155217 }
156218
219+ /// Every attempt of a run, oldest first, the current one last.
220+ async fn attempts_of(&self, run: &RunRow) -> Result<Vec<RunAttempt>> {
221+ let mut attempts: Vec<RunAttempt> = self
222+ .db
223+ .prepare("SELECT attempt, conclusion, actor, debug, started_at, finished_at FROM run_attempts WHERE run_id = ? ORDER BY attempt")
224+ .bind(&[run.id.as_str().into()])?
225+ .all()
226+ .await?
227+ .results::<AttemptRow>()?
228+ .into_iter()
229+ .filter(|row| row.attempt < run.attempt)
230+ .map(|row| RunAttempt {
231+ attempt: row.attempt,
232+ status: "completed".to_owned(),
233+ conclusion: row.conclusion,
234+ actor: row.actor,
235+ debug: row.debug != 0,
236+ started_at: row.started_at,
237+ finished_at: row.finished_at,
238+ })
239+ .collect();
240+ attempts.push(RunAttempt {
241+ attempt: run.attempt,
242+ status: run.status.clone(),
243+ conclusion: run.conclusion.clone().filter(|_| run.status == "completed"),
244+ actor: run.triggering_actor.clone().or_else(|| run.actor.clone()),
245+ debug: run.debug != 0,
246+ started_at: run.started_at.clone(),
247+ finished_at: run.finished_at.clone(),
248+ });
249+ Ok(attempts)
250+ }
251+
252+ /// The jobs of one attempt: the live ones for the current attempt, the
253+ /// kept ones for an earlier attempt. None for an attempt it never had.
254+ async fn attempt_jobs(&self, run: &RunRow, attempt: Option<u64>) -> Result<Option<AttemptJobs>> {
255+ match attempt.filter(|n| *n != run.attempt) {
256+ None => {
257+ let jobs: Vec<Job> = self.job_rows(&run.id).await?.into_iter().map(job_view).collect();
258+ let log_ids = jobs.iter().map(|job| job.id.clone()).collect();
259+ Ok(Some(AttemptJobs { jobs, log_ids }))
260+ }
261+ Some(n) if n == 0 || n > run.attempt => Ok(None),
262+ Some(n) => {
263+ let rows = self
264+ .db
265+ .prepare("SELECT * FROM job_attempts WHERE run_id = ? AND attempt = ? ORDER BY rowid")
266+ .bind(&[run.id.as_str().into(), (n as f64).into()])?
267+ .all()
268+ .await?
269+ .results::<AttemptJobRow>()?;
270+ let log_ids = rows.iter().map(|row| row.log_id.clone()).collect();
271+ Ok(Some(AttemptJobs { jobs: rows.into_iter().map(attempt_job_view).collect(), log_ids }))
272+ }
273+ }
274+ }
275+
157276 pub async fn run(&self, a: RunArgs) -> Result<Outcome<RunDetail>> {
158277 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
159278 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
160279 }
161280 let run = check!(self.run_in(&a.repo, &a.id).await?);
162− let jobs: Vec<Job> = self.job_rows(&run.id).await?.into_iter().map(job_view).collect();
163− let pending_deployments = self.pending_for(&run, &a.viewer).await?;
281+ let Some(AttemptJobs { jobs, .. }) = self.attempt_jobs(&run, a.attempt).await? else {
282+ return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
283+ };
284+ let attempts = self.attempts_of(&run).await?;
164285 let mut summary = run.summary();
286+ // An earlier attempt: as it ended, and nothing waits for it.
287+ if let Some(earlier) = a.attempt.filter(|n| *n != run.attempt).and_then(|n| attempts.iter().find(|at| at.attempt == n)) {
288+ summary.attempt = earlier.attempt;
289+ summary.status = "completed".to_owned();
290+ summary.conclusion = earlier.conclusion.clone();
291+ summary.started_at = earlier.started_at.clone();
292+ summary.finished_at = earlier.finished_at.clone();
293+ return Ok(Outcome::Ok(RunDetail {
294+ notes: notes(&run.source),
295+ run: summary,
296+ jobs,
297+ approval: run.approval(),
298+ pending_deployments: Vec::new(),
299+ attempts,
300+ }));
301+ }
302+ let pending_deployments = self.pending_for(&run, &a.viewer).await?;
165303 // Jobs held at an environment's rules: the run waits, as on GitHub.
166304 if matches!(summary.status.as_str(), "queued" | "in_progress") && jobs.iter().any(|job| job.status == "pending") {
167305 summary.status = "waiting".to_owned();
172310 jobs,
173311 approval: run.approval(),
174312 pending_deployments,
313+ attempts,
175314 }))
176315 }
177316
178− pub async fn logs(&self, a: LogsArgs) -> Result<Outcome<JobLog>> {
179− if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
180− return Ok(fail(FailureCode::NotFound, "There is no such repository."));
181− }
182− let job = self
317+ /// A job of the repository's runs by the id a run's page gives it: a
318+ /// live job, or one of an earlier attempt. Its name, steps, where its
319+ /// logs and summary are kept, and whether it has finished.
320+ async fn job_for_log(&self, repo: &g1t_contracts::repos::RepoPath, id: &str) -> Result<Option<(Job, String, bool)>> {
321+ let full = format!("{}/{}", repo.namespace, repo.name);
322+ let live = self
183323 .db
184324 .prepare("SELECT jobs.* FROM jobs JOIN runs ON runs.id = jobs.run_id WHERE jobs.id = ? AND lower(runs.repo) = lower(?)")
185− .bind(&[a.job.as_str().into(), format!("{}/{}", a.repo.namespace, a.repo.name).into()])?
325+ .bind(&[id.into(), full.as_str().into()])?
186326 .first::<JobRow>(None)
187327 .await?;
188− let Some(job) = job else {
189− return Ok(fail(FailureCode::NotFound, "No such job."));
190− };
328+ if let Some(job) = live {
329+ let done = job.status == "completed";
330+ let view = job_view(job);
331+ let log_id = view.id.clone();
332+ return Ok(Some((view, log_id, done)));
333+ }
334+ let kept = self
335+ .db
336+ .prepare(
337+ "SELECT job_attempts.* FROM job_attempts JOIN runs ON runs.id = job_attempts.run_id
338+ WHERE job_attempts.id = ? AND lower(runs.repo) = lower(?)",
339+ )
340+ .bind(&[id.into(), full.as_str().into()])?
341+ .first::<AttemptJobRow>(None)
342+ .await?;
343+ Ok(kept.map(|row| {
344+ let log_id = row.log_id.clone();
345+ (attempt_job_view(row), log_id, true)
346+ }))
347+ }
348+
349+ async fn log_chunks(&self, log_id: &str, after: u64, limit: u32) -> Result<Vec<LogChunk>> {
191350 #[derive(Deserialize)]
192351 struct Row {
193352 seq: u64,
194353 step: u32,
195354 text: String,
196355 }
197− let chunks = self
356+ Ok(self
198357 .db
199− .prepare("SELECT seq, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 500")
200− .bind(&[job.id.as_str().into(), (a.after as f64).into()])?
358+ .prepare("SELECT seq, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT ?")
359+ .bind(&[log_id.into(), (after as f64).into(), limit.into()])?
201360 .all()
202361 .await?
203362 .results::<Row>()?
204363 .into_iter()
205364 .map(|row| LogChunk { seq: row.seq, step: row.step, text: row.text })
206− .collect();
207− Ok(Outcome::Ok(JobLog {
208− chunks,
209− done: job.status == "completed",
210− }))
365+ .collect())
366+ }
367+
368+ /// Every chunk of a job's log, a page at a time.
369+ async fn whole_log(&self, log_id: &str) -> Result<Vec<LogChunk>> {
370+ let mut chunks: Vec<LogChunk> = Vec::new();
371+ loop {
372+ let after = chunks.last().map_or(0, |chunk| chunk.seq);
373+ let page = self.log_chunks(log_id, after, 500).await?;
374+ let more = page.len() == 500;
375+ chunks.extend(page);
376+ if !more {
377+ return Ok(chunks);
378+ }
379+ }
380+ }
381+
382+ pub async fn logs(&self, a: LogsArgs) -> Result<Outcome<JobLog>> {
383+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
384+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
385+ }
386+ let Some((_, log_id, done)) = self.job_for_log(&a.repo, &a.job).await? else {
387+ return Ok(fail(FailureCode::NotFound, "No such job."));
388+ };
389+ let chunks = self.log_chunks(&log_id, a.after, 500).await?;
390+ Ok(Outcome::Ok(JobLog { chunks, done }))
391+ }
392+
393+ /// `job_log_text`: a job's whole log, to download.
394+ pub async fn job_log_text(&self, a: JobLogTextArgs) -> Result<Outcome<JobLogText>> {
395+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
396+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
397+ }
398+ let Some((job, log_id, done)) = self.job_for_log(&a.repo, &a.job).await? else {
399+ return Ok(fail(FailureCode::NotFound, "No such job."));
400+ };
401+ let chunks = self.whole_log(&log_id).await?;
402+ Ok(Outcome::Ok(JobLogText { job_id: job.id, name: job.name, steps: job.steps, chunks, done, omitted: false }))
403+ }
404+
405+ /// `run_logs`: every job's whole log for one attempt, to download as one
406+ /// archive.
407+ pub async fn run_logs(&self, a: RunLogsArgs) -> Result<Outcome<Vec<JobLogText>>> {
408+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
409+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
410+ }
411+ let run = check!(self.run_in(&a.repo, &a.id).await?);
412+ let Some(AttemptJobs { jobs, log_ids }) = self.attempt_jobs(&run, a.attempt).await? else {
413+ return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
414+ };
415+ let mut out = Vec::with_capacity(jobs.len());
416+ let mut total = 0usize;
417+ for (job, log_id) in jobs.into_iter().zip(log_ids) {
418+ let done = job.status == "completed";
419+ let omitted = total >= MAX_RUN_LOG_BYTES;
420+ let chunks = if omitted { Vec::new() } else { self.whole_log(&log_id).await? };
421+ total += chunks.iter().map(|chunk| chunk.text.len()).sum::<usize>();
422+ out.push(JobLogText { job_id: job.id, name: job.name, steps: job.steps, chunks, done, omitted });
423+ }
424+ Ok(Outcome::Ok(out))
425+ }
426+
427+ /// `summaries`: what each job's steps wrote to `$GITHUB_STEP_SUMMARY`.
428+ pub async fn summaries(&self, a: SummariesArgs) -> Result<Outcome<Vec<JobSummary>>> {
429+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
430+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
431+ }
432+ let run = check!(self.run_in(&a.repo, &a.id).await?);
433+ let Some(AttemptJobs { jobs, log_ids }) = self.attempt_jobs(&run, a.attempt).await? else {
434+ return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
435+ };
436+ #[derive(Deserialize)]
437+ struct Row {
438+ job_id: String,
439+ step: u32,
440+ markdown: String,
441+ }
442+ let rows = self
443+ .db
444+ .prepare("SELECT job_id, step, markdown FROM job_summaries WHERE job_id IN (SELECT value FROM json_each(?)) ORDER BY step")
445+ .bind(&[serde_json::to_string(&log_ids)?.into()])?
446+ .all()
447+ .await?
448+ .results::<Row>()?;
449+ Ok(Outcome::Ok(
450+ jobs.into_iter()
451+ .zip(log_ids)
452+ .filter_map(|(job, log_id)| {
453+ let steps: Vec<StepSummary> = rows
454+ .iter()
455+ .filter(|row| row.job_id == log_id)
456+ .map(|row| StepSummary { step: row.step, markdown: row.markdown.clone() })
457+ .collect();
458+ (!steps.is_empty()).then_some(JobSummary { job_id: job.id, name: job.name, steps })
459+ })
460+ .collect(),
461+ ))
211462 }
212463
213464 pub async fn set_workflow_enabled(&self, a: SetWorkflowEnabledArgs) -> Result<Outcome<Workflow>> {
291542 notes: Vec::new(),
292543 approval: run.approval(),
293544 pending_deployments: Vec::new(),
545+ attempts: Vec::new(),
294546 })
295547 .collect())
296548 }
+1−1
845845 let rerun: Outcome<Value> = g1t_kit::call(
846846 &self.actions,
847847 "rerun",
848− &RunActionArgs { actor: actor.clone(), repo: repo.clone(), id: run_id.to_owned(), failed_only: false },
848+ &RunActionArgs { actor: actor.clone(), repo: repo.clone(), id: run_id.to_owned(), failed_only: false, job: None, debug: false, force: false },
849849 )
850850 .await?;
851851 Ok(match rerun {
+1−1
950950 let detail: Outcome<RunDetail> = g1t_kit::call(
951951 &self.actions,
952952 "run",
953− &RunArgs { repo: repo.clone(), viewer: viewer.clone(), id: run_id },
953+ &RunArgs { repo: repo.clone(), viewer: viewer.clone(), id: run_id, attempt: None },
954954 )
955955 .await?;
956956 let Outcome::Ok(detail) = detail else { continue };