Public: anyone can read this; the crew can change it.
The runs list shows agent runs, checks and deploys together by default, with the three as filters #2400
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2381-the-runs-list-shows-agent-runs-checks-an → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +84
−25
src/web/studio/lists.ts +44 −25
| @@ -52,7 +52,8 @@ import { | ||
| 52 | 52 | seg, |
| 53 | 53 | time, |
| 54 | 54 | } from "../ui.ts"; |
| 55 | import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts"; | |
| 55 | import type { Run } from "../../core/runs.ts"; | |
| 56 | import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs.ts"; | |
| 56 | 57 | import { originGlyph } from "../pages/thread.ts"; |
| 57 | 58 | import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts"; |
| 58 | 59 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; |
| @@ -553,43 +554,49 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 553 | 554 | }); |
| 554 | 555 | } |
| 555 | 556 | |
| 556 | /** Which runs a list shows: the crew's by default, or the deploys or the | |
| 557 | * checks that `?kind=` asks for — the narrowing dev's list offered, kept | |
| 558 | * so a link written to it keeps meaning what it said. */ | |
| 557 | /** Which runs a list shows: all three kinds by default, or the one that | |
| 558 | * `?kind=` asks for — the narrowing dev's list offered, kept so a link | |
| 559 | * written to it keeps meaning what it said. */ | |
| 559 | 560 | type RunsKind = "agent" | "deploy" | "check"; |
| 560 | 561 | |
| 561 | function readRunsKind(ctx: Ctx): RunsKind { | |
| 562 | function readRunsKind(ctx: Ctx): RunsKind | undefined { | |
| 562 | 563 | const raw = ctx.url.searchParams.get("kind"); |
| 563 | return raw === "deploy" || raw === "check" ? raw : "agent"; | |
| 564 | return raw === "agent" || raw === "deploy" || raw === "check" | |
| 565 | ? raw | |
| 566 | : undefined; | |
| 564 | 567 | } |
| 565 | 568 | |
| 566 | 569 | /** The runs list's own address with a word, a kind and a cursor. */ |
| 567 | 570 | function runsHref( |
| 568 | 571 | base: string, |
| 569 | kind: RunsKind, | |
| 572 | kind: RunsKind | undefined, | |
| 570 | 573 | word: WordFilter | undefined, |
| 571 | 574 | cursor?: string, |
| 572 | 575 | ): string { |
| 573 | 576 | const params = new URLSearchParams(); |
| 574 | if (kind !== "agent") params.set("kind", kind); | |
| 577 | if (kind !== undefined) params.set("kind", kind); | |
| 575 | 578 | if (word !== undefined) params.set("word", word); |
| 576 | 579 | if (cursor !== undefined) params.set("cursor", cursor); |
| 577 | 580 | const text = params.toString(); |
| 578 | 581 | return text === "" ? `${base}/runs` : `${base}/runs?${text}`; |
| 579 | 582 | } |
| 580 | 583 | |
| 584 | /** A check's state in the words its row names it by. */ | |
| 585 | const checkState = (run: Run): string => | |
| 586 | run.state === "ok" ? "passed" : run.state; | |
| 587 | ||
| 581 | 588 | const RUNS_KIND_WORDS: Record<RunsKind, string> = { |
| 582 | agent: "the crew's runs", | |
| 583 | deploy: "deploys", | |
| 584 | check: "checks", | |
| 589 | agent: "Agent runs", | |
| 590 | deploy: "Deploys", | |
| 591 | check: "Checks", | |
| 585 | 592 | }; |
| 586 | 593 | |
| 587 | 594 | /** |
| 588 | 595 | * GET /:owner/:name/runs: every run, for every repository and every viewer, |
| 589 | 596 | * one row per run in its studio word — who ran it, when it last moved, and |
| 590 | * the transcript as the row's own link. The crew's runs by default; | |
| 591 | * `?kind=deploy|check` reads the deploys or the checks instead, from one | |
| 592 | * quiet line under the list. `?word=` narrows any of them. | |
| 597 | * the transcript as the row's own link. Agent runs, checks and deploys | |
| 598 | * together, newest first, by default; `?kind=agent|deploy|check` narrows to | |
| 599 | * one, from the three links under the list. `?word=` narrows any of them. | |
| 593 | 600 | */ |
| 594 | 601 | export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { |
| 595 | 602 | const db = ctx.app.db; |
| @@ -620,7 +627,13 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 620 | 627 | const words = await Promise.all(fetched.map((run) => runWord(wordCtx, run))); |
| 621 | 628 | const rows = fetched |
| 622 | 629 | .map((run, i) => ({ run, info: words[i]! })) |
| 623 | .filter((r) => inWordFilter(r.info.word, filter)); | |
| 630 | .filter( | |
| 631 | // With no word chosen a check or a deploy that finished is news too: | |
| 632 | // the default list is the whole story, newest first. | |
| 633 | (r) => | |
| 634 | (filter === undefined && r.run.kind !== "agent") || | |
| 635 | inWordFilter(r.info.word, filter), | |
| 636 | ); | |
| 624 | 637 | |
| 625 | 638 | const list = |
| 626 | 639 | rows.length === 0 |
| @@ -630,7 +643,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 630 | 643 | note: emptyNote( |
| 631 | 644 | filter, |
| 632 | 645 | "No run", |
| 633 | kind === "agent" | |
| 646 | kind === undefined || kind === "agent" | |
| 634 | 647 | ? "Nothing open. A run is one go the crew takes at a job; each one shows up here." |
| 635 | 648 | : `Nothing open. Every ${kind === "deploy" ? "deploy" : "check"} shows up here while it runs.`, |
| 636 | 649 | ), |
| @@ -649,16 +662,18 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 649 | 662 | ? avatar(who) |
| 650 | 663 | : icon(run.kind === "check" ? "check" : "rocket"), |
| 651 | 664 | title: runTitleFrom(run, actors, issues), |
| 652 | meta: html`${run.kind === "agent" ? html`${who} · ` : null}${info.note} | |
| 665 | meta: | |
| 666 | run.kind === "check" | |
| 667 | ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)} | |
| 668 | ${when(durationText(run), (d) => html` · took ${d}`)} · | |
| 669 | ${time(run.finishedAt ?? run.createdAt)}` | |
| 670 | : html`${run.kind === "agent" ? html`${who} · ` : null}${info.note} | |
| 653 | 671 | · ${time(run.finishedAt ?? run.createdAt)}`, |
| 654 | 672 | trail: wordBadge(info.word, undefined, record === undefined), |
| 655 | 673 | }); |
| 656 | 674 | }), |
| 657 | 675 | ); |
| 658 | 676 | const last = fetched[fetched.length - 1]; |
| 659 | const others = (["agent", "deploy", "check"] as const).filter( | |
| 660 | (k) => k !== kind, | |
| 661 | ); | |
| 662 | 677 | |
| 663 | 678 | const body = html`${repoHeader(ctx, repo, "runs", { compact: true })} |
| 664 | 679 | <div class="mt"> |
| @@ -675,12 +690,16 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 675 | 690 | )} |
| 676 | 691 | </div> |
| 677 | 692 | <p class="muted small mt"> |
| 678 | Also ${others.map( | |
| 693 | Show ${(["agent", "deploy", "check"] as const).map( | |
| 679 | 694 | (k, i) => |
| 680 | html`${i > 0 ? " · " : ""}<a href="${runsHref(base, k, undefined)}" | |
| 681 | >${RUNS_KIND_WORDS[k]}</a | |
| 682 | >`, | |
| 683 | )} | |
| 695 | html`${i > 0 ? " · " : ""}${k === kind | |
| 696 | ? html`<strong>${RUNS_KIND_WORDS[k]}</strong>` | |
| 697 | : html`<a href="${runsHref(base, k, undefined)}" | |
| 698 | >${RUNS_KIND_WORDS[k]}</a | |
| 699 | >`}`, | |
| 700 | )}${kind === undefined | |
| 701 | ? null | |
| 702 | : html` · <a href="${runsHref(base, undefined, undefined)}">All</a>`} | |
| 684 | 703 | </p> |
| 685 | 704 | ${liveMount({ repo: base })}`; |
| 686 | 705 |
test/studio-changes.test.ts +40
| @@ -303,6 +303,46 @@ describe("the studio, continued", () => { | ||
| 303 | 303 | expect(deploys).toContain('href="/alice/notes/runs?kind=deploy&word=told"'); |
| 304 | 304 | }); |
| 305 | 305 | |
| 306 | test("the runs list shows agent runs, checks and deploys together, and each kind link narrows it", async () => { | |
| 307 | const scout = getUserByName(app.db, "scout")!; | |
| 308 | const mk = (kind: "agent" | "check" | "deploy") => { | |
| 309 | const run = createRun(app.db, { | |
| 310 | repoId: repo.id, | |
| 311 | kind, | |
| 312 | actorId: kind === "agent" ? scout.id : undefined, | |
| 313 | reason: "push", | |
| 314 | commitOid: "abcdef1234567890abcdef1234567890abcdef12", | |
| 315 | }); | |
| 316 | claimRun(app.db, run.id); | |
| 317 | finishRun(app.db, run.id, { state: "ok", summary: "fine" }); | |
| 318 | return `href="/alice/notes/runs/${run.id}"`; | |
| 319 | }; | |
| 320 | const agent = mk("agent"); | |
| 321 | const check = mk("check"); | |
| 322 | const deploy = mk("deploy"); | |
| 323 | ||
| 324 | const all = await (await get("/alice/notes/runs")).text(); | |
| 325 | for (const link of [agent, check, deploy]) expect(all).toContain(link); | |
| 326 | expect(all).toContain("Check of abcdef1"); | |
| 327 | expect(all).toContain("abcdef1 · passed"); | |
| 328 | expect(all).toContain("took "); | |
| 329 | ||
| 330 | const only = async (kind: string) => | |
| 331 | (await get(`/alice/notes/runs?kind=${kind}`)).text(); | |
| 332 | const a = await only("agent"); | |
| 333 | expect(a).toContain(agent); | |
| 334 | expect(a).not.toContain(check); | |
| 335 | expect(a).not.toContain(deploy); | |
| 336 | const c = await only("check"); | |
| 337 | expect(c).toContain(check); | |
| 338 | expect(c).not.toContain(agent); | |
| 339 | expect(c).not.toContain(deploy); | |
| 340 | const d = await only("deploy"); | |
| 341 | expect(d).toContain(deploy); | |
| 342 | expect(d).not.toContain(agent); | |
| 343 | expect(d).not.toContain(check); | |
| 344 | }); | |
| 345 | ||
| 306 | 346 | test("issues, pulls and runs on a repository that is not an app are the same one list", async () => { |
| 307 | 347 | const plain = await createRepo(app, alice, { |
| 308 | 348 | name: "plainlists", |
new above
offline
Small tap targets in a muted line below the list. Move above the list as padded chips and mark the current one with aria-current="page" (bold alone is not announced).