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 #11161

Merged opened this

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

Merged

scout/forge:scout/11122-runs-answers-with-a-ui-forge-runs-card-e → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/11122-runs-answers-with-a-ui-forge-runs-card-e was merged into main as 0855bd7.

Review

Sign in to review this change.
6 files changed +134 −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`, `releases`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` and `asked` name a `ui://` resource in
485 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `releases`, `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 `asks`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with
487 `asks`, `release`, `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 asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision:
490490 what, which repository, how long it has waited, and its one button on the row itself.
src/http/mcp/tools/runs.ts +17 −4
@@ -1,11 +1,13 @@
11 import { listRepoRuns } from "../../../actions/runs.ts";
22 import { RUN_KINDS, type RunKind } from "../../../core/runs.ts";
3 import { mayView } from "../../../core/sealed.ts";
4 import { markReaders } from "../../../crew/direct.ts";
35 import { getIssueById } from "../../../core/issues.ts";
46 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
57
68 export const tool: Tool = {
79 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",
10 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, `untrusted: true` on a title from someone who cannot write, 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",
911 description:
1012 "A repository's runs, newest first: id, kind, state, number, start, end.",
1113 inputSchema: {
@@ -41,13 +43,24 @@ export const tool: Tool = {
4143 ...(kind === undefined ? {} : { kind: kind as RunKind }),
4244 });
4345 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
46 const rows = runs.value.map((r) => {
47 const found = r.issueId === null ? undefined : getIssueById(ctx.app.db, r.issueId);
outdated

HIGH, data leaking across users: getIssueById returns a sealed issue too. Sealed issues are seen only by writers and crew, but this now puts the title of any sealed issue or pull into the answer, and into the card, for any reader of the repo. Check mayView(db, repo, user, issue) from core/sealed.ts, as src/http/mcp/shared.ts does. When the reader may not see it, return title null, and number null too. Add a test with a sealed issue and a read-only user that asserts neither appears. Also mark title as untrusted when its author cannot write, as the issues tool does.

48 // A sealed issue the reader may not see gives neither number nor title.
outdated

Clean: the object literal and its closing brace are not indented to match the new block body. Re-indent so it reads as one block.

49 const issue = found !== undefined && mayView(ctx.app.db, repo, found, user) ? found : undefined;
50 return { run: r, issue };
51 });
52 const seen = rows.flatMap((x) => (x.issue === undefined ? [] : [x.issue]));
53 const untrusted = new Set(
54 markReaders(ctx.app.db, repo, seen).flatMap((m) => (m.untrusted === undefined ? [] : [m.id])),
55 );
4456 const answer = good({
45 runs: runs.value.map((r) => ({
57 runs: rows.map(({ run: r, issue }) => ({
4658 id: r.id,
4759 kind: r.kind,
4860 state: r.state,
49 number:
50 r.issueId === null ? null : (getIssueById(ctx.app.db, r.issueId)?.number ?? null),
61 number: issue?.number ?? null,
62 title: issue?.title ?? null,
63 ...(issue !== undefined && untrusted.has(issue.id) ? { untrusted: true } : {}),
5164 startedAt: r.startedAt,
5265 endedAt: r.finishedAt,
5366 })),
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" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,13 +44,13 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5151 apps: "app",
5252 now: "crew",
53 runs: "run",
53 runs: "runs",
5454 asks: "asks",
5555 milestones: "milestones",
5656 releases: "release",
@@ -80,6 +80,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
8080 explore: "Explore",
8181 pull: "Pull",
8282 pulls: "Pulls",
83 runs: "Runs",
8384 asked: "Asked",
8485 issue: "Issue",
8586 flow: "Flow",
@@ -119,6 +120,9 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = {
119120 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
120121 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
121122
123 /** A run state as one glyph beside its word: a card is read at a glance. */
124 const RUN_GLYPH: Readonly<Record<string, string>> = { queued: "◌", running: "▶", ok: "✓", failed: "✗", cancelled: "–" };
125
122126 /** A run state as one check word: what a pull's check says at a glance. */
123127 const checkWord = (state: string): string =>
124128 state === "ok" ? "green"
@@ -338,6 +342,39 @@ export function cardFor(
338342 }
339343 return { kind, title: CARD_TITLE[kind], status, rows, actions };
340344 }
345 if (kind === "runs") {
346 // `runs` answers `{ runs: [{ id, kind, state, number, title, startedAt, endedAt }] }`, newest first;
347 // the repo comes in `extra`. A failed run says failed before anything else.
348 const runs = list(obj(value)["runs"]).map(obj);
349 const slug = text(extra["repo"]);
350 const now = Date.now();
351 const failed = runs.filter((r) => text(r["state"]) === "failed").length;
352 const going = runs.filter((r) => text(r["state"]) === "running").length;
353 status = [
354 failed > 0 ? `${failed} failed` : "",
355 going > 0 ? `${going} running` : "",
356 `${runs.length} run${runs.length === 1 ? "" : "s"}`,
357 ].filter((w) => w !== "").join(" · ");
358 for (const r of runs.slice(0, PULLS_LIMIT)) {
359 const state = text(r["state"]);
360 const number = text(r["number"]);
361 const title = text(r["title"]);
362 const started = Date.parse(text(r["startedAt"]));
363 const ended = Date.parse(text(r["endedAt"]));
364 const length = Number.isNaN(started) ? "" : Number.isNaN(ended)
365 ? state === "running" ? ` · running for ${span(now - started)}` : ""
366 : ` · ${span(ended - started)}`;
367 const path = `/${slug}/runs/${text(r["id"])}`;
368 rows.push({
369 label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${title}`}`,
370 value: `${state}${length}`,
371 ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) && here(origin, path)
372 ? { link: origin + path }
373 : {}),
374 });
375 }
376 return { kind, title: CARD_TITLE[kind], status, rows, actions };
377 }
341378 if (kind === "release") {
342379 // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card.
343380 const items = list(obj(value)["items"]).map(obj);
@@ -419,21 +456,6 @@ export function cardFor(
419456 for (const a of apps.slice(0, 3))
420457 if (text(a["app"]) !== "")
421458 actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true });
422 } else if (tool === "runs") {
423 // `runs` answers `{ runs: [{ id, kind, state, number, startedAt, endedAt }] }`,
424 // newest first; the repo comes in `extra`; past runs, so the card offers no stop.
425 const runs = list(obj(value)["runs"]).map(obj);
426 status = runs.length === 0 ? "no runs" : `${runs.length} run${runs.length === 1 ? "" : "s"}`;
427 const slug = text(extra["repo"]);
428 for (const r of runs.slice(0, LIMIT))
429 rows.push({
430 label: `${text(r["kind"])} ${text(r["id"])}`,
431 value: `${r["number"] == null ? "" : `#${text(r["number"])} — `}${text(r["state"])}`,
432 ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"]))
433 ? { link: `${origin}/${slug}/runs/${text(r["id"])}` }
434 : {}),
435 });
436 return { kind, title: "Runs", status, rows, actions };
437459 } else if (kind === "run") {
438460 const runs = list(value).map(obj);
439461 status = runs.length === 0 ? "nothing running" : `${runs.length} going`;
test/mcp-runs-card.test.ts added +36
@@ -0,0 +1,36 @@
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");

Box 2: 'a failed one says failed first' is asserted only in the status line, and the failed run is the second row. Also assert that a failed row's value begins with 'failed', or that failed runs are listed first, so the behaviour is pinned.

18 expect(card.rows[1]!.value).toBe("failed · 3m");
19 // Failed leads the status line, and the failed row's value begins with the word.
20 expect(card.status!.startsWith("1 failed")).toBe(true);
21 expect(card.rows[1]!.value.startsWith("failed")).toBe(true);
22 expect(card.rows[1]!.label.startsWith("✗ check #8")).toBe(true);
23 expect(card.rows[0]!.link).toBe(`https://f.example/a/b/runs/${ID}`);
24 });
25
26 test("runs card: a hostile title stays data and the shell never uses innerHTML", () => {
27 const card = cardFor("runs", { runs }, "https://f.example", { repo: "a/b" })!;
28 expect(JSON.parse(JSON.stringify(card)).rows[0].label).toContain(hostile);
29 expect(CARD_SHELL("runs")).not.toContain("innerHTML");
30 expect(CARD_SHELL("runs")).not.toContain(hostile);
31 });
32
33 test("runs card: a bad id or repo gets no link", () => {
34 const bad = cardFor("runs", { runs: [{ ...runs[0], id: "../x" }] }, "https://f.example", { repo: "a/b" })!;
35 expect(bad.rows[0]!.link).toBeUndefined();
36 });
test/mcp-runs.test.ts +33 −2
@@ -11,7 +11,7 @@ import { createRepo } from "../src/actions/repos.ts";
1111 import { createIssue } from "../src/core/issues.ts";
1212 import { createRun } from "../src/core/runs.ts";
1313 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616
1717 describe("MCP runs", () => {
@@ -85,11 +85,42 @@ 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 });
9292
93 test("a sealed issue's run shows neither its number nor its title to a reader who may not see it", async () => {
94 const alice = getUserByName(app.db, "alice")!;
95 const made = await createRepo(app, alice, { name: "open", visibility: "public" });
96 if (!made.ok) throw new Error(String(made.error));
97 const repoId = made.value.id;
98 const sealed = createIssue(app.db, { repoId, authorId: alice.id, title: "SECRET-HOLE", sealed: true });
99 if (!sealed.ok) throw new Error(sealed.error);
100 createRun(app.db, { repoId, kind: "agent", issueId: sealed.value.id, actorId: alice.id, reason: "assigned" });
101 const bob = await tool(bobRo, { repo: "alice/open" });
102 const raw = JSON.stringify(bob.value);
103 expect(raw).not.toContain("SECRET-HOLE");
104 expect(raw).not.toContain(`"number":${sealed.value.number}`);
105 expect(bob.value.runs).toHaveLength(0);
106 const own = await tool(aliceRo, { repo: "alice/open" });
107 expect(own.value.runs[0].title).toBe("SECRET-HOLE");
108 });
109
110 test("a title from someone who cannot write is marked untrusted", async () => {
111 const alice = getUserByName(app.db, "alice")!;
112 const bob = getUserByName(app.db, "bob")!;
113 const made = await createRepo(app, alice, { name: "marked", visibility: "public" });
114 if (!made.ok) throw new Error(String(made.error));
115 const repoId = made.value.id;
116 const issue = createIssue(app.db, { repoId, authorId: bob.id, title: "from a stranger" });
117 if (!issue.ok) throw new Error(issue.error);
118 createRun(app.db, { repoId, kind: "agent", issueId: issue.value.id, actorId: alice.id, reason: "assigned" });
119 const r = await tool(aliceRo, { repo: "alice/marked" });
120 expect(r.value.runs[0].title).toBe("from a stranger");
121 expect(r.value.runs[0].untrusted).toBe(true);
122 });
123
93124 test("a private repository without access is not found", async () => {
94125 const r = await tool(bobRo, { repo: "alice/vault" });
95126 expect(r.error).toContain("no such app or repository");
test/mcp.test.ts +5 −6
@@ -1301,6 +1301,7 @@ describe("the forge over MCP", () => {
13011301 "ui://forge/pulls",
13021302 "ui://forge/release",
13031303 "ui://forge/run",
1304 "ui://forge/runs",
13041305 "ui://forge/scorecard",
13051306 "ui://forge/wave",
13061307 ]);
@@ -1326,6 +1327,7 @@ describe("the forge over MCP", () => {
13261327 expect(meta("explore")).toBe("ui://forge/explore");
13271328 expect(meta("pull")).toBe("ui://forge/pull");
13281329 expect(meta("pulls")).toBe("ui://forge/pulls");
1330 expect(meta("runs")).toBe("ui://forge/runs");
13291331 expect(meta("asked")).toBe("ui://forge/asked");
13301332 expect(meta("flow")).toBe("ui://forge/flow");
13311333 expect(meta("scorecard")).toBe("ui://forge/scorecard");
@@ -1508,19 +1510,16 @@ describe("the forge over MCP", () => {
15081510 const id = "01m23vygyffx5hg2f5vm8r74sr";
15091511 runs[1]!.id = id;
15101512 const card = cardFor("runs", { runs }, "https://f.example", { repo: "alice/game" })!;
1511 expect(card.kind).toBe("run");
1513 expect(card.kind).toBe("runs");
15121514 expect(card.title).toBe("Runs");
15131515 expect(card.rows[1]!.link).toBe(`https://f.example/alice/game/runs/${id}`);
15141516 expect(card.rows[0]!.link).toBeUndefined();
15151517 const nolink = cardFor("runs", { runs }, "https://f.example", { repo: "alice" })!;
15161518 expect(nolink.rows[1]!.link).toBeUndefined();
1517 expect(card.rows).toHaveLength(12);
1518 expect(card.rows[0]).toEqual({ label: "agent r0", value: "done" });
1519 expect(card.rows[1]).toMatchObject({ label: `agent ${id}`, value: "#1 — done" });
15201519 const { body } = await call(rw, "tools/call", { name: "runs", arguments: { repo: "alice/game" } });
1521 expect(body.result.structuredContent.card.kind).toBe("run");
1520 expect(body.result.structuredContent.card.kind).toBe("runs");
15221521 const tools = (await listCall(rw)).body.result.tools;
1523 expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/run");
1522 expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/runs");
15241523 });
15251524
15261525 test("a tool result carries the card it renders", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs