Public: anyone can read this; the crew can change it.
A failed run's feed post names the pull or issue it was for and its first error line, and links to that line of the log #5839
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5812-a-failed-run-s-feed-post-names-the-pull → main 3 commits fork 5 threads · 5 unresolved
9 files
changed +229
−11
src/actions/runs.ts +16 −1
| @@ -26,12 +26,14 @@ import { | ||
| 26 | 26 | listRuns as queryRuns, |
| 27 | 27 | type ListRunsOptions, |
| 28 | 28 | type Run, |
| 29 | setRunErrorLine, | |
| 29 | 30 | } from "../core/runs.ts"; |
| 30 | 31 | import type { User } from "../core/users.ts"; |
| 31 | 32 | import { abortAgentRun } from "../crew/running.ts"; |
| 32 | 33 | import { queueAgentRun, taskOf } from "../crew/dispatch.ts"; |
| 33 | 34 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 34 | 35 | import { readRunLog, runLogPath } from "../runs/log.ts"; |
| 36 | import { firstErrorLine } from "../runs/error_line.ts"; | |
| 35 | 37 | import { requireReader, requireWriter, type AccessError } from "./access.ts"; |
| 36 | 38 | |
| 37 | 39 | /** What a run event carries: enough for a surface to render the card. */ |
| @@ -66,10 +68,23 @@ export function announceRun(app: App, run: Run, actorId?: string): void { | ||
| 66 | 68 | kind, |
| 67 | 69 | repoId: run.repoId, |
| 68 | 70 | ...(actorId === undefined ? {} : { actorId }), |
| 69 | payload: runPayload(run), | |
| 71 | payload: { ...runPayload(run), ...(kind === "run.finished" ? failureOf(app, run) : {}) }, | |
| 70 | 72 | }); |
| 71 | 73 | } |
| 72 | 74 | |
| 75 | /** | |
| 76 | * What a failed run's public event may say of it: the number of the pull or | |
| 77 | * issue it was for. The first error line of its log is a log line, so it is | |
| 78 | * kept with the run (`setRunErrorLine`), once, and never put on the event. | |
| 79 | */ | |
| 80 | function failureOf(app: App, run: Run): Record<string, unknown> { | |
| 81 | if (run.state !== "failed") return {}; | |
| 82 | const found = firstErrorLine(readRunLog(app.config.dataDir, run.id).lines); | |
| 83 | if (found !== undefined) setRunErrorLine(app.db, run.id, found.text, found.at); | |
| 84 | const subject = run.issueId === null ? undefined : getIssueById(app.db, run.issueId); | |
| 85 | return subject === undefined ? {} : { subject: subject.number }; | |
| 86 | } | |
| 87 | ||
| 73 | 88 | /** |
| 74 | 89 | * Whether `actor` may read this repository's runs: anyone who can read it, |
| 75 | 90 | * unless it runs a private app — then only someone who may enter the app. |
src/core/migrations/20261025120000-run-error-line.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // The first error line of a failed run's log and its place there, kept with | |
| 2 | // the run as it finishes so the feed never opens a log. A log line is for | |
| 3 | // the repository's writers and crew; it lives here, never on the public | |
| 4 | // event. Additive; empty on every run before this column. | |
| 5 | export default ` | |
| 6 | ALTER TABLE run ADD COLUMN error_line TEXT NOT NULL DEFAULT ''; | |
| 7 | ALTER TABLE run ADD COLUMN error_at INTEGER NOT NULL DEFAULT 0; | |
| 8 | `; |
src/core/runs.ts +15
| @@ -1262,3 +1262,18 @@ export function listRunning( | ||
| 1262 | 1262 | issue: r.issue_number, |
| 1263 | 1263 | })); |
| 1264 | 1264 | } |
| 1265 | ||
| 1266 | /** Keep a finished run's first error line (see `runs/error_line.ts`) beside it. */ | |
| 1267 | export function setRunErrorLine(db: Database, runId: string, text: string, at: number): void { | |
| 1268 | db.query("UPDATE run SET error_line = ?2, error_at = ?3 WHERE id = ?1").run(runId, text, at); | |
| 1269 | } | |
| 1270 | ||
| 1271 | /** That line and its number in the log, when one was kept. */ | |
| 1272 | export function getRunErrorLine(db: Database, runId: string): { text: string; at: number } | undefined { | |
| 1273 | const row = db | |
| 1274 | .query<{ error_line: string; error_at: number }, [string]>( | |
| 1275 | "SELECT error_line, error_at FROM run WHERE id = ?1", | |
| 1276 | ) | |
| 1277 | .get(runId); | |
| 1278 | return row === null || row === undefined || row.error_line === "" ? undefined : { text: row.error_line, at: row.error_at }; | |
| 1279 | } |
src/feed/items.ts +23 −3
| @@ -20,10 +20,12 @@ import { | ||
| 20 | 20 | import { joinMany, toldBy, type Pull } from "../core/pulls.ts"; |
| 21 | 21 | import { |
| 22 | 22 | canSeeOwnerFacts, |
| 23 | onCrew, | |
| 23 | 24 | getReposByIds, |
| 24 | 25 | listReposWritableTo, |
| 25 | 26 | type Repo, |
| 26 | 27 | } from "../core/repos.ts"; |
| 28 | import { getRunErrorLine } from "../core/runs.ts"; | |
| 27 | 29 | import { getUserById, type User } from "../core/users.ts"; |
| 28 | 30 | import { watchedBy } from "../core/watches.ts"; |
| 29 | 31 | import { chooseCandidates } from "./choose.ts"; |
| @@ -285,6 +287,12 @@ export function candidatesFor( | ||
| 285 | 287 | }, |
| 286 | 288 | }; |
| 287 | 289 | |
| 290 | const logs = new Map<string, boolean>(); | |
| 291 | const readsLogs = (repo: Repo): boolean => { | |
| 292 | if (!logs.has(repo.id)) logs.set(repo.id, owns(repo) || onCrew(db, repo, viewer)); | |
| 293 | return logs.get(repo.id)!; | |
| 294 | }; | |
| 295 | ||
| 288 | 296 | const out: Candidate[] = []; |
| 289 | 297 | const folded = foldPulls(events); |
| 290 | 298 | for (const e of events) { |
| @@ -292,7 +300,7 @@ export function candidatesFor( | ||
| 292 | 300 | if (!repo) continue; |
| 293 | 301 | const fold = folded.get(e.id); |
| 294 | 302 | if (fold === "skip") continue; |
| 295 | const c = fromEvent(db, e, repo, owns(repo), subjects); | |
| 303 | const c = fromEvent(db, e, repo, owns(repo), readsLogs(repo), subjects); | |
| 296 | 304 | if (c) |
| 297 | 305 | out.push({ |
| 298 | 306 | ...c, |
| @@ -421,6 +429,7 @@ function fromEvent( | ||
| 421 | 429 | e: ForgeEvent, |
| 422 | 430 | repo: Repo, |
| 423 | 431 | ownerFacts: boolean, |
| 432 | logReader: boolean, | |
| 424 | 433 | subjects: Subjects, |
| 425 | 434 | ): Partial | undefined { |
| 426 | 435 | const common = { |
| @@ -449,6 +458,17 @@ function fromEvent( | ||
| 449 | 458 | const cost = typeof p["costUsd"] === "number" ? p["costUsd"] : 0; |
| 450 | 459 | const link = `${base(repo)}/runs/${String(p["runId"] ?? "")}`; |
| 451 | 460 | const summary = clip(typeof p["summary"] === "string" ? p["summary"] : ""); |
| 461 | const subject = typeof p["subject"] === "number" ? ` on #${p["subject"]}` : ""; | |
| 462 | // A log line is for writers and crew; everyone else keeps the summary. | |
| 463 | const kept = | |
| 464 | state === "failed" && logReader ? getRunErrorLine(db, String(p["runId"] ?? "")) : undefined; | |
| 465 | const errorLine = kept?.text ?? ""; | |
| 466 | const errorAt = kept?.at ?? 0; | |
| 467 | // The line it broke at, on the run page that already holds the whole log. | |
| 468 | const failLink = state === "failed" && errorLine !== "" && errorAt > 0 | |
| 469 | ? `${link}?all=1#L${errorAt}` | |
| 470 | : link; | |
| 471 | const failure = errorLine === "" ? summary : errorLine; | |
| 452 | 472 | if (kind === "agent" && cost >= COST_FLOOR_USD && ownerFacts) |
| 453 | 473 | return { |
| 454 | 474 | ...common, |
| @@ -459,9 +479,9 @@ function fromEvent( | ||
| 459 | 479 | return { |
| 460 | 480 | ...common, |
| 461 | 481 | kind: "run", |
| 462 | link, | |
| 482 | link: failLink, | |
| 463 | 483 | urgency: state === "failed" ? 4 : 0, |
| 464 | what: `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind} run ${state === "failed" ? "failed" : "finished"}${summary ? `: ${summary}` : ""}`, | |
| 484 | what: `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind} run${subject} ${state === "failed" ? "failed" : "finished"}${state === "failed" ? (failure ? `: ${failure}` : "") : summary ? `: ${summary}` : ""}`, | |
| 465 | 485 | }; |
| 466 | 486 | } |
| 467 | 487 | case "pull.opened": |
src/runs/error_line.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * The line of a run's log that says what broke: the first one carrying an | |
| 3 | * error or fail marker, and its 1-based place in the lines `readRunLog` | |
| 4 | * returns — the same count the run page numbers its rows by (`#L<n>`). | |
| 5 | * Read once, when the run finishes, and kept with the event; the feed never | |
Stale: it is kept with the run (error_line column), not the event. |
||
| 6 | * opens a log. | |
| 7 | */ | |
| 8 | ||
Matches any prose containing the word 'error' or 'fail'. Rank 'error:' / '(fail)' prefixed lines first, so the link lands on the failure and not on the agent's talk. |
||
| 9 | /** bun's `(fail)`, `error:`, `Error`, `failed`, `failure` — as words, any case. */ | |
| 10 | const MARKER = /\(fail\)|\b(?:error|fail(?:ed|ure|s)?)\b/i; | |
| 11 | ||
| 12 | export const ERROR_LINE_MAX = 120; | |
| 13 | ||
| 14 | export interface ErrorLine { | |
| 15 | /** The line, trimmed and cut to `ERROR_LINE_MAX`. */ | |
| 16 | text: string; | |
| 17 | /** Its 1-based number in the log. */ | |
| 18 | at: number; | |
| 19 | } | |
| 20 | ||
| 21 | export function firstErrorLine(lines: readonly string[]): ErrorLine | undefined { | |
| 22 | for (let i = 0; i < lines.length; i++) { | |
| 23 | const text = lines[i]!.trim(); | |
| 24 | if (text !== "" && MARKER.test(text)) | |
| 25 | return { text: text.length > ERROR_LINE_MAX ? `${text.slice(0, ERROR_LINE_MAX - 1)}…` : text, at: i + 1 }; | |
| 26 | } | |
| 27 | return undefined; | |
| 28 | } |
src/web/pages/runs.ts +8 −6
| @@ -157,18 +157,18 @@ export function lineKind(line: string): LineKind { | ||
| 157 | 157 | /** One line of a run's log, as the page draws it: §5's row, a badge naming |
| 158 | 158 | * who or what spoke in the lead, the words as its title. The live script in |
| 159 | 159 | * `client.ts` builds the same markup. */ |
| 160 | export function logLine(line: string, who = "agent"): Html { | |
| 160 | export function logLine(line: string, who = "agent", anchor?: string): Html { | |
| 161 | 161 | const kind = lineKind(line); |
| 162 | 162 | if (kind === "tool") { |
| 163 | 163 | const rest = line.slice(2); |
| 164 | 164 | const at = rest.indexOf(" "); |
| 165 | 165 | const tool = at === -1 ? rest : rest.slice(0, at); |
| 166 | 166 | const args = at === -1 ? "" : rest.slice(at + 1); |
| 167 | return logRow(kind, "tool", html`<b>${tool}</b> ${args}`); | |
| 167 | return logRow(kind, "tool", html`<b>${tool}</b> ${args}`, undefined, anchor); | |
| 168 | 168 | } |
| 169 | if (kind === "you") return logRow(kind, "you", line.slice(5), "secondary"); | |
| 170 | if (kind === "forge") return logRow(kind, "forge", line, "outline"); | |
| 171 | return logRow(kind, who, line); | |
| 169 | if (kind === "you") return logRow(kind, "you", line.slice(5), "secondary", anchor); | |
| 170 | if (kind === "forge") return logRow(kind, "forge", line, "outline", anchor); | |
| 171 | return logRow(kind, who, line, undefined, anchor); | |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | 174 | /** The row itself; also the pulse's, in `studio/log.ts`. */ |
| @@ -177,9 +177,11 @@ export function logRow( | ||
| 177 | 177 | label: string, |
| 178 | 178 | body: Html | string | null, |
| 179 | 179 | variant?: "secondary" | "outline", |
| 180 | /** The row's `id`, so a link can point at this line (`#L12`). */ | |
| 181 | anchor?: string, | |
| 180 | 182 | ): Html { |
| 181 | 183 | const lead = variant === undefined ? badge(label) : badge(label, { variant }); |
| 182 | return html`<div class="row" data-line="${kind}"> | |
| 184 | return html`<div class="row" data-line="${kind}"${anchor === undefined ? null : html` id="${anchor}"`}> | |
| 183 | 185 | <span class="row-lead">${lead}</span> |
| 184 | 186 | <span class="row-main"><span class="row-title">${body}</span></span> |
| 185 | 187 | </div>`; |
src/web/studio/log.ts +3 −1
| @@ -71,6 +71,8 @@ export function logBox(opts: { | ||
| 71 | 71 | /** The lines are already the tail of a longer log (`readRunLog` cut it): |
| 72 | 72 | * the link then reads all of what was read, not all of the run. */ |
| 73 | 73 | truncated?: boolean; |
| 74 | /** Give each row an `id` of `L<its number in the log>`: one log to a page. */ | |
| 75 | anchors?: boolean; | |
| 74 | 76 | }): Html { |
| 75 | 77 | const { shown, hidden } = logWindow(opts.lines, opts.cap); |
| 76 | 78 | return html`<div |
| @@ -95,7 +97,7 @@ export function logBox(opts: { | ||
| 95 | 97 | >.`} |
| 96 | 98 | </p>`, |
| 97 | 99 | )} |
| 98 | ${shown.map((l) => logLine(l, opts.who))} | |
| 100 | ${shown.map((l, i) => logLine(l, opts.who, opts.anchors === true ? `L${hidden + i + 1}` : undefined))} | |
| 99 | 101 | ${when( |
| 100 | 102 | opts.pulse === true, |
| 101 | 103 | () => |
src/web/studio/run.ts +1
| @@ -341,6 +341,7 @@ export async function runView( | ||
| 341 | 341 | )} |
| 342 | 342 | ${when(!logHidden, () => logBox({ |
| 343 | 343 | id: "run-log", |
| 344 | anchors: true, | |
| 344 | 345 | who, |
| 345 | 346 | cap: all |
| 346 | 347 | ? Math.max(lines.length, RUN_TRANSCRIPT_CAP) |
test/feed-failed-run.test.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** A failed run's post names its pull or issue and its first error line, and links to that line (#5812). */ | |
| 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 { announceRun } from "../src/actions/runs.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { firstErrorLine } from "../src/runs/error_line.ts"; | |
| 15 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | const WORDS = "an agent run on #1 failed: error: Cannot find module './nope' from src/a.ts"; | |
| 19 | ||
| 20 | describe("a failed run's feed post", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let cookie: string; | |
| 25 | let token: string; | |
| 26 | let bobCookie: string; | |
| 27 | let bobToken: string; | |
| 28 | let runId: string; | |
| 29 | let ranOn: number; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-feed-failed-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const u = await createUser(app.db, { | |
| 36 | origin: "seed", trusted: true, username: "alice", | |
| 37 | email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!u.ok) throw new Error(u.error); | |
| 40 | cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 41 | token = createToken(app.db, u.value.id, "ro", "ro").token; | |
| 42 | const b = await createUser(app.db, { | |
| 43 | origin: "seed", trusted: true, username: "bob", | |
| 44 | email: "[email protected]", password: "a-good-passphrase", | |
| 45 | }); | |
| 46 | if (!b.ok) throw new Error(b.error); | |
| 47 | bobCookie = `forge_session=${createSession(app.db, b.value.id).token}`; | |
| 48 | bobToken = createToken(app.db, b.value.id, "ro", "ro").token; | |
| 49 | const made = await createRepo(app, u.value, { name: "garden", visibility: "public" }); | |
| 50 | if (!made.ok) throw new Error(String(made.error)); | |
| 51 | const issue = createIssue(app.db, { repoId: made.value.id, authorId: u.value.id, title: "Water" }); | |
| 52 | if (!issue.ok) throw new Error(issue.error); | |
| 53 | ranOn = issue.value.number; | |
| 54 | const run = createRun(app.db, { | |
| 55 | repoId: made.value.id, kind: "agent", issueId: issue.value.id, actorId: u.value.id, | |
| 56 | }); | |
| 57 | runId = run.id; | |
| 58 | claimRun(app.db, run.id); | |
| 59 | for (const line of ["→ read src/a.ts", "I will fix it", "error: Cannot find module './nope' from src/a.ts", "error: later"]) | |
| 60 | appendRunLog(dataDir, run.id, line); | |
| 61 | const done = finishRun(app.db, run.id, { state: "failed", summary: "the run failed" }); | |
| 62 | announceRun(app, done!, u.value.id); | |
| 63 | }); | |
| 64 | ||
| 65 | afterAll(async () => { | |
| 66 | servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("the first error marker is found, cut to 120 chars, with its line number", () => { | |
| 72 | expect(firstErrorLine(["ok", "fine"])).toBeUndefined(); | |
| 73 | expect(firstErrorLine(["a", `Error: ${"x".repeat(200)}`, "error: two"])).toEqual({ | |
| 74 | text: `Error: ${"x".repeat(112)}…`, | |
| 75 | at: 2, | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("home reads the words and links to the line", async () => { | |
| 80 | expect(ranOn).toBe(1); | |
| 81 | const text = await (await fetch(`${servers.url}/`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text(); | |
| 82 | expect(text).toContain("an agent run on #1 failed: error: Cannot find module"); | |
| 83 | expect(text).toContain(`/alice/garden/runs/${runId}?all=1#L3`); | |
| 84 | }); | |
| 85 | ||
| 86 | test("MCP feed carries the same words and link", async () => { | |
| 87 | const res = await fetch(`${servers.url}/mcp`, { | |
| 88 | method: "POST", | |
| 89 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 90 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed", arguments: {} } }), | |
| 91 | }); | |
| 92 | const body: any = await res.json(); | |
| 93 | const items: { what: string; link: string }[] = JSON.parse(body.result.content[0].text).items; | |
| 94 | const post = items.find((i) => i.what.includes("failed"))!; | |
| 95 | expect(post.what).toBe(WORDS); | |
| 96 | expect(post.link).toBe(`/alice/garden/runs/${runId}?all=1#L3`); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a reader who is not crew gets the summary and the plain link, never the log line", async () => { | |
| 100 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie: `${bobCookie}; forge_mode=studio` } })).text(); | |
| 101 | expect(home).toContain("failed"); | |
| 102 | expect(home).not.toContain("Cannot find module"); | |
| 103 | expect(home).not.toContain("#L3"); | |
| 104 | const res = await fetch(`${servers.url}/mcp`, { | |
| 105 | method: "POST", | |
| 106 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${bobToken}` }, | |
| 107 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed", arguments: {} } }), | |
| 108 | }); | |
| 109 | const raw: string = ((await res.json()) as any).result.content[0].text; | |
| 110 | expect(raw).not.toContain("Cannot find module"); | |
| 111 | const post = (JSON.parse(raw).items as { what: string; link: string }[]).find((i) => i.what.includes("failed"))!; | |
| 112 | expect(post.what).toBe("an agent run on #1 failed: the run failed"); | |
| 113 | expect(post.link).toBe(`/alice/garden/runs/${runId}`); | |
| 114 | const events = await fetch(`${servers.url}/api/v1/repos/alice/garden/events`, { | |
| 115 | headers: { authorization: `Bearer ${bobToken}` }, | |
| 116 | }); | |
| 117 | const body = await events.text(); | |
| 118 | expect(body).toContain("run.finished"); | |
| 119 | expect(body).not.toContain("errorLine"); | |
| 120 | expect(body).not.toContain("Cannot find module"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the run page numbers its rows so the link lands", async () => { | |
| 124 | const text = await (await fetch(`${servers.url}/alice/garden/runs/${runId}?all=1`, { headers: { cookie } })).text(); | |
| 125 | expect(text).toContain('id="L3"'); | |
| 126 | }); | |
| 127 | }); |
new above
offline
High: this puts a log line, which
mayReadRunLogkeeps to writers and crew, into arun.finishedpayload that SSE (sse.ts:229),/events, webhooks and the feed hand to every repo reader. Keep it off the public event: store it with the run, and show it only where the viewer passesmayReadRunLog.