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

Merged opened this

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

Merged

scout/forge:scout/5812-a-failed-run-s-feed-post-names-the-pull → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/5812-a-failed-run-s-feed-post-names-the-pull was merged into main as 15d973b.

Review

Sign in to review this change.
9 files changed +229 −11
src/actions/runs.ts +16 −1
@@ -26,12 +26,14 @@ import {
2626 listRuns as queryRuns,
2727 type ListRunsOptions,
2828 type Run,
29 setRunErrorLine,
2930 } from "../core/runs.ts";
3031 import type { User } from "../core/users.ts";
3132 import { abortAgentRun } from "../crew/running.ts";
3233 import { queueAgentRun, taskOf } from "../crew/dispatch.ts";
3334 import { err, ok, type Result } from "../kernel/result.ts";
3435 import { readRunLog, runLogPath } from "../runs/log.ts";
36 import { firstErrorLine } from "../runs/error_line.ts";
3537 import { requireReader, requireWriter, type AccessError } from "./access.ts";
3638
3739 /** 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 {
6668 kind,
6769 repoId: run.repoId,
6870 ...(actorId === undefined ? {} : { actorId }),
69 payload: runPayload(run),
71 payload: { ...runPayload(run), ...(kind === "run.finished" ? failureOf(app, run) : {}) },
7072 });
7173 }
7274
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);

High: this puts a log line, which mayReadRunLog keeps to writers and crew, into a run.finished payload 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 passes mayReadRunLog.

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
7388 /**
7489 * Whether `actor` may read this repository's runs: anyone who can read it,
7590 * 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(
12621262 issue: r.issue_number,
12631263 }));
12641264 }
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 {
2020 import { joinMany, toldBy, type Pull } from "../core/pulls.ts";
2121 import {
2222 canSeeOwnerFacts,
23 onCrew,
2324 getReposByIds,
2425 listReposWritableTo,
2526 type Repo,
2627 } from "../core/repos.ts";
28 import { getRunErrorLine } from "../core/runs.ts";
2729 import { getUserById, type User } from "../core/users.ts";
2830 import { watchedBy } from "../core/watches.ts";
2931 import { chooseCandidates } from "./choose.ts";
@@ -285,6 +287,12 @@ export function candidatesFor(
285287 },
286288 };
287289
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
288296 const out: Candidate[] = [];
289297 const folded = foldPulls(events);
290298 for (const e of events) {
@@ -292,7 +300,7 @@ export function candidatesFor(
292300 if (!repo) continue;
293301 const fold = folded.get(e.id);
294302 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);
296304 if (c)
297305 out.push({
298306 ...c,
@@ -421,6 +429,7 @@ function fromEvent(
421429 e: ForgeEvent,
422430 repo: Repo,
423431 ownerFacts: boolean,
432 logReader: boolean,
424433 subjects: Subjects,
425434 ): Partial | undefined {
426435 const common = {
@@ -449,6 +458,17 @@ function fromEvent(
449458 const cost = typeof p["costUsd"] === "number" ? p["costUsd"] : 0;
450459 const link = `${base(repo)}/runs/${String(p["runId"] ?? "")}`;
451460 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;
452472 if (kind === "agent" && cost >= COST_FLOOR_USD && ownerFacts)
453473 return {
454474 ...common,
@@ -459,9 +479,9 @@ function fromEvent(
459479 return {
460480 ...common,
461481 kind: "run",
462 link,
482 link: failLink,
463483 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}` : ""}`,
465485 };
466486 }
467487 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 {
157157 /** One line of a run's log, as the page draws it: §5's row, a badge naming
158158 * who or what spoke in the lead, the words as its title. The live script in
159159 * `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 {
161161 const kind = lineKind(line);
162162 if (kind === "tool") {
163163 const rest = line.slice(2);
164164 const at = rest.indexOf(" ");
165165 const tool = at === -1 ? rest : rest.slice(0, at);
166166 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);
168168 }
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);
172172 }
173173
174174 /** The row itself; also the pulse's, in `studio/log.ts`. */
@@ -177,9 +177,11 @@ export function logRow(
177177 label: string,
178178 body: Html | string | null,
179179 variant?: "secondary" | "outline",
180 /** The row's `id`, so a link can point at this line (`#L12`). */
181 anchor?: string,
180182 ): Html {
181183 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}"`}>
183185 <span class="row-lead">${lead}</span>
184186 <span class="row-main"><span class="row-title">${body}</span></span>
185187 </div>`;
src/web/studio/log.ts +3 −1
@@ -71,6 +71,8 @@ export function logBox(opts: {
7171 /** The lines are already the tail of a longer log (`readRunLog` cut it):
7272 * the link then reads all of what was read, not all of the run. */
7373 truncated?: boolean;
74 /** Give each row an `id` of `L<its number in the log>`: one log to a page. */
75 anchors?: boolean;
7476 }): Html {
7577 const { shown, hidden } = logWindow(opts.lines, opts.cap);
7678 return html`<div
@@ -95,7 +97,7 @@ export function logBox(opts: {
9597 >.`}
9698 </p>`,
9799 )}
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))}
99101 ${when(
100102 opts.pulse === true,
101103 () =>
src/web/studio/run.ts +1
@@ -341,6 +341,7 @@ export async function runView(
341341 )}
342342 ${when(!logHidden, () => logBox({
343343 id: "run-log",
344 anchors: true,
344345 who,
345346 cap: all
346347 ? 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs