Public: anyone can read this; the crew can change it.
The OG card for a run names its agent, its issue, its cost and how it ended #2059
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2046-the-og-card-for-a-run-names-its-agent-it → main 2 commits fork 4 threads · 4 unresolved
2 files
changed +81
−4
src/web/pages/og.ts +30 −3
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | import { createHash } from "node:crypto"; |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import type { App } from "../../app.ts"; |
| 18 | import type { Run } from "../../core/runs.ts"; | |
| 19 | import { getUserById } from "../../core/users.ts"; | |
| 18 | 20 | import { getRepoRun } from "../../actions/runs.ts"; |
| 19 | 21 | import { getAppBySlug, isSlug } from "../../core/apps.ts"; |
| 20 | 22 | import { |
| @@ -25,7 +27,7 @@ import { | ||
| 25 | 27 | type Repo, |
| 26 | 28 | } from "../../core/repos.ts"; |
| 27 | 29 | import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts"; |
| 28 | import { getIssue, type Issue } from "../../core/issues.ts"; | |
| 30 | import { getIssue, getIssueById, type Issue } from "../../core/issues.ts"; | |
| 29 | 31 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| 30 | 32 | import { mayView } from "../../core/sealed.ts"; |
| 31 | 33 | import { isSafeName } from "../../git/guard.ts"; |
| @@ -132,17 +134,42 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined | ||
| 132 | 134 | }; |
| 133 | 135 | } |
| 134 | 136 | |
| 137 | /** The outcome of an agent run in a word, read from its state and last words. */ | |
| 138 | function outcome(run: Run): string { | |
| 139 | if (run.state === "queued") return "queued"; | |
| 140 | if (run.state === "running") return "working"; | |
| 141 | if (run.state === "cancelled") return "cancelled"; | |
| 142 | if (/opened pull #|updated pull #/.test(run.summary)) return "landed a pull"; | |
| 143 | if (/\bdeclined:/i.test(run.summary)) return "declined"; | |
| 144 | return run.state === "ok" ? "done" : "failed"; | |
| 145 | } | |
| 146 | ||
| 135 | 147 | function runInput(ctx: Ctx, ref: string): CardInput | undefined { |
| 136 | 148 | const at = threePart(ctx, ref); |
| 137 | 149 | if (!at || !RUN_ID.test(at.rest)) return undefined; |
| 150 | // getRepoRun already seals: a run on a sealed thread is the same 404. | |
| 138 | 151 | const found = getRepoRun(ctx.app, undefined, at.repo, at.rest); |
| 139 | 152 | if (!found.ok) return undefined; |
Unreachable guard: getRepoRun already 404s runs whose issue the anonymous reader may not view. Drop mayView here and its import. |
||
| 140 | 153 | const run = found.value; |
| 154 | const db = ctx.app.db; | |
| 155 | const who = | |
| 156 | run.actorId === null ? undefined : getUserById(db, run.actorId)?.username; | |
| 157 | const repoName = `${at.repo.ownerName}/${at.repo.name}`; | |
| 158 | if (run.kind === "agent") { | |
| 159 | const about = run.issueId === null ? undefined : getIssueById(db, run.issueId); | |
| 160 | const thread = about ? `#${about.number} ${about.title}` : undefined; | |
| 161 | return { | |
| 162 | kind: "run", | |
| 163 | title: run.summary || "agent run", | |
| 164 | lines: [repoName, ...(thread ? [thread] : [])], | |
| 165 | stat: `${who ?? "agent"} · ${outcome(run)} · $${run.costUsd.toFixed(2)}`, | |
| 166 | }; | |
| 167 | } | |
| 141 | 168 | return { |
| 142 | 169 | kind: "run", |
| 143 | 170 | title: run.summary || `${run.kind} run`, |
| 144 | lines: [`${at.repo.ownerName}/${at.repo.name}`], | |
| 145 | stat: `${run.kind} · ${run.state}`, | |
| 171 | lines: [repoName, ...(who ? [`by ${who}`] : [])], | |
| 172 | stat: `${run.kind}${run.env ? ` · ${run.env}` : ""} · ${run.state}`, | |
| 146 | 173 | }; |
| 147 | 174 | } |
| 148 | 175 |
test/og-route.test.ts +51 −1
| @@ -9,7 +9,7 @@ import { createApp } from "../src/core/apps.ts"; | ||
| 9 | 9 | import { addDependency } from "../src/core/dependencies.ts"; |
| 10 | 10 | import { getIssue, createIssue } from "../src/core/issues.ts"; |
| 11 | 11 | import { createRun } from "../src/core/runs.ts"; |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | |
| 15 | 15 | describe("og cards", () => { |
| @@ -207,6 +207,56 @@ describe("og cards", () => { | ||
| 207 | 207 | } |
| 208 | 208 | }); |
| 209 | 209 | |
| 210 | test("an agent run names its agent, thread, cost and outcome", async () => { | |
| 211 | const repo = getRepo(app.db, "alice", "shown")!; | |
| 212 | const alice = getUserByName(app.db, "alice")!; | |
| 213 | const issue = getIssue(app.db, repo.id, 1)!; | |
| 214 | const r = createRun(app.db, { | |
| 215 | repoId: repo.id, | |
| 216 | kind: "agent", | |
| 217 | issueId: issue.id, | |
| 218 | actorId: alice.id, | |
| 219 | summary: "opened pull #2 on alice/shown", | |
| 220 | }); | |
| 221 | app.db.query("UPDATE run SET state = 'ok', cost_usd = 1.234 WHERE id = ?1").run(r.id); | |
| 222 | const body = await (await get(`/og/run/alice~shown~${r.id}.svg`)).text(); | |
| 223 | expect(body).toContain("alice"); | |
| 224 | expect(body).toContain("#1 A public issue <b>"); | |
| 225 | expect(body).toContain("$1.23"); | |
| 226 | expect(body).toContain("landed a pull"); | |
| 227 | app.db.query("UPDATE run SET summary = 'declined: too big', state = 'ok' WHERE id = ?1").run(r.id); | |
| 228 | expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("declined ·"); | |
| 229 | app.db.query("UPDATE run SET state = 'failed', summary = 'boom' WHERE id = ?1").run(r.id); | |
| 230 | expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("failed"); | |
| 231 | app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(r.id); | |
| 232 | expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("working"); | |
| 233 | }); | |
| 234 | ||
| 235 | test("a deploy run shows its environment and trigger, no cost", async () => { | |
| 236 | const repo = getRepo(app.db, "alice", "shown")!; | |
| 237 | const alice = getUserByName(app.db, "alice")!; | |
| 238 | const r = createRun(app.db, { | |
| 239 | repoId: repo.id, | |
| 240 | kind: "deploy", | |
| 241 | env: "production", | |
| 242 | actorId: alice.id, | |
| 243 | summary: "deployed", | |
| 244 | }); | |
| 245 | const body = await (await get(`/og/run/alice~shown~${r.id}.svg`)).text(); | |
| 246 | expect(body).toContain("production"); | |
| 247 | expect(body).toContain("by alice"); | |
| 248 | expect(body).not.toContain("$"); | |
| 249 | }); | |
not.toContain("$") passes for many wrong cards; assert the full stat line (deploy · production · ok). |
||
| 250 | ||
| 251 | test("a queued or cancelled run says so", async () => { | |
| 252 | const repo = getRepo(app.db, "alice", "shown")!; | |
| 253 | const r = createRun(app.db, { repoId: repo.id, kind: "agent", summary: "waiting" }); | |
outdated
This passes without the change (the 404 comes from getRepoRun). Remove it or point it at something the change adds. |
||
| 254 | const card = async () => (await get(`/og/run/alice~shown~${r.id}.svg`)).text(); | |
| 255 | expect(await card()).toContain("queued"); | |
| 256 | app.db.query("UPDATE run SET state = 'cancelled' WHERE id = ?1").run(r.id); | |
| 257 | expect(await card()).toContain("cancelled"); | |
| 258 | }); | |
| 259 | ||
| 210 | 260 | test("the wrong kind, number or shape is 404", async () => { |
| 211 | 261 | for (const path of [ |
| 212 | 262 | "issue/alice~shown~2", // a pull is not an issue card |
new above
offline
Summary-regex outcome breaks silently if the wording changes. Next: a structured outcome field on the run.