A run that is still going offers Cancel run, and not the re-runs, on every page of it. Which buttons a run's page shows is worked out in one place (runControls, lib/run-controls.ts): the run is going while its latest attempt is, as the attempt list's last entry says, whichever attempt the page shows, and on the latest attempt a job that has not finished (waiting for the jobs it needs, held for an environment's approval, queued for a runner, running) keeps it going whatever the run's own status says. So an earlier attempt's page, and its ?job= links, offer Cancel run while a later attempt runs, where they offered nothing, and keep refreshing until it ends; the re-runs show only on the latest attempt of a finished run. A page left open on a finished run asked for nothing and heard nothing when the run was re-run from somewhere else (the API, the MCP tool, another tab): it kept showing the attempt that had ended, with its Re-run buttons and no Cancel run, until a job of the new attempt started or finished. A re-run now tells pages open on the run, as a job starting or finishing does. The run view's status for its current attempt is read by one function (shown_status), with tests that a job queued for a self-hosted runner leaves its run in progress and a job held at an environment makes it wait. The Actions guide says when Cancel run shows.
6 files+189−130/6 viewed
| 1374 | 1374 | ||
| 1375 | 1375 | ### Cancelling | |
| 1376 | 1376 | ||
| 1377 | − | **Cancel run** cancels jobs that have not started at once. A job that is | |
| 1378 | − | running is stopped the way GitHub stops one: | |
| 1377 | + | **Cancel run** is at the top of the run's page while any job of its latest | |
| 1378 | + | attempt has not finished: waiting for the jobs it needs, held for an | |
| 1379 | + | environment's approval, queued for a runner (a self-hosted job waits | |
| 1380 | + | until a runner with its labels takes it), or running. It is there on an | |
| 1381 | + | earlier attempt's page too, and on a job's, and the re-run buttons appear | |
| 1382 | + | only once the run has finished. A page left open on a finished run hears | |
| 1383 | + | when it is re-run, from anywhere, and offers Cancel run. | |
| 1384 | + | ||
| 1385 | + | It cancels jobs that have not started at once. A job that is running is | |
| 1386 | + | stopped the way GitHub stops one: | |
| 1379 | 1387 | ||
| 1380 | 1388 | 1. The step it is on gets `SIGINT`, then `SIGTERM` 7.5 seconds later, and | |
| 1381 | 1389 | is killed 2.5 seconds after that. Signals reach the processes the step |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts"; | |
| 5 | + | import { runControls } from "./run-controls.ts"; | |
| 6 | + | ||
| 7 | + | type Run = Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">; | |
| 8 | + | type Attempt = Pick<RunAttempt, "attempt" | "status">; | |
| 9 | + | ||
| 10 | + | const run = (more: Partial<Run> = {}): Run => ({ attempt: 1, status: "completed", conclusion: "success", error: null, ...more }); | |
| 11 | + | const jobs = (...statuses: Job["status"][]) => statuses.map((status) => ({ status })); | |
| 12 | + | /** Attempts 1 to `latest`, the earlier ones finished, the latest as `status`. */ | |
| 13 | + | function attempts(latest: number, status: Attempt["status"]): Attempt[] { | |
| 14 | + | return Array.from({ length: latest }, (_, i) => ({ attempt: i + 1, status: i + 1 === latest ? status : "completed" })); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | test("a re-run of failed jobs whose job waits for a self-hosted runner can be cancelled, not re-run", () => { | |
| 18 | + | // Attempt 6 of 6: its first job succeeded, the one that needs it is queued. | |
| 19 | + | const controls = runControls({ | |
| 20 | + | run: run({ attempt: 6, status: "in_progress", conclusion: null }), | |
| 21 | + | jobs: jobs("completed", "queued"), | |
| 22 | + | attempts: attempts(6, "in_progress"), | |
| 23 | + | member: true, | |
| 24 | + | }); | |
| 25 | + | assert.deepEqual(controls, { latest: true, going: true, cancelling: false, canCancel: true, canRerun: false }); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("an earlier attempt's page offers Cancel run while the latest attempt runs", () => { | |
| 29 | + | // `?attempt=5`: attempt 5 as it ended, every job finished. | |
| 30 | + | const controls = runControls({ | |
| 31 | + | run: run({ attempt: 5, status: "completed", conclusion: "failure" }), | |
| 32 | + | jobs: jobs("completed", "completed"), | |
| 33 | + | attempts: attempts(6, "in_progress"), | |
| 34 | + | member: true, | |
| 35 | + | }); | |
| 36 | + | assert.equal(controls.latest, false); | |
| 37 | + | assert.equal(controls.going, true); | |
| 38 | + | assert.equal(controls.canCancel, true); | |
| 39 | + | assert.equal(controls.canRerun, false); | |
| 40 | + | // How attempt 5 ended says nothing of attempt 6 being cancelled. | |
| 41 | + | assert.equal(runControls({ run: run({ attempt: 5, conclusion: "cancelled" }), jobs: [], attempts: attempts(6, "queued"), member: true }).cancelling, false); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("a job not finished keeps the latest attempt going, whatever the run's own status says", () => { | |
| 45 | + | for (const status of ["waiting", "pending", "queued", "in_progress", "calling"] as const) { | |
| 46 | + | const controls = runControls({ run: run({ attempt: 2, status: "completed" }), jobs: jobs("completed", status), attempts: attempts(2, "completed"), member: true }); | |
| 47 | + | assert.equal(controls.canCancel, true, status); | |
| 48 | + | assert.equal(controls.canRerun, false, status); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test("every status a run waits or runs in can be cancelled", () => { | |
| 53 | + | for (const status of ["pending", "action_required", "queued", "in_progress", "waiting"] as const) { | |
| 54 | + | const controls = runControls({ run: run({ status, conclusion: null }), jobs: [], attempts: attempts(1, status), member: true }); | |
| 55 | + | assert.equal(controls.canCancel, true, status); | |
| 56 | + | assert.equal(controls.canRerun, false, status); | |
| 57 | + | } | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("a finished run is re-run from its latest attempt only", () => { | |
| 61 | + | const latest = runControls({ run: run({ attempt: 2, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true }); | |
| 62 | + | assert.deepEqual(latest, { latest: true, going: false, cancelling: false, canCancel: false, canRerun: true }); | |
| 63 | + | const earlier = runControls({ run: run({ attempt: 1, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true }); | |
| 64 | + | assert.equal(earlier.canRerun, false); | |
| 65 | + | assert.equal(earlier.canCancel, false); | |
| 66 | + | // A run of one attempt has no list to look through. | |
| 67 | + | assert.equal(runControls({ run: run(), jobs: jobs("completed"), attempts: [], member: true }).canRerun, true); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("a run being cancelled shows Force cancel", () => { | |
| 71 | + | const controls = runControls({ run: run({ status: "in_progress", conclusion: "cancelled" }), jobs: jobs("in_progress"), attempts: attempts(1, "in_progress"), member: true }); | |
| 72 | + | assert.equal(controls.cancelling, true); | |
| 73 | + | assert.equal(controls.canCancel, true); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test("neither without Write, nor for a run whose workflow file could not be used", () => { | |
| 77 | + | const going = { run: run({ status: "in_progress", conclusion: null }), jobs: jobs("queued"), attempts: attempts(1, "in_progress") }; | |
| 78 | + | assert.equal(runControls({ ...going, member: false }).canCancel, false); | |
| 79 | + | const broken = runControls({ run: run({ conclusion: "failure", error: "Its file is not YAML." }), jobs: [], attempts: [], member: true }); | |
| 80 | + | assert.equal(broken.canCancel, false); | |
| 81 | + | assert.equal(broken.canRerun, false); | |
| 82 | + | }); |
| 1 | + | /** | |
| 2 | + | * Which of a run's buttons its page shows: Cancel run (or Force cancel) | |
| 3 | + | * while any of it is still going, the re-runs once it has finished. Pure; | |
| 4 | + | * the page is routes/repo/actions-run.tsx. | |
| 5 | + | * | |
| 6 | + | * A run is going while its latest attempt is: the attempt list's last | |
| 7 | + | * entry says how it stands now, whichever attempt the page shows, so an | |
| 8 | + | * earlier attempt's page (`?attempt=2`, and its `?job=` links) offers | |
| 9 | + | * Cancel run while a later one runs. On the latest attempt a job that has | |
| 10 | + | * not finished (waiting, pending approval, queued for a runner, running) | |
| 11 | + | * keeps the run going too, whatever the run's own status says. | |
| 12 | + | */ | |
| 13 | + | import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts"; | |
| 14 | + | ||
| 15 | + | export type RunControls = { | |
| 16 | + | /** The attempt shown is the run's latest. */ | |
| 17 | + | latest: boolean; | |
| 18 | + | /** The run, at its latest attempt, has not finished. */ | |
| 19 | + | going: boolean; | |
| 20 | + | /** Cancelled, while its jobs run their cleanup steps: Force cancel in place of Cancel run. */ | |
| 21 | + | cancelling: boolean; | |
| 22 | + | canCancel: boolean; | |
| 23 | + | canRerun: boolean; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | export function runControls(input: { | |
| 27 | + | run: Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">; | |
| 28 | + | jobs: Pick<Job, "status">[]; | |
| 29 | + | attempts: Pick<RunAttempt, "attempt" | "status">[]; | |
| 30 | + | /** The viewer may cancel and re-run (Write). */ | |
| 31 | + | member: boolean; | |
| 32 | + | }): RunControls { | |
| 33 | + | const { run, jobs, attempts, member } = input; | |
| 34 | + | const newest = attempts[attempts.length - 1]; | |
| 35 | + | const latest = newest == null || run.attempt === newest.attempt; | |
| 36 | + | const going = latest ? run.status !== "completed" || jobs.some((job) => job.status !== "completed") : newest.status !== "completed"; | |
| 37 | + | // Only the latest attempt's own status says it is being cancelled; an | |
| 38 | + | // earlier attempt's conclusion is how that attempt ended. | |
| 39 | + | const cancelling = going && latest && run.conclusion === "cancelled"; | |
| 40 | + | const allowed = member && !run.error; | |
| 41 | + | return { latest, going, cancelling, canCancel: allowed && going, canRerun: allowed && !going && latest }; | |
| 42 | + | } |
| 57 | 57 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 58 | 58 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 59 | 59 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 60 | + | import { runControls } from "../../lib/run-controls"; | |
| 60 | 61 | import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph"; | |
| 61 | 62 | import { repoPages } from "../../lib/repo-live"; | |
| 62 | 63 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| ⋯ | |||
| 1222 | 1223 | const { run, jobs, notes } = detail; | |
| 1223 | 1224 | const deployments = detail.pendingDeployments ?? []; | |
| 1224 | 1225 | const attempts = detail.attempts ?? []; | |
| 1225 | − | const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt; | |
| 1226 | 1226 | const base = `/${params.owner}/${params.repo}`; | |
| 1227 | 1227 | const [search] = useSearchParams(); | |
| 1228 | 1228 | // One of the run's buttons is working: the others wait for it. | |
| 1229 | 1229 | const busy = usePending(); | |
| 1230 | + | // The attempt shown is still going. | |
| 1230 | 1231 | const live = run.status !== "completed"; | |
| 1231 | − | // Cancelled, while its jobs run their cleanup steps. | |
| 1232 | − | const cancelling = live && run.conclusion === "cancelled"; | |
| 1233 | − | // Re-running is for the latest attempt of a finished run. | |
| 1234 | − | const canRerun = member && !run.error && !live && latest; | |
| 1232 | + | // Cancel run while any of the run is going, from whichever attempt's | |
| 1233 | + | // page; re-running is for the latest attempt of a finished run. | |
| 1234 | + | const { latest, going, cancelling, canCancel, canRerun } = runControls({ run, jobs, attempts, member }); | |
| 1235 | 1235 | // A mirror standing by runs nothing; in CI failover it runs them again. | |
| 1236 | 1236 | const rerunBlocked = useWorkflowReason(); | |
| 1237 | 1237 | const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`; | |
| ⋯ | |||
| 1240 | 1240 | // streams on its own), every few seconds without it. Waiting on a person | |
| 1241 | 1241 | // needs no quick refresh. | |
| 1242 | 1242 | const { live: heard } = useRepoLive({ match: repoPages.run(run.id, run.sha) }); | |
| 1243 | − | useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500); | |
| 1243 | + | useRefreshWhile(going, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500); | |
| 1244 | 1244 | ||
| 1245 | 1245 | // The job asked for; none is the run's summary. | |
| 1246 | 1246 | const selected = jobs.find((job) => job.id === search.get("job")) ?? null; | |
| ⋯ | |||
| 1275 | 1275 | </Button> | |
| 1276 | 1276 | </Hint> | |
| 1277 | 1277 | )} | |
| 1278 | − | {member && !run.error && live && ( | |
| 1278 | + | {canCancel && ( | |
| 1279 | 1279 | <Form method="post" className="flex gap-2"> | |
| 1280 | 1280 | {cancelling ? ( | |
| 1281 | 1281 | <Hint label="Stop its jobs now, without waiting for their cleanup steps"> | |
| 2227 | 2227 | self.report_pending(&run).await?; | |
| 2228 | 2228 | } | |
| 2229 | 2229 | self.advance(&run.id).await?; | |
| 2230 | + | // A page left open on the attempt that ended, which asks for nothing | |
| 2231 | + | // while its run is over, hears that it is going again: re-run from | |
| 2232 | + | // the API, the MCP tool, a pull request or another tab, as well as | |
| 2233 | + | // from that page, it offers Cancel run and not the re-runs. | |
| 2234 | + | self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await; | |
| 2230 | 2235 | self.run_summary(&run.id).await | |
| 2231 | 2236 | } | |
| 2232 | 2237 |
| 322 | 322 | })); | |
| 323 | 323 | } | |
| 324 | 324 | let pending_deployments = self.pending_for(&run, &a.viewer).await?; | |
| 325 | − | // Jobs held at an environment's rules: the run waits, as on GitHub. | |
| 326 | − | if matches!(summary.status.as_str(), "queued" | "in_progress") && jobs.iter().any(|job| job.status == "pending") { | |
| 327 | − | summary.status = "waiting".to_owned(); | |
| 328 | − | } | |
| 325 | + | summary.status = shown_status(&summary.status, jobs.iter().map(|job| job.status.as_str())); | |
| 329 | 326 | Ok(Outcome::Ok(RunDetail { | |
| 330 | 327 | notes: notes(&run.source), | |
| 331 | 328 | run: summary, | |
| ⋯ | |||
| 580 | 577 | ||
| 581 | 578 | /// The most runs `check_runs` reads for a set of commits. | |
| 582 | 579 | const CHECK_RUNS_LIMIT: u32 = 200; | |
| 580 | + | ||
| 581 | + | /// How the run's current attempt stands on its page: as its row says, and | |
| 582 | + | /// `waiting`, as on GitHub, while a job is held at an environment's rules. | |
| 583 | + | /// A job queued for a runner, or waiting for the jobs it needs, leaves a | |
| 584 | + | /// run that has started `in_progress`: it is still going. | |
| 585 | + | fn shown_status<'a>(status: &str, mut jobs: impl Iterator<Item = &'a str>) -> String { | |
| 586 | + | if matches!(status, "queued" | "in_progress") && jobs.any(|job| job == "pending") { | |
| 587 | + | "waiting".to_owned() | |
| 588 | + | } else { | |
| 589 | + | status.to_owned() | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | #[cfg(test)] | |
| 594 | + | mod tests { | |
| 595 | + | use super::*; | |
| 596 | + | ||
| 597 | + | fn shown(status: &str, jobs: &[&str]) -> String { | |
| 598 | + | shown_status(status, jobs.iter().copied()) | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | #[test] | |
| 602 | + | fn a_job_queued_for_a_self_hosted_runner_keeps_its_run_going() { | |
| 603 | + | // A re-run of failed jobs: the job that failed now succeeded, and the | |
| 604 | + | // one that needs it waits for a runner with labels no runner has. | |
| 605 | + | assert_eq!(shown("in_progress", &["completed", "queued"]), "in_progress"); | |
| 606 | + | assert_eq!(shown("queued", &["waiting", "waiting"]), "queued"); | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | #[test] | |
| 610 | + | fn a_job_held_at_an_environment_makes_its_run_wait() { | |
| 611 | + | assert_eq!(shown("in_progress", &["completed", "pending"]), "waiting"); | |
| 612 | + | assert_eq!(shown("queued", &["pending"]), "waiting"); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | #[test] | |
| 616 | + | fn a_finished_or_held_run_reads_as_its_row_says() { | |
| 617 | + | assert_eq!(shown("completed", &["completed"]), "completed"); | |
| 618 | + | assert_eq!(shown("action_required", &["waiting"]), "action_required"); | |
| 619 | + | assert_eq!(shown("pending", &["pending"]), "pending"); | |
| 620 | + | } | |
| 621 | + | } | |