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

A repository's runs page filters by state: `?state=failed` lists only failed runs, under a row of state links #6490

Merged opened this

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

Merged

scout/forge:scout/6477-a-repository-s-runs-page-filters-by-stat → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6477-a-repository-s-runs-page-filters-by-stat was merged into main as 2728865.

Review

Sign in to review this change.
6 files changed +306 −5
README.md +5
@@ -536,6 +536,11 @@ work here.
536536
537537 ## What works today
538538
539 - **A repository's runs page filters by state**: a row of links (All, then each
540 state) above the list, or `/:owner/:name/runs?state=failed` by hand. An unknown
541 state is ignored, and the word filter and the pager keep the chosen state. The
542 terminal does the same: `H` in a repository opens its runs, and `s` cycles
outdated

This says the terminal takes no state yet. That is the blocker: add the filter to the terminal's runs list and drop this sentence.

543 All, Failed, Running, Succeeded.
539544 - **A run page's live log** rides the page's one `/events` connection (its
540545 `run.log` frames); it opens no stream of its own. Known gaps, both silent: a
541546 frame the server cuts for size (64 lines or 16 KB) loses its omitted lines
src/tui/screens/runs.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * Runs: every run of a repository, newest first, narrowed by state with `s`
3 * (All, Failed, Running, Succeeded) — the web runs page's `?state=` row.
4 * Read through `listRepoRuns`, so it is gated like every other run read.
5 */
6
7 import { listRepoRuns } from "../../actions/runs.ts";
8 import type { Repo } from "../../core/repos.ts";
9 import type { Run, RunState } from "../../core/runs.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { emptyState } from "../ui/widgets.ts";
17 import { RunList, openRunLog } from "./runlist.ts";
18
19 const LIMIT = 50;
20
21 /** The states `s` cycles through, in the words the web row uses. */
22 export const RUN_STATE_CYCLE: readonly [RunState | undefined, string][] = [
23 [undefined, "All"],
24 ["failed", "Failed"],
25 ["running", "Running"],
26 ["ok", "Succeeded"],
27 ];
28
29 export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
30 const list = new RunList(f, { nameIssues: true });
31 let at = 0;
32 let loaded = false;
33
34 const read = (): void => {
35 const listed = listRepoRuns(f.app, f.user, repo, {
36 state: RUN_STATE_CYCLE[at]![0],
37 limit: LIMIT,
38 });
39 list.set(listed.ok ? listed.value : []);
40 loaded = true;
41 ctx.invalidate();
42 };
43
44 const open = (run: Run): void => openRunLog(f, ctx, repo, run);
45
46 return {
47 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 ],
54 load: async () => read(),
55 render(area, s) {
56 const c = ctx.theme.c;
57 const [head, body] = split(area, "v", [len(1), fill(1)]);
58 s.text(
59 head!.x,
60 head!.y,
61 truncate(`${repo.ownerName}/${repo.name} · runs · ${RUN_STATE_CYCLE[at]![1]}`, head!.w),
62 style(c.mutedFg, c.bg),
63 head!.x + head!.w,
64 );
65 if (list.runs.length === 0) {
66 emptyState(s, body!, c, {
67 title: loaded ? "No runs" : "Reading…",
68 body: loaded
69 ? at === 0
70 ? "Nothing has run here yet."
71 : `No ${RUN_STATE_CYCLE[at]![1].toLowerCase()} runs.`
72 : "Reading the runs",
73 });
74 return;
75 }
76 list.render(s, body!, c, ctx, true, open);
77 },
78 key(e) {
79 if (list.state.key(e, list.runs.length)) return true;
80 if (e.key === "enter") {
81 const run = list.selected;
82 if (run !== undefined) open(run);
83 return true;
84 }
85 if (e.key === "s") {
86 at = (at + 1) % RUN_STATE_CYCLE.length;
87 read();
88 return true;
89 }
90 if (e.key === "r") {
91 read();
92 return true;
93 }
94 return false;
95 },
96 };
97 }
src/tui/screens/workspace.ts +6
@@ -98,6 +98,7 @@ import { canSeeAnswers } from "../../web/studio/answers.ts";
9898 import { answersScreen } from "./answers.ts";
9999 import { commitsScreen } from "./commits.ts";
100100 import { deploysScreen } from "./deploys.ts";
101 import { runsScreen } from "./runs.ts";
101102 import {
102103 Filter,
103104 ListState,
@@ -1308,6 +1309,7 @@ export function workspaceScreen(
13081309 binding(["I"], "about this repository"),
13091310 binding(["V"], "waves"),
13101311 ...(repo === undefined ? [] : [binding(["d"], "production deploys")]),
1312 ...(repo === undefined ? [] : [binding(["H"], "runs, by state")]),
13111313 ...(repo !== undefined && canSeeAnswers(f.db, repo, f.user)
13121314 ? [binding(["a"], "your issues and the crew's answers")]
13131315 : []),
@@ -1646,6 +1648,10 @@ export function workspaceScreen(
16461648 ctx.push(deploysScreen(f, ctx, repo));
16471649 return true;
16481650 }
1651 if (plain && e.key === "H" && repo !== undefined) {
1652 ctx.push(runsScreen(f, ctx, repo));
1653 return true;
1654 }
16491655 if (plain && e.key === "a" && repo !== undefined && canSeeAnswers(f.db, repo, f.user)) {
16501656 ctx.push(answersScreen(f, ctx, repo));
16511657 return true;
src/web/studio/lists.ts +40 −5
@@ -53,7 +53,7 @@ import {
5353 seg,
5454 time,
5555 } from "../ui.ts";
56 import type { Run } from "../../core/runs.ts";
56 import type { Run, RunState } from "../../core/runs.ts";
5757 import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs.ts";
5858 import { originGlyph } from "../pages/thread.ts";
5959 import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts";
@@ -592,15 +592,39 @@ function readRunsKind(ctx: Ctx): RunsKind | undefined {
592592 : undefined;
593593 }
594594
595 /** The runs list's own address with a word, a kind and a cursor. */
595 /** The states `?state=` narrows to; anything else is ignored. */
596 const RUN_STATES: readonly RunState[] = [
597 "queued",
598 "running",
599 "ok",
600 "failed",
601 "cancelled",
602 ];
603
604 function readRunsState(ctx: Ctx): RunState | undefined {
605 const raw = ctx.url.searchParams.get("state");
606 return RUN_STATES.find((s) => s === raw);
607 }
608
609 /** The row of state links above the list, in the words the badges use. */
610 const STATE_LINKS: readonly [RunState | undefined, string][] = [
611 [undefined, "All"],
612 ["failed", "Failed"],
613 ["running", "Running"],
614 ["ok", "Succeeded"],
615 ];
616
617 /** The runs list's own address with a word, a kind, a state and a cursor. */
596618 function runsHref(
597619 base: string,
598620 kind: RunsKind | undefined,
599621 word: WordFilter | undefined,
600622 cursor?: string,
623 state?: RunState,
601624 ): string {
602625 const params = new URLSearchParams();
603626 if (kind !== undefined) params.set("kind", kind);
627 if (state !== undefined) params.set("state", state);
604628 if (word !== undefined) params.set("word", word);
605629 if (cursor !== undefined) params.set("cursor", cursor);
606630 const text = params.toString();
@@ -629,6 +653,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
629653 const base = repoBase(repo);
630654 const filter = readWordFilter(ctx);
631655 const kind = readRunsKind(ctx);
656 const state = readRunsState(ctx);
632657 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
633658 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
634659
@@ -637,6 +662,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
637662 const reading = performance.now();
638663 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
639664 kind,
665 state,
640666 limit: LIST_PAGE,
641667 ...(cursor ? { cursor } : {}),
642668 });
@@ -659,7 +685,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
659685 // With no word chosen a check or a deploy that finished is news too:
660686 // the default list is the whole story, newest first.
661687 (r) =>
662 (filter === undefined && r.run.kind !== "agent") ||
688 (filter === undefined && (r.run.kind !== "agent" || state !== undefined)) ||

The agent-runs-kept branch has no test. Seed a failed agent run and assert it appears under ?state=failed.

663689 inWordFilter(r.info.word, filter),
664690 );
665691
@@ -708,15 +734,24 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
708734
709735 const body = html`${repoHeader(ctx, repo, "runs", { compact: true })}
710736 <div class="mt">

Use seg(STATE_LINKS.map(([s,label]) => ({label, href: runsHref(base, kind, filter, undefined, s), current: s === state}))) instead of the hand-rolled nav with · separators. This breaks the DESIGN.md rule of using named components, and the tap targets are too small on a phone.

737 <nav aria-label="Run state">
738 ${seg(
739 STATE_LINKS.map(([s, label]) => ({
740 label,
741 href: runsHref(base, kind, filter, undefined, s),
742 current: s === state,
743 })),
744 )}
745 </nav>
711746 ${wordFilterBar(
712 (word) => runsHref(base, kind, word),
747 (word) => runsHref(base, kind, word, undefined, state),
713748 filter,
714749 filter === undefined,
715750 )}
716751 ${list}
717752 ${pager(
718753 fetched.length === LIST_PAGE && last !== undefined
719 ? { next: runsHref(base, kind, filter, last.id) }
754 ? { next: runsHref(base, kind, filter, last.id, state) }
720755 : {},
721756 )}
722757 </div>
test/runs-state-filter.test.ts added +78
@@ -0,0 +1,78 @@
1 /** `?state=` narrows a repository's runs page to one state (#6490). */
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 { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
9 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dataDir = "";
16 let app: App;
17 let http: ForgeHttp;
18 let failedId = "";
19 let okId = "";
20 let agentId = "";
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-rstate-"));
24 app = createApp({ dataDir, port: 0 });
25 http = forgeHttp(app);
26 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
27 if (!admin.ok) throw new Error(admin.error);
28 const pub = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
29 const priv = await createRepo(app, admin.value, { name: "secret", visibility: "private", description: "" });
30 if (!pub.ok || !priv.ok) throw new Error("repo");
31 okId = createRun(app.db, { repoId: pub.value.id, kind: "check" }).id;
32 claimRun(app.db, okId);
33 finishRun(app.db, okId, { state: "ok", summary: "fine" });
34 failedId = createRun(app.db, { repoId: pub.value.id, kind: "check" }).id;
35 claimRun(app.db, failedId);
36 finishRun(app.db, failedId, { state: "failed", summary: "broke" });
37 agentId = createRun(app.db, { repoId: pub.value.id, kind: "agent" }).id;
38 claimRun(app.db, agentId);
39 finishRun(app.db, agentId, { state: "failed", summary: "agent broke" });
40 });
41
42 afterAll(async () => {
43 app.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 const get = async (path: string) => {
48 const res = await http.handle(new Request(`http://forge.test${path}`));
49 return { status: res.status, body: await res.text() };
50 };
51
52 test("?state=failed lists only the failed run and marks its link", async () => {
53 const { status, body } = await get("/alice/notes/runs?state=failed");
54 expect(status).toBe(200);
55 expect(body).toContain(`/alice/notes/runs/${failedId}`);
56 expect(body).not.toContain(`/alice/notes/runs/${okId}`);
57 // An agent run is kept under a state, though the default list leaves it out.
58 expect(body).toContain(`/alice/notes/runs/${agentId}`);
59 expect(body).toMatch(/<a\s+href="[^"]*state=failed"\s+aria-current="page"\s*>Failed/);
60 const nav = body.match(/aria-label="Run state">[\s\S]*?<\/nav>/)?.[0] ?? "";
61 expect(nav).toContain(`class="seg"`);
62 expect(nav.match(/aria-current="page"/g)?.length).toBe(1);
63 });
64
65 test("an unknown state is ignored: every run, All is current", async () => {
66 const { status, body } = await get("/alice/notes/runs?state=bogus");
67 expect(status).toBe(200);
68 expect(body).toContain(`/alice/notes/runs/${failedId}`);
69 expect(body).toContain(`/alice/notes/runs/${okId}`);
70 expect(body).toMatch(/<a\s+href="[^"]*\/runs"\s+aria-current="page"\s*>All/);
71 });
72
73 test("a private repository is still the one 404 to an outsider", async () => {
74 const a = await get("/alice/secret/runs?state=failed");
75 const b = await get("/alice/nothing/runs?state=failed");
76 expect(a.status).toBe(404);
77 expect(b.status).toBe(404);
78 });
test/tui-runs-state.test.ts added +80
@@ -0,0 +1,80 @@
1 /** The terminal's runs list cycles a state filter, as the web row does (#6490). */
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 } from "../src/core/runs.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { runsScreen } from "../src/tui/screens/runs.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { settleApp } from "./helpers/tui.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let forge: Forge;
21 let repo: Repo;
22 let alice: import("../src/core/users.ts").User;
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-tui-runs-"));
26 forge = createApp({ dataDir: dir, port: 0 });
27 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
28 if (!a.ok) throw new Error("signup failed");
29 alice = a.value;
30 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
31 if (!r.ok) throw new Error("repo failed");
32 repo = r.value;
33 const done = createRun(forge.db, { repoId: repo.id, kind: "check", summary: "GREEN-MARK" });
34 claimRun(forge.db, done.id);
35 finishRun(forge.db, done.id, { state: "ok", summary: "GREEN-MARK" });
36 const bad = createRun(forge.db, { repoId: repo.id, kind: "check" });
37 claimRun(forge.db, bad.id);
38 finishRun(forge.db, bad.id, { state: "failed", summary: "RED-MARK" });
39 const agent = createRun(forge.db, { repoId: repo.id, kind: "agent" });
40 claimRun(forge.db, agent.id);
41 finishRun(forge.db, agent.id, { state: "failed", summary: "AGENT-RED-MARK" });
42 });
43 afterAll(async () => {
44 forge.close?.();
45 await rm(dir, { recursive: true, force: true });
46 });
47
48 test("s cycles All, Failed, Running, Succeeded and the list follows", async () => {
49 const { app, session } = createForgeApp(
50 forge,
51 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice },
52 { terminal: headlessTerminal(120, 30), exitHooks: false },
53 );
54 app.start();
55 await settleApp(app);
56 app.push(runsScreen(session, app.ctx, repo));
57 await settleApp(app);
58 const press = async (key: string): Promise<string> => {
59 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
60 await settleApp(app);
61 return app.screen.toText();
62 };
63 let text = app.screen.toText();
64 expect(text).toContain("GREEN-MARK");
65 expect(text).toContain("RED-MARK");
66 text = await press("s");
67 expect(text).toContain("Failed");
68 expect(text).toContain("RED-MARK");
69 expect(text).toContain("AGENT-RED-MARK");
70 expect(text).not.toContain("GREEN-MARK");
71 text = await press("s");
72 expect(text).toContain("No running runs");
73 text = await press("s");
74 expect(text).toContain("GREEN-MARK");
75 expect(text).not.toContain("RED-MARK");
76 text = await press("s");
77 expect(text).toContain("GREEN-MARK");
78 expect(text).toContain("RED-MARK");
79 app.stop();
80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs