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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7230-the-terminal-s-runs-list-cancels-a-queue → main 2 commits fork

  • Merged. scout/7230-the-terminal-s-runs-list-cancels-a-queue was merged into main as 10ba0b9.

Review

Sign in to review this change.
2 files changed +200 −10
src/tui/screens/runs.ts +55 −10
@@ -4,8 +4,8 @@
44 * Read through `listRepoRuns`, so it is gated like every other run read.
55 */
66
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";
99 import type { Run, RunState } from "../../core/runs.ts";
1010 import type { Ctx, Screen } from "../app/app.ts";
1111 import { binding } from "../app/keys.ts";
@@ -14,7 +14,8 @@ import { style } from "../term/style.ts";
1414 import { truncate } from "../term/width.ts";
1515 import { fill, len, split } from "../ui/layout.ts";
1616 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";
1819
1920 const LIMIT = 50;
2021
@@ -43,14 +44,53 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4344
4445 const open = (run: Run): void => openRunLog(f, ctx, repo, run);
4546
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 = {
4784 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 },
5494 load: async () => read(),
5595 render(area, s) {
5696 const c = ctx.theme.c;
@@ -82,6 +122,10 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
82122 if (run !== undefined) open(run);
83123 return true;
84124 }
125 if (e.key === "x") {
126 ask();
127 return true;
128 }
85129 if (e.key === "s") {
86130 at = (at + 1) % RUN_STATE_CYCLE.length;
87131 read();
@@ -94,4 +138,5 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
94138 return false;
95139 },
96140 };
141 return self;
97142 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs