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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6477-a-repository-s-runs-page-filters-by-stat → main 4 commits fork 3 threads · 3 unresolved
6 files
changed +306
−5
README.md +5
| @@ -536,6 +536,11 @@ work here. | ||
| 536 | 536 | |
| 537 | 537 | ## What works today |
| 538 | 538 | |
| 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 | |
| 543 | All, Failed, Running, Succeeded. | |
| 539 | 544 | - **A run page's live log** rides the page's one `/events` connection (its |
| 540 | 545 | `run.log` frames); it opens no stream of its own. Known gaps, both silent: a |
| 541 | 546 | 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"; | ||
| 98 | 98 | import { answersScreen } from "./answers.ts"; |
| 99 | 99 | import { commitsScreen } from "./commits.ts"; |
| 100 | 100 | import { deploysScreen } from "./deploys.ts"; |
| 101 | import { runsScreen } from "./runs.ts"; | |
| 101 | 102 | import { |
| 102 | 103 | Filter, |
| 103 | 104 | ListState, |
| @@ -1308,6 +1309,7 @@ export function workspaceScreen( | ||
| 1308 | 1309 | binding(["I"], "about this repository"), |
| 1309 | 1310 | binding(["V"], "waves"), |
| 1310 | 1311 | ...(repo === undefined ? [] : [binding(["d"], "production deploys")]), |
| 1312 | ...(repo === undefined ? [] : [binding(["H"], "runs, by state")]), | |
| 1311 | 1313 | ...(repo !== undefined && canSeeAnswers(f.db, repo, f.user) |
| 1312 | 1314 | ? [binding(["a"], "your issues and the crew's answers")] |
| 1313 | 1315 | : []), |
| @@ -1646,6 +1648,10 @@ export function workspaceScreen( | ||
| 1646 | 1648 | ctx.push(deploysScreen(f, ctx, repo)); |
| 1647 | 1649 | return true; |
| 1648 | 1650 | } |
| 1651 | if (plain && e.key === "H" && repo !== undefined) { | |
| 1652 | ctx.push(runsScreen(f, ctx, repo)); | |
| 1653 | return true; | |
| 1654 | } | |
| 1649 | 1655 | if (plain && e.key === "a" && repo !== undefined && canSeeAnswers(f.db, repo, f.user)) { |
| 1650 | 1656 | ctx.push(answersScreen(f, ctx, repo)); |
| 1651 | 1657 | return true; |
src/web/studio/lists.ts +40 −5
| @@ -53,7 +53,7 @@ import { | ||
| 53 | 53 | seg, |
| 54 | 54 | time, |
| 55 | 55 | } from "../ui.ts"; |
| 56 | import type { Run } from "../../core/runs.ts"; | |
| 56 | import type { Run, RunState } from "../../core/runs.ts"; | |
| 57 | 57 | import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs.ts"; |
| 58 | 58 | import { originGlyph } from "../pages/thread.ts"; |
| 59 | 59 | import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts"; |
| @@ -592,15 +592,39 @@ function readRunsKind(ctx: Ctx): RunsKind | undefined { | ||
| 592 | 592 | : undefined; |
| 593 | 593 | } |
| 594 | 594 | |
| 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. */ | |
| 596 | 618 | function runsHref( |
| 597 | 619 | base: string, |
| 598 | 620 | kind: RunsKind | undefined, |
| 599 | 621 | word: WordFilter | undefined, |
| 600 | 622 | cursor?: string, |
| 623 | state?: RunState, | |
| 601 | 624 | ): string { |
| 602 | 625 | const params = new URLSearchParams(); |
| 603 | 626 | if (kind !== undefined) params.set("kind", kind); |
| 627 | if (state !== undefined) params.set("state", state); | |
| 604 | 628 | if (word !== undefined) params.set("word", word); |
| 605 | 629 | if (cursor !== undefined) params.set("cursor", cursor); |
| 606 | 630 | const text = params.toString(); |
| @@ -629,6 +653,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 629 | 653 | const base = repoBase(repo); |
| 630 | 654 | const filter = readWordFilter(ctx); |
| 631 | 655 | const kind = readRunsKind(ctx); |
| 656 | const state = readRunsState(ctx); | |
| 632 | 657 | const cursor = ctx.url.searchParams.get("cursor") ?? undefined; |
| 633 | 658 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 634 | 659 | |
| @@ -637,6 +662,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 637 | 662 | const reading = performance.now(); |
| 638 | 663 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 639 | 664 | kind, |
| 665 | state, | |
| 640 | 666 | limit: LIST_PAGE, |
| 641 | 667 | ...(cursor ? { cursor } : {}), |
| 642 | 668 | }); |
| @@ -659,7 +685,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 659 | 685 | // With no word chosen a check or a deploy that finished is news too: |
| 660 | 686 | // the default list is the whole story, newest first. |
| 661 | 687 | (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. |
||
| 663 | 689 | inWordFilter(r.info.word, filter), |
| 664 | 690 | ); |
| 665 | 691 | |
| @@ -708,15 +734,24 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 708 | 734 | |
| 709 | 735 | const body = html`${repoHeader(ctx, repo, "runs", { compact: true })} |
| 710 | 736 | <div class="mt"> |
Use |
||
| 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> | |
| 711 | 746 | ${wordFilterBar( |
| 712 | (word) => runsHref(base, kind, word), | |
| 747 | (word) => runsHref(base, kind, word, undefined, state), | |
| 713 | 748 | filter, |
| 714 | 749 | filter === undefined, |
| 715 | 750 | )} |
| 716 | 751 | ${list} |
| 717 | 752 | ${pager( |
| 718 | 753 | fetched.length === LIST_PAGE && last !== undefined |
| 719 | ? { next: runsHref(base, kind, filter, last.id) } | |
| 754 | ? { next: runsHref(base, kind, filter, last.id, state) } | |
| 720 | 755 | : {}, |
| 721 | 756 | )} |
| 722 | 757 | </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 | }); |
new above
offline
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.