Public: anyone can read this; the crew can change it.
The terminal's runs list cancels a queued or running crew run with `x` and a confirm, as the run page does #7239
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7230-the-terminal-s-runs-list-cancels-a-queue → main 2 commits fork
2 files
changed +200
−10
src/tui/screens/runs.ts +55 −10
| @@ -4,8 +4,8 @@ | ||
| 4 | 4 | * Read through `listRepoRuns`, so it is gated like every other run read. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 8 | import type { Repo } from "../../core/repos.ts"; | |
| 7 | import { cancelRepoRun, listRepoRuns } from "../../actions/runs.ts"; | |
| 8 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 9 | 9 | import type { Run, RunState } from "../../core/runs.ts"; |
| 10 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 11 | import { binding } from "../app/keys.ts"; |
| @@ -14,7 +14,8 @@ import { style } from "../term/style.ts"; | ||
| 14 | 14 | import { truncate } from "../term/width.ts"; |
| 15 | 15 | import { fill, len, split } from "../ui/layout.ts"; |
| 16 | 16 | import { emptyState } from "../ui/widgets.ts"; |
| 17 | import { RunList, openRunLog } from "./runlist.ts"; | |
| 17 | import { confirmScreen } from "./common.ts"; | |
| 18 | import { RunList, openRunLog, runTitle } from "./runlist.ts"; | |
| 18 | 19 | |
| 19 | 20 | const LIMIT = 50; |
| 20 | 21 | |
| @@ -43,14 +44,53 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 43 | 44 | |
| 44 | 45 | const open = (run: Run): void => openRunLog(f, ctx, repo, run); |
| 45 | 46 | |
| 46 | return { | |
| 47 | /** The selected run, when it has not finished and the viewer may write. */ | |
| 48 | const cancellable = (): Run | undefined => { | |
| 49 | const run = list.selected; | |
| 50 | if (run === undefined || (run.state !== "queued" && run.state !== "running")) | |
| 51 | return undefined; | |
| 52 | return canWrite(f.app.db, repo, f.user) ? run : undefined; | |
| 53 | }; | |
| 54 | ||
| 55 | const ask = (): void => { | |
| 56 | const run = cancellable(); | |
| 57 | if (run === undefined) return; | |
| 58 | const names = new Map<string, { username: string }>(); | |
| 59 | const what = `${runTitle(run, names)} (${run.id.slice(0, 8)})`; | |
| 60 | ctx.push( | |
| 61 | confirmScreen(ctx, self, { | |
| 62 | title: "Cancel run", | |
| 63 | body: [`Cancel ${what}?`], | |
| 64 | actions: [{ label: "Cancel run", variant: "danger" }, { label: "Keep" }], | |
| 65 | selected: 1, | |
| 66 | onChoose: (choice) => { | |
| 67 | if (choice !== 0) return; | |
| 68 | const done = cancelRepoRun(f.app, f.user, repo, run.id); | |
| 69 | if (done.ok) ctx.toast(`Cancelled ${what}.`); | |
| 70 | else | |
| 71 | ctx.toast( | |
| 72 | done.error === "finished" | |
| 73 | ? "That run already finished." | |
| 74 | : `Could not cancel: ${done.error}.`, | |
| 75 | "danger", | |
| 76 | ); | |
| 77 | read(); | |
| 78 | }, | |
| 79 | }), | |
| 80 | ); | |
| 81 | }; | |
| 82 | ||
| 83 | const self: Screen = { | |
| 47 | 84 | title: "Runs", |
| 48 | keys: [ | |
| 49 | binding(["j", "k"], "move"), | |
| 50 | binding(["enter"], "open the log"), | |
| 51 | binding(["s"], "state: All, Failed, Running, Succeeded (cycle)"), | |
| 52 | binding(["r"], "refresh"), | |
| 53 | ], | |
| 85 | get keys() { | |
| 86 | return [ | |
| 87 | binding(["j", "k"], "move"), | |
| 88 | binding(["enter"], "open the log"), | |
| 89 | ...(cancellable() === undefined ? [] : [binding(["x"], "cancel")]), | |
| 90 | binding(["s"], "state: All, Failed, Running, Succeeded (cycle)"), | |
| 91 | binding(["r"], "refresh"), | |
| 92 | ]; | |
| 93 | }, | |
| 54 | 94 | load: async () => read(), |
| 55 | 95 | render(area, s) { |
| 56 | 96 | const c = ctx.theme.c; |
| @@ -82,6 +122,10 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 82 | 122 | if (run !== undefined) open(run); |
| 83 | 123 | return true; |
| 84 | 124 | } |
| 125 | if (e.key === "x") { | |
| 126 | ask(); | |
| 127 | return true; | |
| 128 | } | |
| 85 | 129 | if (e.key === "s") { |
| 86 | 130 | at = (at + 1) % RUN_STATE_CYCLE.length; |
| 87 | 131 | read(); |
| @@ -94,4 +138,5 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 94 | 138 | return false; |
| 95 | 139 | }, |
| 96 | 140 | }; |
| 141 | return self; | |
| 97 | 142 | } |
test/tui-run-cancel.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** The terminal's runs list cancels a queued or running run with `x` and a confirm (#7230). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 8 | import { claimRun, createRun, finishRun, getRun, type RunKind } from "../src/core/runs.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let forge: Forge; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | let n = 0; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dir = await mkdtemp(join(tmpdir(), "forge-tui-run-cancel-")); | |
| 28 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 29 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 30 | if (!a.ok) throw new Error("signup failed"); | |
| 31 | alice = a.value; | |
| 32 | const b = await createUser(forge.db, { | |
| 33 | origin: "seed", | |
| 34 | trusted: true, | |
| 35 | username: "bob", | |
| 36 | email: "[email protected]", | |
| 37 | password: "a-long-passphrase", | |
| 38 | }); | |
| 39 | if (!b.ok) throw new Error("bob failed"); | |
| 40 | bob = b.value; | |
| 41 | }); | |
| 42 | afterAll(async () => { | |
| 43 | forge.close?.(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | async function open(user: User, make: (repo: Repo) => string) { | |
| 48 | const r = await createRepo(forge, alice, { name: `lamp${n++}`, visibility: "public" }); | |
| 49 | if (!r.ok) throw new Error("repo failed"); | |
| 50 | const runId = make(r.value); | |
| 51 | const { app, session } = createForgeApp( | |
| 52 | forge, | |
| 53 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 54 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 55 | ); | |
| 56 | app.start(); | |
| 57 | await settleApp(app); | |
| 58 | app.push(runsScreen(session, app.ctx, r.value)); | |
| 59 | await settleApp(app); | |
| 60 | const press = async (key: string): Promise<string> => { | |
| 61 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 62 | await settleApp(app); | |
| 63 | return app.screen.toText(); | |
| 64 | }; | |
| 65 | const hints = () => app.screen.toText(); | |
| 66 | return { app, press, runId, hints }; | |
| 67 | } | |
| 68 | ||
| 69 | const queued = (kind: RunKind = "deploy") => (repo: Repo) => | |
| 70 | createRun(forge.db, { repoId: repo.id, kind, env: "production" }).id; | |
| 71 | const state = (id: string) => getRun(forge.db, id)!.state; | |
| 72 | ||
| 73 | test("x asks, yes cancels the run and the row reads cancelled", async () => { | |
| 74 | const h = await open(alice, queued()); | |
| 75 | expect(h.hints()).toMatch(/x +cancel/); | |
| 76 | let text = await h.press("x"); | |
| 77 | expect(text).toContain("Cancel run"); | |
| 78 | expect(text).toContain("deploy production"); | |
| 79 | expect(state(h.runId)).toBe("queued"); | |
| 80 | await h.press("left"); // Keep -> Cancel run | |
| 81 | text = await h.press("enter"); | |
| 82 | expect(state(h.runId)).toBe("cancelled"); | |
| 83 | expect(text).toContain("cancelled"); | |
| 84 | h.app.stop(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("declining the confirm leaves the run as it was", async () => { | |
| 88 | const h = await open(alice, queued()); | |
| 89 | await h.press("x"); | |
| 90 | await h.press("enter"); // defaults to Keep | |
| 91 | expect(state(h.runId)).toBe("queued"); | |
| 92 | await h.press("x"); | |
| 93 | await h.press("esc"); | |
| 94 | expect(state(h.runId)).toBe("queued"); | |
| 95 | h.app.stop(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a running run offers x cancel too", async () => { | |
| 99 | const h = await open(alice, (repo) => { | |
| 100 | const run = createRun(forge.db, { repoId: repo.id, kind: "deploy", env: "production" }); | |
| 101 | claimRun(forge.db, run.id); | |
| 102 | return run.id; | |
| 103 | }); | |
| 104 | expect(getRun(forge.db, h.runId)!.state).toBe("running"); | |
| 105 | await h.press("x"); | |
| 106 | await h.press("left"); | |
| 107 | await h.press("enter"); | |
| 108 | expect(state(h.runId)).toBe("cancelled"); | |
| 109 | h.app.stop(); | |
| 110 | }); | |
| 111 | ||
| 112 | test("x does nothing on a finished run", async () => { | |
| 113 | const h = await open(alice, (repo) => { | |
| 114 | const run = createRun(forge.db, { repoId: repo.id, kind: "check" }); | |
| 115 | claimRun(forge.db, run.id); | |
| 116 | finishRun(forge.db, run.id, { state: "ok", summary: "GREEN-MARK" }); | |
| 117 | return run.id; | |
| 118 | }); | |
| 119 | expect(h.hints()).not.toMatch(/x +cancel/); | |
| 120 | const text = await h.press("x"); | |
| 121 | expect(text).not.toContain("Cancel run"); | |
| 122 | expect(state(h.runId)).toBe("ok"); | |
| 123 | h.app.stop(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a reader is not offered x and a press changes nothing", async () => { | |
| 127 | const h = await open(bob, queued()); | |
| 128 | expect(h.hints()).not.toMatch(/x +cancel/); | |
| 129 | const text = await h.press("x"); | |
| 130 | expect(text).not.toContain("Cancel run"); | |
| 131 | expect(state(h.runId)).toBe("queued"); | |
| 132 | h.app.stop(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a run that finished meanwhile answers in one line", async () => { | |
| 136 | const h = await open(alice, queued()); | |
| 137 | await h.press("x"); | |
| 138 | claimRun(forge.db, h.runId); | |
| 139 | finishRun(forge.db, h.runId, { state: "ok", summary: "done" }); | |
| 140 | await h.press("left"); | |
| 141 | const text = await h.press("enter"); | |
| 142 | expect(text).toContain("already finished"); | |
| 143 | expect(state(h.runId)).toBe("ok"); | |
| 144 | h.app.stop(); | |
| 145 | }); |
new above
offline