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

Parked opened this
Closed without merging

scout/forge:scout/11122-runs-answers-with-a-ui-forge-runs-card-e → main 4 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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`;
185185 | `review` | — | Approve a pull, or request changes (`reason` required); your own pull is refused | 350 |
186186 | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 |
187187 | `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 |
189189 | `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 |
190190 | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 237 |
191191 | `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
482482
483483 ## Cards, the contract and the plugins (#1116)
484484
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
486486 `_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
488488 its ready, working and waiting counts, and the first five ready issues linked
489489 to their pages; it has no actions. The decision card's rows link to the pull or issue
490490 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"
55
66 export const tool: Tool = {
77 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",
99 description:
1010 "A repository's runs, newest first: id, kind, state, number, start, end.",
1111 inputSchema: {
@@ -42,15 +42,18 @@ export const tool: Tool = {
4242 });
4343 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
4444 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 {
4648 id: r.id,
4749 kind: r.kind,
4850 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,
5153 startedAt: r.startedAt,
5254 endedAt: r.finishedAt,
53 })),
55 };
56 }),
5457 });
5558 return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } };
5659 },
src/web/studio/cards.ts +40 −18
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
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";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -42,13 +42,13 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4242
4343 export const CARD_MIME = "text/html;profile=mcp-app";
4444 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"];
4646
4747 /** The tool whose answer each card draws. */
4848 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4949 apps: "app",
5050 now: "crew",
51 runs: "run",
51 runs: "runs",
5252 asks: "decision",
5353 milestones: "milestones",
5454 asks_act: "decision",
@@ -77,6 +77,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
7777 explore: "Explore",
7878 pull: "Pull",
7979 pulls: "Pulls",
80 runs: "Runs",
8081 asked: "Asked",
8182 issue: "Issue",
8283 flow: "Flow",
@@ -114,6 +115,9 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = {
114115 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
115116 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
116117
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
117121 /** A run state as one check word: what a pull's check says at a glance. */
118122 const checkWord = (state: string): string =>
119123 state === "ok" ? "green"
@@ -333,6 +337,39 @@ export function cardFor(
333337 }
334338 return { kind, title: CARD_TITLE[kind], status, rows, actions };
335339 }
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 }
336373 if (kind === "asked") {
337374 // `asked` answers `{ asked: [{ repo, number, title, state, flagged, pulls }] }`.
338375 const asks = list(obj(value)["asked"]).map(obj);
@@ -397,21 +434,6 @@ export function cardFor(
397434 for (const a of apps.slice(0, 3))
398435 if (text(a["app"]) !== "")
399436 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 };
415437 } else if (kind === "run") {
416438 const runs = list(value).map(obj);
417439 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", () => {
8585 expect(r.value.runs[1].number).toBe(number);
8686 expect(r.value.runs[0].number).toBeNull();
8787 expect(Object.keys(r.value.runs[0]).sort()).toEqual(
88 ["endedAt", "id", "kind", "number", "startedAt", "state"],
88 ["endedAt", "id", "kind", "number", "startedAt", "state", "title"],
8989 );
9090 expect((await tool(aliceRo, { repo: "alice/vault", limit: 1 })).value.runs).toHaveLength(1);
9191 });
test/mcp.test.ts +5 −6
@@ -1300,6 +1300,7 @@ describe("the forge over MCP", () => {
13001300 "ui://forge/pull",
13011301 "ui://forge/pulls",
13021302 "ui://forge/run",
1303 "ui://forge/runs",
13031304 "ui://forge/scorecard",
13041305 "ui://forge/wave",
13051306 ]);
@@ -1325,6 +1326,7 @@ describe("the forge over MCP", () => {
13251326 expect(meta("explore")).toBe("ui://forge/explore");
13261327 expect(meta("pull")).toBe("ui://forge/pull");
13271328 expect(meta("pulls")).toBe("ui://forge/pulls");
1329 expect(meta("runs")).toBe("ui://forge/runs");
13281330 expect(meta("asked")).toBe("ui://forge/asked");
13291331 expect(meta("flow")).toBe("ui://forge/flow");
13301332 expect(meta("scorecard")).toBe("ui://forge/scorecard");
@@ -1507,19 +1509,16 @@ describe("the forge over MCP", () => {
15071509 const id = "01m23vygyffx5hg2f5vm8r74sr";
15081510 runs[1]!.id = id;
15091511 const card = cardFor("runs", { runs }, "https://f.example", { repo: "alice/game" })!;
1510 expect(card.kind).toBe("run");
1512 expect(card.kind).toBe("runs");
15111513 expect(card.title).toBe("Runs");
15121514 expect(card.rows[1]!.link).toBe(`https://f.example/alice/game/runs/${id}`);
15131515 expect(card.rows[0]!.link).toBeUndefined();
15141516 const nolink = cardFor("runs", { runs }, "https://f.example", { repo: "alice" })!;
15151517 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" });
15191518 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");
15211520 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");
15231522 });
15241523
15251524 test("a tool result carries the card it renders", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs