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

Merged opened this

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

Merged

torque/forge:torque/11110-releases-answers-with-a-ui-forge-release → main 6 commits fork 4 threads · 4 unresolved

  • Merged. torque/11110-releases-answers-with-a-ui-forge-release was merged into main as 9ad7f4a.

Review

Sign in to review this change.
6 files changed +176 −6
docs/MCP.md +4 −3
@@ -175,7 +175,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
175175 | `read_file` | — | Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge | 307 |
176176 | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 |
177177 | `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 |
179179 | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 |
180180 | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 |
181181 | `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
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`, `releases`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` 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`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. 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).
491491 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
492492 linked production row when the app has an http(s) URL; the `new_app` card links its
493493 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.
494495 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`.
495496 `resources/list` and `resources/read` serve one small
496497 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[] {
142142 return db.query<Row, [number]>(`SELECT * FROM keeper_release ORDER BY id DESC LIMIT ?1`).all(limit).map(toRelease);
143143 }
144144
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
145151 /** The release the keeper has not finished with, if any. */
146152 export function inFlight(db: Database): KeeperRelease | undefined {
147153 const row = db
src/release/answer.ts +19 −1
@@ -10,8 +10,12 @@ import type { User } from "../core/users.ts";
1010 import { running } from "../kernel/version.ts";
1111 import { BOOTED, mayRead } from "./gate.ts";
1212 import { chosenRepo, readLandings, type Landing, type Landings } from "./landings.ts";
13 import { keeperReleaseOfSha, type Stage } from "../keeper/record.ts";
1314 import { listReleases, releaseName } from "./record.ts";
1415
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
1519 export const RELEASES_REFUSAL = "the release history is for the forge's administrators and its repository's readers";
1620
1721 export type ReleasesAnswer =
@@ -40,14 +44,27 @@ export async function shippedAnswer(app: App, asked: string | null = null) {
4044 return { version: read.version, commit: read.commit, live: read.live.filter(keep), onItsWay: read.onItsWay.filter(keep) };
4145 }
4246
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 {
outdated

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.

53 if (stage === "rolled_back") return "rolled back";
54 if (!preflightOk) return "unproven";
55 return stage === "verified" ? "proven" : "unproven";
56 }
57
4358 /** 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 proven, which overstates it.

4459 export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer {
4560 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.

4661 const live = running();
62 const kept = listReleases(app.db);
4763 return {
4864 ok: true,
4965 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);
5168 const isLive = live.commit !== null && r.sha === live.commit;
5269 const repo = getRepoById(app.db, r.repoId);
5370 return {
@@ -56,6 +73,7 @@ export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer
5673 pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber },
5774 landed: r.createdAt,
5875 ok: r.ok,
76 proof,
5977 ...(isLive ? { live: true, since: BOOTED } : {}),
6078 };
6179 }),
src/web/studio/cards.ts +22 −2
@@ -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" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -42,7 +42,7 @@ 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", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
4646
4747 /** The tool whose answer each card draws. */
4848 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -51,6 +51,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5151 runs: "run",
5252 asks: "decision",
5353 milestones: "milestones",
54 releases: "release",
5455 asks_act: "decision",
5556 waves: "wave",
5657 map: "map",
@@ -85,6 +86,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
8586 board: "Board",
8687 forks: "Forks",
8788 milestones: "Milestones",
89 release: "Releases",
8890 };
8991
9092 type Obj = Record<string, unknown>;
@@ -98,6 +100,7 @@ const FEED_LIMIT = 10;
98100 const PULLS_LIMIT = 20;
99101 const ASKED_WORDS = 80;
100102 const ASKED_LIMIT = 20;
103 const RELEASE_ROWS = 20;
101104 /** A waiting row's words are cut to this many characters. */
102105 const SAYS_WORDS = 160;
103106 /** A board card shows this many panels, then counts the rest. */
@@ -333,6 +336,23 @@ export function cardFor(
333336 }
334337 return { kind, title: CARD_TITLE[kind], status, rows, actions };
335338 }
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 }
336356 if (kind === "asked") {
337357 // `asked` answers `{ asked: [{ repo, number, title, state, flagged, pulls }] }`.
338358 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, shipped giving no card, and the card rows matching the releases page numbers (box 1). The row assertions use toContain on the same fields the code produced, so they are close to circular.

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", () => {
12991299 "ui://forge/milestones",
13001300 "ui://forge/pull",
13011301 "ui://forge/pulls",
1302 "ui://forge/release",
13021303 "ui://forge/run",
13031304 "ui://forge/scorecard",
13041305 "ui://forge/wave",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs