Public: anyone can read this; the crew can change it.
`releases` answers with a ui://forge/release card: each kept release's version, when it rolled, how many pulls it carried and whether it is proven #11131
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11110-releases-answers-with-a-ui-forge-release → main 6 commits fork 4 threads · 4 unresolved
docs/MCP.md +4 −3
| @@ -175,7 +175,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 175 | 175 | | `read_file` | — | Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge | 307 | |
| 176 | 176 | | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 | |
| 177 | 177 | | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 | |
| 178 | | `releases` | — | Kept releases, newest first | 201 | | |
| 178 | | `releases` | — | Kept releases, newest first | 253 | | |
| 179 | 179 | | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 | |
| 180 | 180 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | |
| 181 | 181 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | |
| @@ -482,15 +482,16 @@ 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`, `releases`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` 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`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. 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). |
| 491 | 491 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 492 | 492 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| 493 | 493 | Production row the same way. Any other address stays plain text, never a link. |
| 494 | The Releases card (`releases`, `ui://forge/release`) is one row per kept release, newest first: its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it) and one word, `proven` (the keeper verified it), `unproven` (preflight failed, the keeper never cut it or is still verifying it) or `rolled back` (the keeper rolled it back). The keeper's row is looked up by sha, however old. The rows are the `items` of the tool's text, which carry the same `proof` and `pull`; `releases` with `shipped` has no card. | |
| 494 | 495 | The Forks card (`fork_tree`, `ui://forge/forks`) is one row per repository you may read, nested by a `depth` field the card pads (not leading spaces, which HTML collapses); the value says how it follows: `follows on its own`, `takes updates`, `offers updates` (the card's own wording for the `offers` mode) or `off`. |
| 495 | 496 | `resources/list` and `resources/read` serve one small |
| 496 | 497 | sandboxed page, which draws the `structuredContent.card` model that the |
src/keeper/record.ts +6
| @@ -142,6 +142,12 @@ export function listKeeperReleases(db: Database, limit = 20): KeeperRelease[] { | ||
| 142 | 142 | return db.query<Row, [number]>(`SELECT * FROM keeper_release ORDER BY id DESC LIMIT ?1`).all(limit).map(toRelease); |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | /** The keeper's newest row for one sha, whatever its age; undefined when it never cut it. */ | |
| 146 | export function keeperReleaseOfSha(db: Database, sha: string): KeeperRelease | undefined { | |
| 147 | const row = db.query<Row, [string]>(`SELECT * FROM keeper_release WHERE sha = ?1 ORDER BY id DESC LIMIT 1`).get(sha); | |
| 148 | return row === null ? undefined : toRelease(row); | |
| 149 | } | |
| 150 | ||
| 145 | 151 | /** The release the keeper has not finished with, if any. */ |
| 146 | 152 | export function inFlight(db: Database): KeeperRelease | undefined { |
| 147 | 153 | const row = db |
src/release/answer.ts +19 −1
| @@ -10,8 +10,12 @@ import type { User } from "../core/users.ts"; | ||
| 10 | 10 | import { running } from "../kernel/version.ts"; |
| 11 | 11 | import { BOOTED, mayRead } from "./gate.ts"; |
| 12 | 12 | import { chosenRepo, readLandings, type Landing, type Landings } from "./landings.ts"; |
| 13 | import { keeperReleaseOfSha, type Stage } from "../keeper/record.ts"; | |
| 13 | 14 | import { listReleases, releaseName } from "./record.ts"; |
| 14 | 15 | |
| 16 | /** What the forge can say of a kept release's soundness: the keeper's verdict, else the preflight's. */ | |
| 17 | export type Proof = "proven" | "unproven" | "rolled back"; | |
| 18 | ||
| 15 | 19 | export const RELEASES_REFUSAL = "the release history is for the forge's administrators and its repository's readers"; |
| 16 | 20 | |
| 17 | 21 | export type ReleasesAnswer = |
| @@ -40,14 +44,27 @@ export async function shippedAnswer(app: App, asked: string | null = null) { | ||
| 40 | 44 | return { version: read.version, commit: read.commit, live: read.live.filter(keep), onItsWay: read.onItsWay.filter(keep) }; |
| 41 | 45 | } |
| 42 | 46 | |
| 47 | /** | |
| 48 | * The word for a kept release: the keeper's verdict when it has one, else the | |
| 49 | * preflight's. `proven` only when the keeper verified it; a release the keeper | |
| 50 | * never cut (or is still cutting) or whose preflight failed is `unproven`. | |
| 51 | */ | |
| 52 | export function proofOf(stage: Stage | undefined, preflightOk: boolean): Proof { | |
| 53 | if (stage === "rolled_back") return "rolled back"; | |
| 54 | if (!preflightOk) return "unproven"; | |
| 55 | return stage === "verified" ? "proven" : "unproven"; | |
| 56 | } | |
| 57 | ||
| 43 | 58 | /** The kept releases newest first, for whoever may read /releases; others get its refusal. */ |
outdated
One nested ternary decides a word the user reads as a safety claim. Pull it into a small named function and test each branch (rolled_back, rolling, verifying, !ok, default). A release with no keeper row and ok=true is called |
||
| 44 | 59 | export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer { |
| 45 | 60 | if (!mayRead(app, user)) return { ok: false, error: "forbidden", message: RELEASES_REFUSAL }; |
outdated
Counts landings of this same sha that have a pullNumber, so the result is 0 or 1 and also loops over every release for each release. This is not pulls carried and may not match the releases page. Compute it from the real source of carried pulls, once, not per row. |
||
| 46 | 61 | const live = running(); |
| 62 | const kept = listReleases(app.db); | |
| 47 | 63 | return { |
| 48 | 64 | ok: true, |
| 49 | 65 | value: { |
| 50 | items: listReleases(app.db).map((r) => { | |
| 66 | items: kept.map((r) => { | |
| 67 | const proof = proofOf(keeperReleaseOfSha(app.db, r.sha)?.stage, r.ok); | |
| 51 | 68 | const isLive = live.commit !== null && r.sha === live.commit; |
| 52 | 69 | const repo = getRepoById(app.db, r.repoId); |
| 53 | 70 | return { |
| @@ -56,6 +73,7 @@ export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer | ||
| 56 | 73 | pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber }, |
| 57 | 74 | landed: r.createdAt, |
| 58 | 75 | ok: r.ok, |
| 76 | proof, | |
| 59 | 77 | ...(isLive ? { live: true, since: BOOTED } : {}), |
| 60 | 78 | }; |
| 61 | 79 | }), |
src/web/studio/cards.ts +22 −2
| @@ -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" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -42,7 +42,7 @@ 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", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"]; | |
| 46 | 46 | |
| 47 | 47 | /** The tool whose answer each card draws. */ |
| 48 | 48 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -51,6 +51,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 51 | 51 | runs: "run", |
| 52 | 52 | asks: "decision", |
| 53 | 53 | milestones: "milestones", |
| 54 | releases: "release", | |
| 54 | 55 | asks_act: "decision", |
| 55 | 56 | waves: "wave", |
| 56 | 57 | map: "map", |
| @@ -85,6 +86,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 85 | 86 | board: "Board", |
| 86 | 87 | forks: "Forks", |
| 87 | 88 | milestones: "Milestones", |
| 89 | release: "Releases", | |
| 88 | 90 | }; |
| 89 | 91 | |
| 90 | 92 | type Obj = Record<string, unknown>; |
| @@ -98,6 +100,7 @@ const FEED_LIMIT = 10; | ||
| 98 | 100 | const PULLS_LIMIT = 20; |
| 99 | 101 | const ASKED_WORDS = 80; |
| 100 | 102 | const ASKED_LIMIT = 20; |
| 103 | const RELEASE_ROWS = 20; | |
| 101 | 104 | /** A waiting row's words are cut to this many characters. */ |
| 102 | 105 | const SAYS_WORDS = 160; |
| 103 | 106 | /** A board card shows this many panels, then counts the rest. */ |
| @@ -333,6 +336,23 @@ export function cardFor( | ||
| 333 | 336 | } |
| 334 | 337 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 335 | 338 | } |
| 339 | if (kind === "release") { | |
| 340 | // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card. | |
| 341 | const items = list(obj(value)["items"]).map(obj); | |
| 342 | if (!Array.isArray(obj(value)["items"])) return undefined; | |
| 343 | const count = (w: string): number => items.filter((i) => i["proof"] === w).length; | |
| 344 | status = `${items.length} kept · ${count("proven")} proven · ${count("unproven")} unproven · ${count("rolled back")} rolled back`; | |
| 345 | for (const i of items.slice(0, RELEASE_ROWS)) { | |
| 346 | const p = obj(i["pull"]); | |
| 347 | const pull = text(p["number"]) === "" ? "" : `#${text(p["number"])} · `; | |
| 348 | const at = text(i["landed"]); | |
| 349 | rows.push({ | |
| 350 | label: text(i["version"]) || text(i["sha"]), | |
| 351 | value: `${at === "" ? "" : `${timeAgo(at)} · `}${pull}${text(i["proof"])}`, | |
| 352 | }); | |
| 353 | } | |
| 354 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; | |
| 355 | } | |
| 336 | 356 | if (kind === "asked") { |
| 337 | 357 | // `asked` answers `{ asked: [{ repo, number, title, state, flagged, pulls }] }`. |
| 338 | 358 | const asks = list(obj(value)["asked"]).map(obj); |
test/mcp-releases-card.test.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** MCP `releases` draws the `ui://forge/release` card: a row per kept release, matching the tool's text. */ | |
Missing tests that would fail without the change: rolled back, the keeper-stage mapping, a hostile version string rendered as text, |
||
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { toolEntries } from "../src/http/mcp.ts"; | |
| 12 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 13 | import { finish, recordCut } from "../src/keeper/record.ts"; | |
| 14 | import { proofOf, releasesAnswer } from "../src/release/answer.ts"; | |
| 15 | import { cardFor } from "../src/web/studio/cards.ts"; | |
| 16 | import { recordRelease } from "../src/release/record.ts"; | |
| 17 | import { CARD_OF_TOOL, CARD_SHELL, SHELL_JS } from "../src/web/studio/cards.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("MCP releases card", () => { | |
| 23 | let dataDir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let token = ""; | |
| 27 | ||
| 28 | const rpc = async (method: string, params: unknown = {}) => { | |
| 29 | const res = await fetch(`${servers.url}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 32 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), | |
| 33 | }); | |
| 34 | return ((await res.json()) as any).result; | |
| 35 | }; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-releases-card-")); | |
| 39 | app = createApp({ dataDir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 42 | if (!r.ok) throw new Error("user"); | |
| 43 | token = createToken(app.db, r.value.id, "admin", "admin").token; | |
| 44 | const repo = await createRepo(app, r.value, { name: "forge", visibility: "public" }); | |
| 45 | if (!repo.ok) throw new Error("repo"); | |
| 46 | recordRelease(app.db, { sha: "a".repeat(40), repoId: repo.value.id, pullNumber: 7, ok: true, line: "ok" }); | |
| 47 | recordRelease(app.db, { sha: "b".repeat(40), repoId: repo.value.id, pullNumber: 8, ok: false, line: "preflight failed" }); | |
| 48 | const cut = (sha: string, stage?: "verified" | "rolled_back") => { | |
| 49 | const k = recordCut(app.db, { version: "v-" + sha, sha, knownGood: { version: "v0", image: "img" }, at: 1 }); | |
| 50 | if (stage !== undefined) finish(app.db, k.id, stage, "t", 2); | |
| 51 | }; | |
| 52 | // the oldest kept release is the one the keeper rolled back, and 120 later keeper rows push it past any cap | |
| 53 | recordRelease(app.db, { sha: "c".repeat(40), repoId: repo.value.id, pullNumber: 5, ok: true, line: "ok" }); | |
| 54 | cut("c".repeat(40), "rolled_back"); | |
| 55 | for (let i = 0; i < 120; i++) cut(i.toString(16).padStart(40, "0"), "verified"); | |
| 56 | cut("a".repeat(40), "verified"); | |
| 57 | recordRelease(app.db, { sha: "d".repeat(40), repoId: repo.value.id, pullNumber: null, ok: true, line: "ok" }); | |
| 58 | }, 60_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | servers.stop(); | |
| 62 | app.close(); | |
| 63 | await rm(dataDir, { recursive: true, force: true }); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the tool names the card and resources/list serves it", async () => { | |
| 67 | expect(CARD_OF_TOOL["releases"]).toBe("release"); | |
| 68 | const entry = toolEntries(TOOLS).find((t) => t.name === "releases") as any; | |
| 69 | expect(entry._meta.ui.resourceUri).toBe("ui://forge/release"); | |
| 70 | const uris = ((await rpc("resources/list")).resources as any[]).map((r) => r.uri); | |
| 71 | expect(uris).toContain("ui://forge/release"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("one row per release, matching the releases answer", async () => { | |
| 75 | const res = await rpc("tools/call", { name: "releases", arguments: {} }); | |
| 76 | const items = JSON.parse(res.content[0].text).items as any[]; | |
| 77 | const card = res.structuredContent.card; | |
| 78 | expect(card.kind).toBe("release"); | |
| 79 | expect(items.map((i) => i.proof)).toEqual(["unproven", "rolled back", "unproven", "proven"]); | |
| 80 | expect(card.rows.map((r: any) => r.value.split(" · ").slice(1).join(" · ")).join("|")).toBe("unproven|#5 · rolled back|#8 · unproven|#7 · proven"); | |
| 81 | expect(items.find((i) => i.sha.startsWith("ccccccc")).pull.number).toBe(5); | |
| 82 | expect(card.status).toBe("4 kept · 1 proven · 2 unproven · 1 rolled back"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the card's pull is the one the releases page names, not a count", async () => { | |
| 86 | const answer = releasesAnswer(app, undefined); | |
| 87 | expect(answer.ok).toBe(false); | |
| 88 | const res = await rpc("tools/call", { name: "releases", arguments: {} }); | |
| 89 | const items = JSON.parse(res.content[0].text).items as any[]; | |
| 90 | expect(items.every((i) => !("pulls" in i))).toBe(true); | |
| 91 | expect(res.structuredContent.card.rows[3].value).toContain("#7"); | |
| 92 | expect(res.structuredContent.card.rows[3].value).not.toContain("1 pull"); | |
| 93 | }); | |
| 94 | ||
| 95 | test("proof words, one per branch", () => { | |
| 96 | expect(proofOf("rolled_back", true)).toBe("rolled back"); | |
| 97 | expect(proofOf("rolled_back", false)).toBe("rolled back"); | |
| 98 | expect(proofOf("rolling", true)).toBe("unproven"); | |
| 99 | expect(proofOf("verifying", true)).toBe("unproven"); | |
| 100 | expect(proofOf("verified", false)).toBe("unproven"); | |
| 101 | expect(proofOf("verified", true)).toBe("proven"); | |
| 102 | expect(proofOf(undefined, true)).toBe("unproven"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the label is the version when live, else the short sha", () => { | |
| 106 | const card = cardFor("releases", { items: [{ sha: "abc1234", version: "1.2.3", landed: "", proof: "proven" }, { sha: "def5678", version: null, landed: "", proof: "unproven" }] })!; | |
| 107 | expect(card.rows.map((r) => r.label)).toEqual(["1.2.3", "def5678"]); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a hostile version travels as text and the shell never parses it as html", () => { | |
| 111 | const evil = `<img src=x onerror=alert(1)>"&`; | |
| 112 | const card = cardFor("releases", { items: [{ sha: "abc1234", version: evil, landed: "", proof: "proven" }] })!; | |
| 113 | expect(card.rows[0]!.label).toBe(evil); | |
| 114 | expect(JSON.stringify(card)).toContain(JSON.stringify(evil).slice(1, -1)); | |
| 115 | expect(CARD_SHELL("release")).not.toContain(evil); | |
| 116 | expect(SHELL_JS).not.toContain("innerHTML"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("releases with shipped has no card", async () => { | |
| 120 | const res = await rpc("tools/call", { name: "releases", arguments: { shipped: true } }); | |
| 121 | expect(res.structuredContent?.card).toBeUndefined(); | |
| 122 | expect(cardFor("releases", { version: "1", live: [], onItsWay: [] })).toBeUndefined(); | |
| 123 | }); | |
| 124 | }); |
test/mcp.test.ts +1
| @@ -1299,6 +1299,7 @@ describe("the forge over MCP", () => { | ||
| 1299 | 1299 | "ui://forge/milestones", |
| 1300 | 1300 | "ui://forge/pull", |
| 1301 | 1301 | "ui://forge/pulls", |
| 1302 | "ui://forge/release", | |
| 1302 | 1303 | "ui://forge/run", |
| 1303 | 1304 | "ui://forge/scorecard", |
| 1304 | 1305 | "ui://forge/wave", |
listKeeperReleases(app.db, 100) caps the lookup: any kept release older than the newest 100 keeper rows falls through to
proven, including rolled-back ones. Query by sha, or lift the cap, and test it.