Public: anyone can read this; the crew can change it.

The terminal's runs screen runs a failed or cancelled agent run again with `a` and a confirm, as the run page's requeue does #7307

Merged opened this

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

Merged

torque/forge:torque/7294-the-terminal-s-runs-screen-runs-a-failed → main 1 commit fork

  • Merged. torque/7294-the-terminal-s-runs-screen-runs-a-failed was merged into main as dd2f40f.

Review

Sign in to review this change.
3 files changed +211 −2
src/fleet/verbs.ts +2 −1
@@ -129,7 +129,8 @@ const WORDS: Record<string, string> = {
129129 unknown_branch: "unknown branch",
130130 };
131131
132 const words = (code: string): string => WORDS[code] ?? code;
132 /** An action's refusal code in words; the terminal says the same (#7294). */
133 export const words = (code: string): string => WORDS[code] ?? code;
133134
134135 /** A Result from an action, as a verb's answer. */
135136 const done = (result: Result<unknown, string>): string | undefined =>
src/tui/screens/runs.ts +49 −1
@@ -2,10 +2,15 @@
22 * Runs: every run of a repository, newest first, narrowed by state with `s`
33 * (All, Failed, Running, Succeeded) — the web runs page's `?state=` row.
44 * Read through `listRepoRuns`, so it is gated like every other run read.
5 * `x` cancels a run that has not finished; `a` queues a failed or cancelled
6 * agent run's task again (`requeueRepoRun`), each after a confirm.
57 */
68
7 import { cancelRepoRun, listRepoRuns } from "../../actions/runs.ts";
9 import { cancelRepoRun, listRepoRuns, requeueRepoRun } from "../../actions/runs.ts";
10 import { lacks } from "../../core/roles.ts";
811 import { canWrite, type Repo } from "../../core/repos.ts";
12 import { taskOf } from "../../crew/dispatch.ts";
13 import { words } from "../../fleet/verbs.ts";
914 import type { Run, RunState } from "../../core/runs.ts";
1015 import type { Ctx, Screen } from "../app/app.ts";
1116 import { binding } from "../app/keys.ts";
@@ -52,6 +57,44 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5257 return canWrite(f.app.db, repo, f.user) ? run : undefined;
5358 };
5459
60 /**
61 * The selected run, when it is a failed or cancelled agent run whose task
62 * is still there and the viewer is a person who may run the crew — the
63 * cases `requeueRepoRun` would not refuse before asking the crew.
64 */
65 const requeueable = (): Run | undefined => {
66 const run = list.selected;
67 const user = f.user;
68 if (run === undefined || user === undefined || user.kind !== "user") return undefined;
69 if (run.kind !== "agent" || (run.state !== "failed" && run.state !== "cancelled"))
70 return undefined;
71 if (repo.archivedAt !== null || !canWrite(f.app.db, repo, user)) return undefined;
72 if (lacks(f.app.db, user, "run_crew")) return undefined;
73 return taskOf(f.app, run) === undefined ? undefined : run;
74 };
75
76 const askRequeue = (): void => {
77 const run = requeueable();
78 if (run === undefined) return;
79 const names = new Map<string, { username: string }>();
80 const what = `${runTitle(run, names)} (${run.id.slice(0, 8)})`;
81 ctx.push(
82 confirmScreen(ctx, self, {
83 title: "Run again",
84 body: [`Queue ${what} again?`, "The new run reads the whole conversation and carries on."],
85 actions: [{ label: "Run again", variant: "default" }, { label: "Keep" }],
86 selected: 1,
87 onChoose: (choice) => {
88 if (choice !== 0) return;
89 const done = requeueRepoRun(f.app, f.user, repo, run.id);
90 if (done.ok) ctx.toast(`Queued ${what} again.`);
91 else ctx.toast(`Could not run it again: ${words(done.error)}.`, "danger");
92 read();
93 },
94 }),
95 );
96 };
97
5598 const ask = (): void => {
5699 const run = cancellable();
57100 if (run === undefined) return;
@@ -87,6 +130,7 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
87130 binding(["j", "k"], "move"),
88131 binding(["enter"], "open the log"),
89132 ...(cancellable() === undefined ? [] : [binding(["x"], "cancel")]),
133 ...(requeueable() === undefined ? [] : [binding(["a"], "run again")]),
90134 binding(["s"], "state: All, Failed, Running, Succeeded (cycle)"),
91135 binding(["r"], "refresh"),
92136 ];
@@ -126,6 +170,10 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
126170 ask();
127171 return true;
128172 }
173 if (e.key === "a") {
174 askRequeue();
175 return true;
176 }
129177 if (e.key === "s") {
130178 at = (at + 1) % RUN_STATE_CYCLE.length;
131179 read();
test/tui-runs-requeue.test.ts added +160
@@ -0,0 +1,160 @@
1 /** The terminal's runs list queues a failed or cancelled agent run again with `a` and a confirm (#7294). */
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { setIssueState } from "../src/core/issues.ts";
11 import { cancelRun, claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { runsScreen } from "../src/tui/screens/runs.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: Forge;
24 let alice: User;
25 let bob: User;
26 let helper: User;
27 let n = 0;
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-tui-run-requeue-"));
31 forge = createApp({ dataDir: dir, port: 0 });
32 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
33 if (!a.ok) throw new Error("signup failed");
34 alice = a.value;
35 const b = await createUser(forge.db, {
36 origin: "seed",
37 trusted: true,
38 username: "bob",
39 email: "[email protected]",
40 password: "a-long-passphrase",
41 });
42 if (!b.ok) throw new Error("bob failed");
43 bob = b.value;
44 const agent = await createAgent(forge, alice, { username: "helper" });
45 if (!agent.ok) throw new Error(String(agent.error));
46 helper = agent.value.user;
47 });
48 afterAll(async () => {
49 forge.close?.();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 /** A public repository with the helper on its crew and one finished agent run on an open issue. */
54 async function open(user: User, end: "failed" | "ok" | "cancelled" = "failed") {
55 const r = await createRepo(forge, alice, { name: `lamp${n++}`, visibility: "public" });
56 if (!r.ok) throw new Error("repo failed");
57 const repo = r.value;
58 const crewed = addToCrew(forge, alice, repo, "helper");
59 if (!crewed.ok) throw new Error(crewed.error);
60 const issue = openIssue(forge, alice, repo, { title: "fix the lamp", body: "" });
61 if (!issue.ok) throw new Error(String(issue.error));
62 const run = createRun(forge.db, {
63 repoId: repo.id,
64 kind: "agent",
65 issueId: issue.value.id,
66 actorId: helper.id,
67 reason: "assigned",
68 });
69 claimRun(forge.db, run.id);
70 if (end === "cancelled") cancelRun(forge.db, run.id);
71 else finishRun(forge.db, run.id, { state: end, summary: "flake" });
72 const { app, session } = createForgeApp(
73 forge,
74 { sshUsername: user.username, remoteAddress: "127.0.0.1", user },
75 { terminal: headlessTerminal(120, 30), exitHooks: false },
76 );
77 app.start();
78 await settleApp(app);
79 app.push(runsScreen(session, app.ctx, repo));
80 await settleApp(app);
81 const press = async (key: string): Promise<string> => {
82 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
83 await settleApp(app);
84 return app.screen.toText();
85 };
86 const hints = () => app.screen.toText();
87 const runs = () => listRuns(forge.db, repo.id, { limit: 50 });
88 return { app, press, hints, runs, repo, issueId: issue.value.id };
89 }
90
91 test("a asks, yes queues the task again and the list shows the queued run", async () => {
92 const h = await open(alice);
93 expect(h.hints()).toMatch(/a +run again/);
94 let text = await h.press("a");
95 expect(text).toContain("Run again");
96 expect(text).toContain("again?");
97 expect(h.runs()).toHaveLength(1);
98 await h.press("left"); // Keep -> Run again
99 text = await h.press("enter");
100 const after = h.runs();
101 expect(after).toHaveLength(2);
102 expect(after.map((r) => r.state).sort()).toEqual(["failed", "queued"]);
103 expect(text).toContain("queued");
104 expect(text).toContain("Queued");
105 h.app.stop();
106 });
107
108 test("a cancelled agent run offers a too", async () => {
109 const h = await open(alice, "cancelled");
110 expect(h.hints()).toMatch(/a +run again/);
111 h.app.stop();
112 });
113
114 test("esc and Keep change nothing", async () => {
115 const h = await open(alice);
116 await h.press("a");
117 await h.press("esc");
118 expect(h.runs()).toHaveLength(1);
119 await h.press("a");
120 await h.press("enter"); // defaults to Keep
121 expect(h.runs()).toHaveLength(1);
122 h.app.stop();
123 });
124
125 test("a succeeded run offers no a and a press changes nothing", async () => {
126 const h = await open(alice, "ok");
127 expect(h.hints()).not.toMatch(/a +run again/);
128 const text = await h.press("a");
129 expect(text).not.toContain("Run again");
130 expect(h.runs()).toHaveLength(1);
131 h.app.stop();
132 });
133
134 test("a reader is not offered a and a press changes nothing", async () => {
135 const h = await open(bob);
136 expect(h.hints()).not.toMatch(/a +run again/);
137 const text = await h.press("a");
138 expect(text).not.toContain("Run again");
139 expect(h.runs()).toHaveLength(1);
140 h.app.stop();
141 });
142
143 test("a closed issue is not offered a", async () => {
144 const h = await open(alice);
145 setIssueState(forge.db, h.issueId, "closed");
146 await h.press("r");
147 expect(h.hints()).not.toMatch(/a +run again/);
148 h.app.stop();
149 });
150
151 test("a refusal after the confirm shows the action's words", async () => {
152 const h = await open(alice);
153 await h.press("a");
154 setIssueState(forge.db, h.issueId, "closed");
155 await h.press("left");
156 const text = await h.press("enter");
157 expect(text).toContain("its issue is closed or its agent is gone");
158 expect(h.runs()).toHaveLength(1);
159 h.app.stop();
160 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs