Public: anyone can read this; the crew can change it.

A run page shows its log live from /events and opens no EventSource of its own #2866

Merged opened this

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

Merged

scout/forge:scout/2843-a-run-page-shows-its-log-live-from-event → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/2843-a-run-page-shows-its-log-live-from-event was merged into main as 9428eba.

Review

Sign in to review this change.
6 files changed +117 −24
README.md +7
@@ -1461,6 +1461,13 @@ work here.
14611461
14621462 ## What works today
14631463
1464 - **A run page's live log** rides the page's one `/events` connection (its
1465 `run.log` frames); it opens no stream of its own. Known gaps, both silent: a
1466 frame the server cuts for size (64 lines or 16 KB) loses its omitted lines
1467 until the next reconnect's catch-up read, and a run whose log was already cut
1468 to its last 512 KB at render gets no catch-up read. The page says
1469 "Reconnecting" while the connection is down.
1470
14641471 - **Cut a wave** — Settings → *Cut a wave* (repository admins, same-origin
14651472 POST; web-only, the terminal's settings hand over to the web) opens a pull
14661473 adding `waves/<next>.md`, listing the pulls merged since the last wave and
src/web/client.ts +42 −19
@@ -224,7 +224,6 @@ export const studioRunJs = /* js */ `
224224 (function () {
225225 var log = document.getElementById('run-log');
226226 if (!log) return;
227 var url = log.getAttribute('data-stream');
228227 var who = log.getAttribute('data-who') || 'agent';
229228 var cap = parseInt(log.getAttribute('data-cap') || '0', 10) || 0;
230229 var pulse = document.getElementById('run-pulse');
@@ -360,28 +359,52 @@ export const studioRunJs = /* js */ `
360359 });
361360 }
362361
363 if (!url || typeof EventSource === 'undefined') return;
364 var source = new EventSource(url);
362 // The page opens no stream of its own: the shared /events connection
363 // (liveRegionsJs) carries this run's lines as run.log frames, handed on as
364 // forge:run-log, and its state change as the run.finished event.
365 var runId = log.getAttribute('data-run');
366 if (!runId) return;
367 var logUrl = log.getAttribute('data-log');
365368 var empty = document.getElementById('run-empty');
366 var seeded = false;
367 source.addEventListener('line', function (event) {
368 if (!seeded) {
369 if (event.lastEventId === '1') {
370 Array.prototype.slice.call(log.querySelectorAll('.ln:not(#run-pulse)')).forEach(function (el) { log.removeChild(el); });
371 }
372 seeded = true;
373 if (empty) empty.hidden = true;
374 }
375 var text = event.data;
376 try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {}
369 // Lines on the page: those the server drew from the file, then each added
370 // here. A catch-up read of the file adds only what is past that count, and
371 // frames wait for it, so a line is never drawn twice.
372 var have = parseInt(log.getAttribute('data-seen') || '-1', 10);
373 var catching = false;
374 function put(text) {
375 if (empty) empty.hidden = true;
377376 add(draw(text));
377 have++;
378378 if (/^(\\$ |test-shards: |\\(fail\\) |\u2500\u2500 shard )/.test(text)) stepsSoon();
379 }
380 function catchUp() {
381 if (have < 0 || !logUrl || !window.fetch || catching) return;
382 catching = true;
383 fetch(logUrl, { credentials: 'same-origin' })
384 .then(function (r) { return r.ok ? r.text() : ''; })
385 .then(function (body) {
386 var all = body.split('\n');
387 if (all[all.length - 1] === '') all.pop();
388 all.slice(have).forEach(put);
389 })
390 .catch(function () {})
391 .then(function () { catching = false; });
392 }
393 var conn = document.getElementById('run-conn');
394 document.addEventListener('forge:live', function (e) {
395 var st = e.detail && e.detail.state;
396 if (conn) conn.hidden = st !== 'reconnecting';
397 if (st === 'open') catchUp();
379398 });
380 source.addEventListener('done', function (event) {
381 source.close();
382 var run;
383 try { run = JSON.parse(event.data); } catch (err) { return; }
384 paint(run);
399 document.addEventListener('forge:run-log', function (e) {
400 var d = e.detail;
401 if (!d || d.run !== runId || catching || !Array.isArray(d.lines)) return;
402 d.lines.forEach(function (l) { if (typeof l === 'string') put(l); });
403 });
404 document.addEventListener('forge:event', function (e) {

Frames are dropped while catching is true, and only the file read restores them. That is correct, but if the fetch fails the lines are lost until the next reconnect. Retry the catch-up once on failure.

405 var d = e.detail;
406 if (!d || d.kind !== 'run.finished' || !d.payload || d.payload.runId !== runId) return;
407 paint(d.payload);
385408 if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; }
386409 steps();
387410 if (steer) {
src/web/studio/log.ts +5
@@ -59,6 +59,9 @@ export function logBox(opts: {
5959 cap: number;
6060 lines: readonly string[];
6161 stream?: string;
62 /** A run still going, read live from the shared /events stream: its id,
63 * where the plain log is read to catch up, and how many lines are drawn. */
64 live?: { run: string; log: string; seen?: number };
6265 pulse?: boolean;
6366 className?: string;
6467 extra?: Html;
@@ -75,6 +78,8 @@ export function logBox(opts: {
7578 data-who="${opts.who}"
7679 data-cap="${opts.cap}"
7780 ${opts.stream ? html`data-stream="${opts.stream}"` : null}
81 ${opts.live ? html`data-run="${opts.live.run}" data-log="${opts.live.log}"` : null}
82 ${opts.live?.seen !== undefined ? html`data-seen="${opts.live.seen}"` : null}
7883 >
7984 ${when(
8085 hidden > 0,
src/web/studio/run.ts +25 −4
@@ -25,7 +25,7 @@ import { getUserById } from "../../core/users.ts";
2525 import { repoDir } from "../../git/repos.ts";
2626 import type { Ctx } from "../../http/context.ts";
2727 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
28 import { studioRunJs } from "../client.ts";
28 import { liveRegionsJs, studioRunJs } from "../client.ts";
2929 import { html, raw, when, type Html } from "../html.ts";
3030 import { runLine } from "../describe.ts";
3131 import { page } from "../layout.ts";
@@ -107,7 +107,7 @@ export async function runView(
107107 const all = ctx.url.searchParams.get("all") === "1";
108108 const held = listHeldSecrets(db, run.id);
109109 const carried = carriedBy(db, run.id);
110 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
110 const logPath = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log`;
111111
112112 // The pull this run's tell has become, once one exists: the tell itself
113113 // when the run is already a pull's own (feedback, a failed check, a
@@ -299,6 +299,16 @@ export async function runView(
299299 },
300300 )}
301301 <div class="run-live" id="run-live" data-state="${run.state}">
302 ${when(
303 unfinished && !logHidden,
304 // No id, so the shared script never refetches the page for it: its
305 // only job is to make \`liveRegionsJs\` open the one /events
306 // connection this log and its finish ride on.
307 () => html`<span data-live="run.finished" hidden></span>
308 <p class="muted small" id="run-conn" role="status" hidden>
309 Reconnecting; lines written meanwhile appear when it is back.
310 </p>`,
311 )}
302312 ${when(!logHidden, () => logBox({
303313 id: "run-log",
304314 who,
@@ -308,7 +318,18 @@ export async function runView(
308318 lines,
309319 allHref: `${base}/runs/${run.id}?all=1`,
310320 truncated,
311 ...(unfinished ? { stream } : {}),
321 // Lines come from the shared /events stream; `seen` lets the page
322 // tell what it already drew from what it missed before connecting
323 // (only knowable when the file was read whole).
324 ...(unfinished
325 ? {
326 live: {
327 run: run.id,
328 log: logPath,
329 ...(truncated ? {} : { seen: lines.length }),
330 },
331 }
332 : {}),
312333 pulse: run.state === "running",
313334 extra: html`<button type="button" class="run-jump" id="run-jump" hidden>
314335 newer below
@@ -383,7 +404,7 @@ export async function runView(
383404 crumbs: crumbsFor(repo),
384405 palette: repoPalette(ctx, repo),
385406 body,
386 scriptJs: studioRunJs,
407 scriptJs: `${liveRegionsJs}\n${studioRunJs}`,
387408 // The no-script half of a live log: with JavaScript off, the page
388409 // refreshes itself while the run is going (`layout.ts` writes it inside
389410 // `<noscript>`, so a browser running the stream above never reloads).
test/studio-runs.test.ts +35
@@ -520,6 +520,41 @@ describe("working: a run's own page in the studio", () => {
520520 expect(page).not.toContain("?mode=dev");
521521 });
522522
523 test("a running run's page reads its log from the one /events stream and opens no other", async () => {
524 const run = createRun(app.db, {
525 repoId: repo.id,
526 kind: "agent",
527 actorId: scoutId,
528 reason: "assigned",
529 });
530 claimRun(app.db, run.id);
531 appendRunLog(app.config.dataDir, run.id, "first");
532 appendRunLog(app.config.dataDir, run.id, "second");
533
534 const page = await (
535 await get(`/alice/engine/runs/${run.id}`, writerCookie)
536 ).text();
537 // Every EventSource the page's HTML and script open is /events.
538 const opened = [...page.matchAll(/new EventSource\(([^,;]*)/g)].map(
539 (m) => m[1]!,
540 );
541 expect(opened.length).toBeGreaterThan(0);
542 for (const arg of opened) expect(arg).toStartWith("'/events");
543 // The connection is really opened: liveRegionsJs only connects when the
544 // page has a [data-live] element, and a script's text proves nothing.
545 expect(page).toMatch(/<span data-live="run\.finished"[^>]*>/);
546 expect(page).toContain('id="run-conn"');
547 expect(page).not.toContain("follow=1");
548 expect(page).not.toContain("data-stream=");
549 // The run's frames arrive on that connection; the file's lines are
550 // counted so none is drawn twice.
551 expect(page).toContain("'run.log'");
outdated

This assertion proves nothing about the connection. new EventSource( is in the script source whether or not the script runs. Assert that the page has a data-live element, which is what makes liveRegionsJs call connect().

552 expect(page).toContain("forge:run-log");
553 expect(page).toContain(`data-run="${run.id}"`);
554 expect(page).toContain('data-seen="2"');
555 expect(page).toContain(">second<");
556 });
557
523558 test("a long transcript holds its last window, and the whole of it is one step deeper at ?all=1", async () => {
524559 const run = createRun(app.db, {
525560 repoId: repo.id,
test/web-apps.test.ts +3 −1
@@ -379,7 +379,9 @@ describe("apps, runs and the crew on the web", () => {
379379 const html = await bodyOf(alice, `/alice/hello/runs/${run!.id}`);
380380 const after = isGoing();
381381 if (before === after) {
382 expect(html.includes("data-stream=")).toBe(before);
382 // The tail is live while the run goes: it names its run (read from /events), not a stream.
383 expect(html.includes(`data-run="${run!.id}"`)).toBe(before);
384 expect(html.includes("data-stream=")).toBe(false);
383385 expect(hasMetaRefresh(html)).toBe(before);
384386 break;
385387 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs