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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11122-runs-answers-with-a-ui-forge-runs-card-e → main 6 commits fork 3 threads · 3 unresolved
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`; | ||
| 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`, `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 | |
| 486 | 486 | `_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 | |
| 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 asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision: |
| 490 | 490 | 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 @@ | ||
| 1 | 1 | import { listRepoRuns } from "../../../actions/runs.ts"; |
| 2 | 2 | import { RUN_KINDS, type RunKind } from "../../../core/runs.ts"; |
| 3 | import { mayView } from "../../../core/sealed.ts"; | |
| 4 | import { markReaders } from "../../../crew/direct.ts"; | |
| 3 | 5 | import { getIssueById } from "../../../core/issues.ts"; |
| 4 | 6 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 7 | |
| 6 | 8 | export const tool: Tool = { |
| 7 | 9 | 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", | |
| 9 | 11 | description: |
| 10 | 12 | "A repository's runs, newest first: id, kind, state, number, start, end.", |
| 11 | 13 | inputSchema: { |
| @@ -41,13 +43,24 @@ export const tool: Tool = { | ||
| 41 | 43 | ...(kind === undefined ? {} : { kind: kind as RunKind }), |
| 42 | 44 | }); |
| 43 | 45 | 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); | |
| 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 | ); | |
| 44 | 56 | const answer = good({ |
| 45 | runs: runs.value.map((r) => ({ | |
| 57 | runs: rows.map(({ run: r, issue }) => ({ | |
| 46 | 58 | id: r.id, |
| 47 | 59 | kind: r.kind, |
| 48 | 60 | 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 } : {}), | |
| 51 | 64 | startedAt: r.startedAt, |
| 52 | 65 | endedAt: r.finishedAt, |
| 53 | 66 | })), |
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" | "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"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,13 +44,13 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | 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"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| 51 | 51 | apps: "app", |
| 52 | 52 | now: "crew", |
| 53 | runs: "run", | |
| 53 | runs: "runs", | |
| 54 | 54 | asks: "asks", |
| 55 | 55 | milestones: "milestones", |
| 56 | 56 | releases: "release", |
| @@ -80,6 +80,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 80 | 80 | explore: "Explore", |
| 81 | 81 | pull: "Pull", |
| 82 | 82 | pulls: "Pulls", |
| 83 | runs: "Runs", | |
| 83 | 84 | asked: "Asked", |
| 84 | 85 | issue: "Issue", |
| 85 | 86 | flow: "Flow", |
| @@ -119,6 +120,9 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = { | ||
| 119 | 120 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 120 | 121 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 121 | 122 | |
| 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 | ||
| 122 | 126 | /** A run state as one check word: what a pull's check says at a glance. */ |
| 123 | 127 | const checkWord = (state: string): string => |
| 124 | 128 | state === "ok" ? "green" |
| @@ -338,6 +342,39 @@ export function cardFor( | ||
| 338 | 342 | } |
| 339 | 343 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 340 | 344 | } |
| 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 | } | |
| 341 | 378 | if (kind === "release") { |
| 342 | 379 | // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card. |
| 343 | 380 | const items = list(obj(value)["items"]).map(obj); |
| @@ -419,21 +456,6 @@ export function cardFor( | ||
| 419 | 456 | for (const a of apps.slice(0, 3)) |
| 420 | 457 | if (text(a["app"]) !== "") |
| 421 | 458 | 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 }; | |
| 437 | 459 | } else if (kind === "run") { |
| 438 | 460 | const runs = list(value).map(obj); |
| 439 | 461 | 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"; | ||
| 11 | 11 | import { createIssue } from "../src/core/issues.ts"; |
| 12 | 12 | import { createRun } from "../src/core/runs.ts"; |
| 13 | 13 | import { createToken } from "../src/core/tokens.ts"; |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | |
| 17 | 17 | describe("MCP runs", () => { |
| @@ -85,11 +85,42 @@ 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 | }); |
| 92 | 92 | |
| 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 | ||
| 93 | 124 | test("a private repository without access is not found", async () => { |
| 94 | 125 | const r = await tool(bobRo, { repo: "alice/vault" }); |
| 95 | 126 | expect(r.error).toContain("no such app or repository"); |
test/mcp.test.ts +5 −6
| @@ -1301,6 +1301,7 @@ describe("the forge over MCP", () => { | ||
| 1301 | 1301 | "ui://forge/pulls", |
| 1302 | 1302 | "ui://forge/release", |
| 1303 | 1303 | "ui://forge/run", |
| 1304 | "ui://forge/runs", | |
| 1304 | 1305 | "ui://forge/scorecard", |
| 1305 | 1306 | "ui://forge/wave", |
| 1306 | 1307 | ]); |
| @@ -1326,6 +1327,7 @@ describe("the forge over MCP", () => { | ||
| 1326 | 1327 | expect(meta("explore")).toBe("ui://forge/explore"); |
| 1327 | 1328 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1328 | 1329 | expect(meta("pulls")).toBe("ui://forge/pulls"); |
| 1330 | expect(meta("runs")).toBe("ui://forge/runs"); | |
| 1329 | 1331 | expect(meta("asked")).toBe("ui://forge/asked"); |
| 1330 | 1332 | expect(meta("flow")).toBe("ui://forge/flow"); |
| 1331 | 1333 | expect(meta("scorecard")).toBe("ui://forge/scorecard"); |
| @@ -1508,19 +1510,16 @@ describe("the forge over MCP", () => { | ||
| 1508 | 1510 | const id = "01m23vygyffx5hg2f5vm8r74sr"; |
| 1509 | 1511 | runs[1]!.id = id; |
| 1510 | 1512 | const card = cardFor("runs", { runs }, "https://f.example", { repo: "alice/game" })!; |
| 1511 | expect(card.kind).toBe("run"); | |
| 1513 | expect(card.kind).toBe("runs"); | |
| 1512 | 1514 | expect(card.title).toBe("Runs"); |
| 1513 | 1515 | expect(card.rows[1]!.link).toBe(`https://f.example/alice/game/runs/${id}`); |
| 1514 | 1516 | expect(card.rows[0]!.link).toBeUndefined(); |
| 1515 | 1517 | const nolink = cardFor("runs", { runs }, "https://f.example", { repo: "alice" })!; |
| 1516 | 1518 | 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" }); | |
| 1520 | 1519 | 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"); | |
| 1522 | 1521 | 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"); | |
| 1524 | 1523 | }); |
| 1525 | 1524 | |
| 1526 | 1525 | test("a tool result carries the card it renders", async () => { |
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.