Public: anyone can read this; the crew can change it.
`runs` answers with a ui://forge/runs card: each run's kind, state, issue or pull, and duration, newest first, linked to its log #11129
scout/forge:scout/11122-runs-answers-with-a-ui-forge-runs-card-e → main 4 commits fork
6 files
changed +90
−33
docs/MCP.md +3 −3
| @@ -185,7 +185,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 185 | 185 | | `review` | — | Approve a pull, or request changes (`reason` required); your own pull is refused | 350 | |
| 186 | 186 | | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 | |
| 187 | 187 | | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 | |
| 188 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 | | |
| 188 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 483 | | |
| 189 | 189 | | `say` | `list`, `new`, `rename`, `archive`, `restore`, `build`, `build_state`, `build_diff`, `build_restore`, `build_publish` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 585 | |
| 190 | 190 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 237 | |
| 191 | 191 | | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 | |
| @@ -482,9 +482,9 @@ answers | ||
| 482 | 482 | |
| 483 | 483 | ## Cards, the contract and the plugins (#1116) |
| 484 | 484 | |
| 485 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` and `asked` name a `ui://` resource in | |
| 485 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `waves` (one wave), `map`, `feed`, `pull`, `pulls`, `runs` and `asked` name a `ui://` resource in | |
| 486 | 486 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 487 | `decision`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with | |
| 487 | `decision`, `wave`, `map`, `feed`, `pull`, `pulls`, `runs`, `asked`. The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with | |
| 488 | 488 | its ready, working and waiting counts, and the first five ready issues linked |
| 489 | 489 | to their pages; it has no actions. The decision card's rows link to the pull or issue |
| 490 | 490 | they are about (a paused repository's row has none). |
src/http/mcp/tools/runs.ts +8 −5
| @@ -5,7 +5,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "runs", |
| 8 | doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them", | |
| 8 | doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number and title, started, ended; draws the `ui://forge/runs` card, a failed run first in its status, each row linked to its run page), 1–50, default 20; read as the runs page reads them", | |
| 9 | 9 | description: |
| 10 | 10 | "A repository's runs, newest first: id, kind, state, number, start, end.", |
| 11 | 11 | inputSchema: { |
| @@ -42,15 +42,18 @@ export const tool: Tool = { | ||
| 42 | 42 | }); |
| 43 | 43 | if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)"); |
| 44 | 44 | const answer = good({ |
| 45 | runs: runs.value.map((r) => ({ | |
| 45 | runs: runs.value.map((r) => { | |
| 46 | const issue = r.issueId === null ? null : getIssueById(ctx.app.db, r.issueId); | |
| 47 | return { | |
| 46 | 48 | id: r.id, |
| 47 | 49 | kind: r.kind, |
| 48 | 50 | state: r.state, |
| 49 | number: | |
| 50 | r.issueId === null ? null : (getIssueById(ctx.app.db, r.issueId)?.number ?? null), | |
| 51 | number: issue?.number ?? null, | |
| 52 | title: issue?.title ?? null, | |
| 51 | 53 | startedAt: r.startedAt, |
| 52 | 54 | endedAt: r.finishedAt, |
| 53 | })), | |
| 55 | }; | |
| 56 | }), | |
| 54 | 57 | }); |
| 55 | 58 | return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } }; |
| 56 | 59 | }, |
src/web/studio/cards.ts +40 −18
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones"; | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -42,13 +42,13 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 42 | 42 | |
| 43 | 43 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 44 | 44 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones"]; | |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones"]; | |
| 46 | 46 | |
| 47 | 47 | /** The tool whose answer each card draws. */ |
| 48 | 48 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| 49 | 49 | apps: "app", |
| 50 | 50 | now: "crew", |
| 51 | runs: "run", | |
| 51 | runs: "runs", | |
| 52 | 52 | asks: "decision", |
| 53 | 53 | milestones: "milestones", |
| 54 | 54 | asks_act: "decision", |
| @@ -77,6 +77,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 77 | 77 | explore: "Explore", |
| 78 | 78 | pull: "Pull", |
| 79 | 79 | pulls: "Pulls", |
| 80 | runs: "Runs", | |
| 80 | 81 | asked: "Asked", |
| 81 | 82 | issue: "Issue", |
| 82 | 83 | flow: "Flow", |
| @@ -114,6 +115,9 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = { | ||
| 114 | 115 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 115 | 116 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 116 | 117 | |
| 118 | /** A run state as one glyph beside its word: a card is read at a glance. */ | |
| 119 | const RUN_GLYPH: Readonly<Record<string, string>> = { queued: "◌", running: "▶", ok: "✓", failed: "✗", cancelled: "–" }; | |
| 120 | ||
| 117 | 121 | /** A run state as one check word: what a pull's check says at a glance. */ |
| 118 | 122 | const checkWord = (state: string): string => |
| 119 | 123 | state === "ok" ? "green" |
| @@ -333,6 +337,39 @@ export function cardFor( | ||
| 333 | 337 | } |
| 334 | 338 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 335 | 339 | } |
| 340 | if (kind === "runs") { | |
| 341 | // `runs` answers `{ runs: [{ id, kind, state, number, title, startedAt, endedAt }] }`, newest first; | |
| 342 | // the repo comes in `extra`. A failed run says failed before anything else. | |
| 343 | const runs = list(obj(value)["runs"]).map(obj); | |
| 344 | const slug = text(extra["repo"]); | |
| 345 | const now = Date.now(); | |
| 346 | const failed = runs.filter((r) => text(r["state"]) === "failed").length; | |
| 347 | const going = runs.filter((r) => text(r["state"]) === "running").length; | |
| 348 | status = [ | |
| 349 | failed > 0 ? `${failed} failed` : "", | |
| 350 | going > 0 ? `${going} running` : "", | |
| 351 | `${runs.length} run${runs.length === 1 ? "" : "s"}`, | |
| 352 | ].filter((w) => w !== "").join(" · "); | |
| 353 | for (const r of runs.slice(0, PULLS_LIMIT)) { | |
| 354 | const state = text(r["state"]); | |
| 355 | const number = text(r["number"]); | |
| 356 | const title = text(r["title"]); | |
| 357 | const started = Date.parse(text(r["startedAt"])); | |
| 358 | const ended = Date.parse(text(r["endedAt"])); | |
| 359 | const length = Number.isNaN(started) ? "" : Number.isNaN(ended) | |
| 360 | ? state === "running" ? ` · running for ${span(now - started)}` : "" | |
| 361 | : ` · ${span(ended - started)}`; | |
| 362 | const path = `/${slug}/runs/${text(r["id"])}`; | |
| 363 | rows.push({ | |
| 364 | label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${title}`}`, | |
| 365 | value: `${state}${length}`, | |
| 366 | ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) && here(origin, path) | |
| 367 | ? { link: origin + path } | |
| 368 | : {}), | |
| 369 | }); | |
| 370 | } | |
| 371 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; | |
| 372 | } | |
| 336 | 373 | if (kind === "asked") { |
| 337 | 374 | // `asked` answers `{ asked: [{ repo, number, title, state, flagged, pulls }] }`. |
| 338 | 375 | const asks = list(obj(value)["asked"]).map(obj); |
| @@ -397,21 +434,6 @@ export function cardFor( | ||
| 397 | 434 | for (const a of apps.slice(0, 3)) |
| 398 | 435 | if (text(a["app"]) !== "") |
| 399 | 436 | actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true }); |
| 400 | } else if (tool === "runs") { | |
| 401 | // `runs` answers `{ runs: [{ id, kind, state, number, startedAt, endedAt }] }`, | |
| 402 | // newest first; the repo comes in `extra`; past runs, so the card offers no stop. | |
| 403 | const runs = list(obj(value)["runs"]).map(obj); | |
| 404 | status = runs.length === 0 ? "no runs" : `${runs.length} run${runs.length === 1 ? "" : "s"}`; | |
| 405 | const slug = text(extra["repo"]); | |
| 406 | for (const r of runs.slice(0, LIMIT)) | |
| 407 | rows.push({ | |
| 408 | label: `${text(r["kind"])} ${text(r["id"])}`, | |
| 409 | value: `${r["number"] == null ? "" : `#${text(r["number"])} — `}${text(r["state"])}`, | |
| 410 | ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) | |
| 411 | ? { link: `${origin}/${slug}/runs/${text(r["id"])}` } | |
| 412 | : {}), | |
| 413 | }); | |
| 414 | return { kind, title: "Runs", status, rows, actions }; | |
| 415 | 437 | } else if (kind === "run") { |
| 416 | 438 | const runs = list(value).map(obj); |
| 417 | 439 | status = runs.length === 0 ? "nothing running" : `${runs.length} going`; |
test/mcp-runs-card.test.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { CARD_SHELL, cardFor } from "../src/web/studio/cards.ts"; | |
| 3 | ||
| 4 | const ID = "01m23vygyffx5hg2f5vm8r74sr"; | |
| 5 | const hostile = `<img src=x onerror=alert(1)>"'`; | |
| 6 | const now = Date.now(); | |
| 7 | const runs = [ | |
| 8 | { id: ID, kind: "agent", state: "running", number: 7, title: hostile, startedAt: new Date(now - 125_000).toISOString(), endedAt: null }, | |
| 9 | { id: ID, kind: "check", state: "failed", number: 8, title: "b", startedAt: "2026-01-01T00:00:00.000Z", endedAt: "2026-01-01T00:03:00.000Z" }, | |
| 10 | ]; | |
| 11 | ||
| 12 | test("runs card: a row per run, newest first, state word and glyph, duration, link", () => { | |
| 13 | const card = cardFor("runs", { runs }, "https://f.example", { repo: "a/b" })!; | |
| 14 | expect(card.kind).toBe("runs"); | |
| 15 | expect(card.status).toBe("1 failed · 1 running · 2 runs"); | |
| 16 | expect(card.rows[0]!.label).toBe(`▶ agent #7 ${hostile}`); | |
| 17 | expect(card.rows[0]!.value).toBe("running · running for 2m"); | |
| 18 | expect(card.rows[1]!.value).toBe("failed · 3m"); | |
| 19 | expect(card.rows[1]!.label.startsWith("✗ check #8")).toBe(true); | |
| 20 | expect(card.rows[0]!.link).toBe(`https://f.example/a/b/runs/${ID}`); | |
| 21 | }); | |
| 22 | ||
| 23 | test("runs card: a hostile title stays data and the shell never uses innerHTML", () => { | |
| 24 | const card = cardFor("runs", { runs }, "https://f.example", { repo: "a/b" })!; | |
| 25 | expect(JSON.parse(JSON.stringify(card)).rows[0].label).toContain(hostile); | |
| 26 | expect(CARD_SHELL("runs")).not.toContain("innerHTML"); | |
| 27 | expect(CARD_SHELL("runs")).not.toContain(hostile); | |
| 28 | }); | |
| 29 | ||
| 30 | test("runs card: a bad id or repo gets no link", () => { | |
| 31 | const bad = cardFor("runs", { runs: [{ ...runs[0], id: "../x" }] }, "https://f.example", { repo: "a/b" })!; | |
| 32 | expect(bad.rows[0]!.link).toBeUndefined(); | |
| 33 | }); |
test/mcp-runs.test.ts +1 −1
| @@ -85,7 +85,7 @@ describe("MCP runs", () => { | ||
| 85 | 85 | expect(r.value.runs[1].number).toBe(number); |
| 86 | 86 | expect(r.value.runs[0].number).toBeNull(); |
| 87 | 87 | expect(Object.keys(r.value.runs[0]).sort()).toEqual( |
| 88 | ["endedAt", "id", "kind", "number", "startedAt", "state"], | |
| 88 | ["endedAt", "id", "kind", "number", "startedAt", "state", "title"], | |
| 89 | 89 | ); |
| 90 | 90 | expect((await tool(aliceRo, { repo: "alice/vault", limit: 1 })).value.runs).toHaveLength(1); |
| 91 | 91 | }); |
test/mcp.test.ts +5 −6
| @@ -1300,6 +1300,7 @@ describe("the forge over MCP", () => { | ||
| 1300 | 1300 | "ui://forge/pull", |
| 1301 | 1301 | "ui://forge/pulls", |
| 1302 | 1302 | "ui://forge/run", |
| 1303 | "ui://forge/runs", | |
| 1303 | 1304 | "ui://forge/scorecard", |
| 1304 | 1305 | "ui://forge/wave", |
| 1305 | 1306 | ]); |
| @@ -1325,6 +1326,7 @@ describe("the forge over MCP", () => { | ||
| 1325 | 1326 | expect(meta("explore")).toBe("ui://forge/explore"); |
| 1326 | 1327 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1327 | 1328 | expect(meta("pulls")).toBe("ui://forge/pulls"); |
| 1329 | expect(meta("runs")).toBe("ui://forge/runs"); | |
| 1328 | 1330 | expect(meta("asked")).toBe("ui://forge/asked"); |
| 1329 | 1331 | expect(meta("flow")).toBe("ui://forge/flow"); |
| 1330 | 1332 | expect(meta("scorecard")).toBe("ui://forge/scorecard"); |
| @@ -1507,19 +1509,16 @@ describe("the forge over MCP", () => { | ||
| 1507 | 1509 | const id = "01m23vygyffx5hg2f5vm8r74sr"; |
| 1508 | 1510 | runs[1]!.id = id; |
| 1509 | 1511 | const card = cardFor("runs", { runs }, "https://f.example", { repo: "alice/game" })!; |
| 1510 | expect(card.kind).toBe("run"); | |
| 1512 | expect(card.kind).toBe("runs"); | |
| 1511 | 1513 | expect(card.title).toBe("Runs"); |
| 1512 | 1514 | expect(card.rows[1]!.link).toBe(`https://f.example/alice/game/runs/${id}`); |
| 1513 | 1515 | expect(card.rows[0]!.link).toBeUndefined(); |
| 1514 | 1516 | const nolink = cardFor("runs", { runs }, "https://f.example", { repo: "alice" })!; |
| 1515 | 1517 | expect(nolink.rows[1]!.link).toBeUndefined(); |
| 1516 | expect(card.rows).toHaveLength(12); | |
| 1517 | expect(card.rows[0]).toEqual({ label: "agent r0", value: "done" }); | |
| 1518 | expect(card.rows[1]).toMatchObject({ label: `agent ${id}`, value: "#1 — done" }); | |
| 1519 | 1518 | const { body } = await call(rw, "tools/call", { name: "runs", arguments: { repo: "alice/game" } }); |
| 1520 | expect(body.result.structuredContent.card.kind).toBe("run"); | |
| 1519 | expect(body.result.structuredContent.card.kind).toBe("runs"); | |
| 1521 | 1520 | const tools = (await listCall(rw)).body.result.tools; |
| 1522 | expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/run"); | |
| 1521 | expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/runs"); | |
| 1523 | 1522 | }); |
| 1524 | 1523 | |
| 1525 | 1524 | test("a tool result carries the card it renders", async () => { |