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

Merged opened this

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

Merged

scout/forge:scout/146-read-what-you-show-the-log-tail-reads-ha → main 1 commit fork

  • Merged. scout/146-read-what-you-show-the-log-tail-reads-ha was merged into main as b48f573.

Review

Sign in to review this change.
5 files changed +86 −4
src/runs/log.ts +20
@@ -78,6 +78,26 @@ export function readRunLog(
7878 }
7979 }
8080
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
81101 /**
82102 * Follow a run's log. Released by `signal` — a TUI screen's abort or an SSE
83103 * 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";
2424 import { autonomyStatus } from "../../crew/autonomy.ts";
2525 import { repoDir } from "../../git/repos.ts";
2626 import type { Ctx } from "../../http/context.ts";
27 import { readRunLog } from "../../runs/log.ts";
27 import { readRunLogTail } from "../../runs/log.ts";
2828 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
2929 import { appTailJs, asksJs, liveRegionsJs } from "../client.ts";
3030 import { html, when, type Html } from "../html.ts";
@@ -265,7 +265,8 @@ export async function studioApp(
265265 : (actors.get(activeRun.actorId)?.username ?? "crew"),
266266 ),
267267 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,
269270 stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`,
270271 className: "log-tail",
271272 })
src/web/studio/previews.ts +3 −2
@@ -20,7 +20,7 @@ import { listPulls, type Pull } from "../../core/pulls.ts";
2020 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2121 import { listRuns } from "../../core/runs.ts";
2222 import type { Ctx } from "../../http/context.ts";
23 import { readRunLog } from "../../runs/log.ts";
23 import { readRunLogTail } from "../../runs/log.ts";
2424 import { loadRepo, repoBase } from "../chrome.ts";
2525 import { html, when, type Html } from "../html.ts";
2626 import { fragment } from "../layout.ts";
@@ -144,7 +144,8 @@ function deployTail(
144144 id: "app-run-tail",
145145 who: "the deploy",
146146 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,
148149 stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${production.runId}/log?follow=1`,
149150 className: "log-tail",
150151 });
test/apps-adversarial.test.ts +37
@@ -59,6 +59,7 @@ import { serve, type Servers } from "../src/http/server.ts";
5959 import {
6060 appendRunLog,
6161 readRunLog,
62 readRunLogTail,
6263 runLogListenerCount,
6364 subscribeRunLog,
6465 } from "../src/runs/log.ts";
@@ -346,6 +347,42 @@ describe("run log: redaction, truncation, and the listener seam", () => {
346347 for (const line of lines) expect(line).toMatch(/^line \d+$/);
347348 });
348349
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
349386 test("subscribe/abort a thousand times leaves no listener behind", () => {
350387 const before = runLogListenerCount();
351388 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", () => {
31073107 expect(settled).not.toContain('id="app-run-tail"');
31083108 });
31093109
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
31103133 test("a reader without access gets the forge's not-found from the page and from the tail's own stream", async () => {
31113134 const tell = openIssue(app, alice, repo, { title: "Private boot work" });
31123135 if (!tell.ok) throw new Error(String(tell.error));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs