Public: anyone can read this; the crew can change it.
Read what you show: the log tail reads half a megabyte to print a dozen lines #157
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/146-read-what-you-show-the-log-tail-reads-ha → main 1 commit fork
5 files
changed +86
−4
src/runs/log.ts +20
| @@ -78,6 +78,26 @@ export function readRunLog( | ||
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | /** | |
| 82 | * A window onto the end of a run's log, sized for a caller that wants | |
| 83 | * `maxLines` of them rather than a byte count to guess at. A worst-case | |
| 84 | * line runs to `MAX_LINE`, so `maxLines` of those plus one more (the | |
| 85 | * partial line `readRunLog` drops after the cut) is enough bytes to always | |
| 86 | * come back with at least `maxLines` whole lines, however long they run — | |
| 87 | * never the file's full history for a tail a few lines deep. The window | |
| 88 | * itself is returned unsliced: a caller after exactly `maxLines` takes the | |
| 89 | * last of them; one building a "N more above" notice (`logBox`) needs the | |
| 90 | * few extra to know there's more. | |
| 91 | */ | |
| 92 | export function readRunLogTail( | |
| 93 | dataDir: string, | |
| 94 | runId: string, | |
| 95 | maxLines: number, | |
| 96 | ): { lines: string[]; truncated: boolean } { | |
| 97 | const maxBytes = (maxLines + 1) * (MAX_LINE + 1); | |
| 98 | return readRunLog(dataDir, runId, maxBytes); | |
| 99 | } | |
| 100 | ||
| 81 | 101 | /** |
| 82 | 102 | * Follow a run's log. Released by `signal` — a TUI screen's abort or an SSE |
| 83 | 103 | * connection closing — so a dead watcher never holds a set entry. |
src/web/studio/app.ts +3 −2
| @@ -24,7 +24,7 @@ import { getUsersByIds } from "../../core/users.ts"; | ||
| 24 | 24 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 25 | 25 | import { repoDir } from "../../git/repos.ts"; |
| 26 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 27 | import { readRunLog } from "../../runs/log.ts"; | |
| 27 | import { readRunLogTail } from "../../runs/log.ts"; | |
| 28 | 28 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 29 | 29 | import { appTailJs, asksJs, liveRegionsJs } from "../client.ts"; |
| 30 | 30 | import { html, when, type Html } from "../html.ts"; |
| @@ -265,7 +265,8 @@ export async function studioApp( | ||
| 265 | 265 | : (actors.get(activeRun.actorId)?.username ?? "crew"), |
| 266 | 266 | ), |
| 267 | 267 | cap: LOG_TAIL_CAP, |
| 268 | lines: readRunLog(ctx.app.config.dataDir, activeRun.id).lines, | |
| 268 | lines: readRunLogTail(ctx.app.config.dataDir, activeRun.id, LOG_TAIL_CAP) | |
| 269 | .lines, | |
| 269 | 270 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`, |
| 270 | 271 | className: "log-tail", |
| 271 | 272 | }) |
src/web/studio/previews.ts +3 −2
| @@ -20,7 +20,7 @@ import { listPulls, type Pull } from "../../core/pulls.ts"; | ||
| 20 | 20 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 21 | 21 | import { listRuns } from "../../core/runs.ts"; |
| 22 | 22 | import type { Ctx } from "../../http/context.ts"; |
| 23 | import { readRunLog } from "../../runs/log.ts"; | |
| 23 | import { readRunLogTail } from "../../runs/log.ts"; | |
| 24 | 24 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 25 | 25 | import { html, when, type Html } from "../html.ts"; |
| 26 | 26 | import { fragment } from "../layout.ts"; |
| @@ -144,7 +144,8 @@ function deployTail( | ||
| 144 | 144 | id: "app-run-tail", |
| 145 | 145 | who: "the deploy", |
| 146 | 146 | cap: LOG_TAIL_CAP, |
| 147 | lines: readRunLog(ctx.app.config.dataDir, production.runId).lines, | |
| 147 | lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP) | |
| 148 | .lines, | |
| 148 | 149 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${production.runId}/log?follow=1`, |
| 149 | 150 | className: "log-tail", |
| 150 | 151 | }); |
test/apps-adversarial.test.ts +37
| @@ -59,6 +59,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 59 | 59 | import { |
| 60 | 60 | appendRunLog, |
| 61 | 61 | readRunLog, |
| 62 | readRunLogTail, | |
| 62 | 63 | runLogListenerCount, |
| 63 | 64 | subscribeRunLog, |
| 64 | 65 | } from "../src/runs/log.ts"; |
| @@ -346,6 +347,42 @@ describe("run log: redaction, truncation, and the listener seam", () => { | ||
| 346 | 347 | for (const line of lines) expect(line).toMatch(/^line \d+$/); |
| 347 | 348 | }); |
| 348 | 349 | |
| 350 | test("readRunLogTail reads a bounded slice and still ends at the true last line", () => { | |
| 351 | const runId = "test-run-tail"; | |
| 352 | // Far more lines than fit in a window sized for a handful of them. | |
| 353 | for (let i = 0; i < 20_000; i++) appendRunLog(dataDir, runId, `line ${i}`); | |
| 354 | const { lines, truncated } = readRunLogTail(dataDir, runId, 6); | |
| 355 | expect(truncated).toBe(true); | |
| 356 | expect(lines.length).toBeLessThan(20_000); | |
| 357 | expect(lines.at(-1)).toBe("line 19999"); | |
| 358 | expect(lines.slice(-6)).toEqual( | |
| 359 | [19994, 19995, 19996, 19997, 19998, 19999].map((i) => `line ${i}`), | |
| 360 | ); | |
| 361 | }); | |
| 362 | ||
| 363 | test("readRunLogTail still returns at least the cap's worth of lines when a line is near the max length", () => { | |
| 364 | const runId = "test-run-tail-long-lines"; | |
| 365 | // Lines this long are rare (real logs run 1KB-45KB total) but the cap | |
| 366 | // must not assume otherwise: it is sized from MAX_LINE, not a guess. | |
| 367 | for (let i = 0; i < 8; i++) | |
| 368 | appendRunLog(dataDir, runId, `${i}-`.repeat(8000)); | |
| 369 | const { lines, truncated } = readRunLogTail(dataDir, runId, 6); | |
| 370 | expect(lines.length).toBeGreaterThanOrEqual(6); | |
| 371 | expect(truncated).toBe(true); | |
| 372 | // Every line kept must be whole: none may be a mid-repeat fragment left | |
| 373 | // by a byte cut landing inside it. | |
| 374 | for (const line of lines) expect(line).toMatch(/^(\d-)+$/); | |
| 375 | expect(lines.at(-1)).toBe("7-".repeat(8000)); | |
| 376 | }); | |
| 377 | ||
| 378 | test("readRunLogTail on a log shorter than the cap returns every line, untruncated", () => { | |
| 379 | const runId = "test-run-tail-short"; | |
| 380 | appendRunLog(dataDir, runId, "only line"); | |
| 381 | const { lines, truncated } = readRunLogTail(dataDir, runId, 6); | |
| 382 | expect(lines).toEqual(["only line"]); | |
| 383 | expect(truncated).toBe(false); | |
| 384 | }); | |
| 385 | ||
| 349 | 386 | test("subscribe/abort a thousand times leaves no listener behind", () => { |
| 350 | 387 | const before = runLogListenerCount(); |
| 351 | 388 | const runId = "test-run-leak"; |
test/studio.test.ts +23
| @@ -3107,6 +3107,29 @@ describe("the app's page tails the crew's log while it works", () => { | ||
| 3107 | 3107 | expect(settled).not.toContain('id="app-run-tail"'); |
| 3108 | 3108 | }); |
| 3109 | 3109 | |
| 3110 | test("a log far larger than the tail's cap still ends the page at its true last line", async () => { | |
| 3111 | const tell = openIssue(app, alice, repo, { title: "Long-running build" }); | |
| 3112 | if (!tell.ok) throw new Error(String(tell.error)); | |
| 3113 | const run = createRun(app.db, { | |
| 3114 | repoId: repo.id, | |
| 3115 | kind: "agent", | |
| 3116 | issueId: tell.value.id, | |
| 3117 | actorId: scoutId, | |
| 3118 | reason: "assigned", | |
| 3119 | }); | |
| 3120 | claimRun(app.db, run.id); | |
| 3121 | // Far past a byte cap sized for a handful of lines (old default: 512KB). | |
| 3122 | for (let i = 0; i < 2000; i++) | |
| 3123 | appendRunLog(app.config.dataDir, run.id, `line ${i}`); | |
| 3124 | ||
| 3125 | const page = await (await get("/alice/engine", writerCookie)).text(); | |
| 3126 | expect(page).not.toContain(">line 1993<"); | |
| 3127 | expect(page).toContain(">line 1999<"); | |
| 3128 | expect(page).toContain("Holding the last 6 lines here"); | |
| 3129 | ||
| 3130 | finishRun(app.db, run.id, { state: "ok", summary: "planned" }); | |
| 3131 | }); | |
| 3132 | ||
| 3110 | 3133 | test("a reader without access gets the forge's not-found from the page and from the tail's own stream", async () => { |
| 3111 | 3134 | const tell = openIssue(app, alice, repo, { title: "Private boot work" }); |
| 3112 | 3135 | if (!tell.ok) throw new Error(String(tell.error)); |
new above
offline