Skip to content

Commit

Merge the run page fixes: Cancel run shows on every view of a run that is still going, including an earlier attempt's and job links, and pages refresh when a run is re-run from anywhere; a job's log follows to its true end, above anything covering the bottom of the window, and Jump to latest lands there.

syntaqxcommitted Parents7a0d243973174eBrowse files
8 files+303−280/8 viewed
+14−4
12171217 page asks for more every second while a step is running.
12181218
12191219 - The running step opens by itself, and its timer counts up. Its log
1220− follows the newest line while the end of it is on screen. Scroll up and
1221− it stays where you are, with **Jump to latest** to go back to the end.
1220+ follows the newest line while the end of it is on screen, with the last
1221+ line wholly in view (above the tab bar on a phone). Scroll up and it
1222+ stays where you are, with **Jump to latest** to go back to the end and
1223+ follow it again.
12221224 - A step that prints nothing for a while says so under its last line:
12231225 **Running · no output for 2m 10s**, or **no output yet** before its
12241226 first line. The job's header names the step it is on.
13741376
13751377 ### Cancelling
13761378
1377−**Cancel run** cancels jobs that have not started at once. A job that is
1378−running is stopped the way GitHub stops one:
1379+**Cancel run** is at the top of the run's page while any job of its latest
1380+attempt has not finished: waiting for the jobs it needs, held for an
1381+environment's approval, queued for a runner (a self-hosted job waits
1382+until a runner with its labels takes it), or running. It is there on an
1383+earlier attempt's page too, and on a job's, and the re-run buttons appear
1384+only once the run has finished. A page left open on a finished run hears
1385+when it is re-run, from anywhere, and offers Cancel run.
1386+
1387+It cancels jobs that have not started at once. A job that is running is
1388+stopped the way GitHub stops one:
13791389
13801390 1. The step it is on gets `SIGINT`, then `SIGTERM` 7.5 seconds later, and
13811391 is killed 2.5 seconds after that. Signals reach the processes the step
+32−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { FOLLOW_SLACK, endPlace } from "./log-follow.ts";
5+
6+const end = (bottom: number) => ({ top: bottom - 1, bottom, width: 600, height: 1 });
7+
8+test("the end above the window's bottom edge is in view", () => {
9+ assert.equal(endPlace(end(700), { height: 800, covered: 12 }), "here");
10+ assert.equal(endPlace(end(788), { height: 800, covered: 12 }), "here");
11+ // Scrolled past it, the latest is still on screen.
12+ assert.equal(endPlace(end(-40), { height: 800, covered: 12 }), "here");
13+});
14+
15+test("an end one row below the edge is not reached: following stops there", () => {
16+ // Before, anything within 160 px below the window counted, so a log
17+ // could stop with its last lines cut off and no Jump to latest.
18+ assert.equal(endPlace(end(800 - 12 + 20), { height: 800, covered: 12 }), "below");
19+ assert.equal(endPlace(end(800 + 20), { height: 800, covered: 0 }), "below");
20+ assert.equal(endPlace(end(800 + FOLLOW_SLACK), { height: 800, covered: 0 }), "here");
21+});
22+
23+test("what covers the bottom of the window hides the end: a phone's tab bar", () => {
24+ // 56 px of tab bar and 12 px of room: an end at 760 is under the bar.
25+ assert.equal(endPlace(end(760), { height: 812, covered: 68 }), "below");
26+ assert.equal(endPlace(end(744), { height: 812, covered: 68 }), "here");
27+});
28+
29+test("a folded step has no end to follow", () => {
30+ assert.equal(endPlace(null, { height: 800, covered: 0 }), "folded");
31+ assert.equal(endPlace({ top: 0, bottom: 0, width: 0, height: 0 }, { height: 800, covered: 0 }), "folded");
32+});
+25−0
1+/**
2+ * Following a running step's log: where the end of the log is against what
3+ * the viewer can see. Pure; the hook that uses it is `useFollow` in
4+ * routes/repo/actions-run.tsx.
5+ *
6+ * The end of the log is a marker after its last line. What covers the
7+ * bottom of the window (a phone's tab bar, and room to breathe under the
8+ * last line) is the marker's `scroll-margin-bottom`, so scrolling it into
9+ * view and judging whether it is in view read the same number.
10+ */
11+
12+/** How far past what can be seen the end may be and still count as reached: rounding, not a row. */
13+export const FOLLOW_SLACK = 2;
14+
15+export type EndBox = { top: number; bottom: number; width: number; height: number };
16+
17+/**
18+ * Where the end of the log is: folded away (a closed step draws nothing),
19+ * in view above whatever covers the window's bottom (the viewer is
20+ * reading the latest), or below it.
21+ */
22+export function endPlace(end: EndBox | null, view: { height: number; covered: number }): "folded" | "here" | "below" {
23+ if (!end || (end.width === 0 && end.height === 0)) return "folded";
24+ return end.bottom <= view.height - view.covered + FOLLOW_SLACK ? "here" : "below";
25+}
+82−0
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+});
+42−0
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+}
+60−20
5757 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
5858 import { accessTo, refusal } from "../../lib/access.server";
5959 import { useRefreshWhile } from "../../lib/refresh";
60+import { runControls } from "../../lib/run-controls";
61+import { endPlace } from "../../lib/log-follow";
6062 import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
6163 import { repoPages } from "../../lib/repo-live";
6264 import { useRepoLive } from "../../lib/use-repo-live";
197199 {quiet}
198200 </p>
199201 )}
200− {running && <div ref={end} aria-hidden className="h-px" />}
202+ {/* The end of the log: kept clear of what covers the window's bottom (a phone's tab bar), with room under the last line. */}
203+ {running && <div ref={end} aria-hidden className="h-px scroll-mb-[calc(var(--tabbar-h)+0.75rem)]" />}
201204 </div>
202205 </details>
203206 );
253256 /**
254257 * Following the running step's log as it grows, as GitHub does: while the
255258 * end of it is in view, new lines keep it in view; once the viewer
256− * scrolls up, it stays put and `behind` says there is more below.
259+ * scrolls up, it stays put and `behind` says there is more below. The end
260+ * is in view only with the last line wholly above whatever covers the
261+ * window's bottom (lib/log-follow.ts), and following pins it there again
262+ * once the new lines have been laid out.
257263 */
258264 function useFollow(running: number | null, grew: unknown): { end: RefObject<HTMLDivElement | null>; behind: boolean; jump: () => void } {
259265 const end = useRef<HTMLDivElement | null>(null);
260266 const following = useRef(false);
267+ // Jump to latest's smooth scroll is on its way: the scrolls it makes are
268+ // not the viewer scrolling up.
269+ const jumping = useRef(false);
261270 const [behind, setBehind] = useState(false);
262− // Where the end of the log is: folded away (a closed step draws nothing),
263− // on screen or nearly (the viewer is reading the latest), or below.
264− const where = (): "folded" | "here" | "below" => {
265− const box = end.current?.getBoundingClientRect();
266− if (!box || (box.width === 0 && box.height === 0)) return "folded";
267− return box.top <= window.innerHeight + 160 ? "here" : "below";
271+ const where = () => {
272+ const marker = end.current;
273+ const covered = marker ? parseFloat(getComputedStyle(marker).scrollMarginBottom) || 0 : 0;
274+ return endPlace(marker?.getBoundingClientRect() ?? null, { height: window.innerHeight, covered });
275+ };
276+ // The end of the log at the bottom of what can be seen, now and once
277+ // anything still settling (a long log's rows being measured, the Jump to
278+ // latest bar going away) has been laid out.
279+ const pin = () => {
280+ end.current?.scrollIntoView({ block: "end" });
281+ requestAnimationFrame(() => {
282+ if (following.current && where() !== "folded") end.current?.scrollIntoView({ block: "end" });
283+ });
268284 };
269285 useEffect(() => {
270286 if (running === null) {
274290 }
275291 // The viewer moving decides: at the end, follow; scrolled up, stay.
276292 const moved = () => {
293+ if (jumping.current) return;
277294 const at = where();
278295 following.current = at === "here";
279296 setBehind(at === "below");
280297 };
281− // A step folded or opened moves nothing the viewer chose; it only
282− // changes whether there is anything below to jump to.
298+ // A step folded or opened, or the window resized (a phone's address
299+ // bar or keyboard), moves nothing the viewer chose: a log followed
300+ // stays at its end, and whether there is anything below to jump to may
301+ // change.
283302 const toggled = () => {
284303 const at = where();
285304 if (at === "folded") following.current = false;
305+ else if (following.current && at === "below" && !jumping.current) {
306+ pin();
307+ return;
308+ }
286309 setBehind(at === "below");
287310 };
288311 window.addEventListener("scroll", moved, { passive: true });
297320 useLayoutEffect(() => {
298321 if (running === null || !end.current) return;
299322 if (following.current && where() !== "folded") {
300− end.current.scrollIntoView({ block: "end" });
323+ pin();
301324 return;
302325 }
303326 // Not following: once there is a log and its end is on screen, follow
307330 if (grew && at === "here") following.current = true;
308331 setBehind(at === "below");
309332 }, [grew, running]);
333+ // Smoothly there, then pinned where the end is once the scroll is over:
334+ // lines that arrived on the way, and the bar this button sits in going
335+ // away, move it.
310336 const jump = () => {
311337 following.current = true;
312− end.current?.scrollIntoView({ block: "end", behavior: "smooth" });
338+ const marker = end.current;
339+ if (!marker) return;
340+ jumping.current = true;
341+ setBehind(false);
342+ let settled = false;
343+ const settle = () => {
344+ if (settled) return;
345+ settled = true;
346+ jumping.current = false;
347+ window.removeEventListener("scrollend", settle);
348+ if (following.current && where() !== "folded") pin();
349+ };
350+ window.addEventListener("scrollend", settle);
351+ // A browser without `scrollend`, or nothing left to scroll.
352+ window.setTimeout(settle, 800);
353+ marker.scrollIntoView({ block: "end", behavior: "smooth" });
313354 };
314355 return { end, behind, jump };
315356 }
650691 ))}
651692 </Card>
652693 {behind && !searching && (
653− <div className="pointer-events-none sticky bottom-4 z-10 flex justify-center">
694+ <div className="pointer-events-none sticky bottom-[calc(var(--tabbar-h)+1rem)] z-10 flex justify-center">
654695 <Button variant="secondary" size="sm" onClick={jump} className="pointer-events-auto shadow-md ring-1 ring-line">
655696 <ArrowDown size={14} />
656697 Jump to latest
12221263 const { run, jobs, notes } = detail;
12231264 const deployments = detail.pendingDeployments ?? [];
12241265 const attempts = detail.attempts ?? [];
1225− const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt;
12261266 const base = `/${params.owner}/${params.repo}`;
12271267 const [search] = useSearchParams();
12281268 // One of the run's buttons is working: the others wait for it.
12291269 const busy = usePending();
1270+ // The attempt shown is still going.
12301271 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;
1272+ // Cancel run while any of the run is going, from whichever attempt's
1273+ // page; re-running is for the latest attempt of a finished run.
1274+ const { latest, going, cancelling, canCancel, canRerun } = runControls({ run, jobs, attempts, member });
12351275 // A mirror standing by runs nothing; in CI failover it runs them again.
12361276 const rerunBlocked = useWorkflowReason();
12371277 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
12401280 // streams on its own), every few seconds without it. Waiting on a person
12411281 // needs no quick refresh.
12421282 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);
1283+ useRefreshWhile(going, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500);
12441284
12451285 // The job asked for; none is the run's summary.
12461286 const selected = jobs.find((job) => job.id === search.get("job")) ?? null;
12751315 </Button>
12761316 </Hint>
12771317 )}
1278− {member && !run.error && live && (
1318+ {canCancel && (
12791319 <Form method="post" className="flex gap-2">
12801320 {cancelling ? (
12811321 <Hint label="Stop its jobs now, without waiting for their cleanup steps">
+5−0
22272227 self.report_pending(&run).await?;
22282228 }
22292229 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;
22302235 self.run_summary(&run.id).await
22312236 }
22322237
+43−4
322322 }));
323323 }
324324 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()));
329326 Ok(Outcome::Ok(RunDetail {
330327 notes: notes(&run.source),
331328 run: summary,
580577
581578 /// The most runs `check_runs` reads for a set of commits.
582579 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+}