Public: anyone can read this; the crew can change it.
The app page's run log tail appends run.log frames from /events and opens no EventSource of its own #2860
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2854-the-app-page-s-run-log-tail-appends-run → main 1 commit fork
2 files
changed +137
−20
src/web/client.ts +21 −20
| @@ -406,9 +406,9 @@ export const studioRunJs = /* js */ ` | ||
| 406 | 406 | * bounds what stays in the DOM as new lines arrive — a chatty run left open |
| 407 | 407 | * cannot grow the page without bound. There is exactly one of these on a |
| 408 | 408 | * page at once (an agent run's own work outranks a background deploy, so |
| 409 | * only one of the two ever renders the id), so it opens one more stream | |
| 410 | * beside the app's own state stream and the page's live regions, never one | |
| 411 | * per row. It carries no controls of its own: Stop lives on the row, the | |
| 409 | * only one of the two ever renders the id), so it opens no stream of | |
| 410 | * its own: it appends the \`run.log\` frames the page's one \`/events\` | |
| 411 | * connection already carries. It carries no controls of its own: Stop lives on the row, the | |
| 412 | 412 | * full transcript is one click away, and the row's own word — or the head |
| 413 | 413 | * card's own state — swaps in once the run is over (the page's existing |
| 414 | 414 | * live regions already repaint on \`run.*\`) — this only ever appends lines |
| @@ -417,7 +417,7 @@ export const studioRunJs = /* js */ ` | ||
| 417 | 417 | export const appTailJs = /* js */ ` |
| 418 | 418 | (function () { |
| 419 | 419 | var tail = document.getElementById('app-run-tail'); |
| 420 | if (!tail || typeof EventSource === 'undefined') return; | |
| 420 | if (!tail) return; | |
| 421 | 421 | var url = tail.getAttribute('data-stream'); |
| 422 | 422 | if (!url) return; |
| 423 | 423 | var who = tail.getAttribute('data-who') || 'the crew'; |
| @@ -433,23 +433,17 @@ export const appTailJs = /* js */ ` | ||
| 433 | 433 | tail.scrollTop = tail.scrollHeight; |
| 434 | 434 | } |
| 435 | 435 | |
| 436 | var source = new EventSource(url); | |
| 437 | var seeded = false; | |
| 438 | source.addEventListener('line', function (event) { | |
| 439 | if (!seeded) { | |
| 440 | if (event.lastEventId === '1') { | |
| 441 | Array.prototype.slice.call(tail.querySelectorAll('.ln')).forEach(function (el) { tail.removeChild(el); }); | |
| 442 | } | |
| 443 | seeded = true; | |
| 444 | } | |
| 445 | var text = event.data; | |
| 446 | try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {} | |
| 447 | add(draw(text)); | |
| 436 | // The run this tail follows is the one its stream URL names; the lines | |
| 437 | // themselves arrive as \`run.log\` frames on the page's one \`/events\` | |
| 438 | // connection (\`liveClientJs\`), so this script opens no stream of its own. | |
| 439 | var named = /\\/runs\\/([^\\/?]+)\\/log/.exec(url); | |
| 440 | var run = named && named[1]; | |
| 441 | if (!run) return; | |
| 442 | document.addEventListener('forge:run-log', function (event) { | |
| 443 | var d = event.detail; | |
| 444 | if (!d || d.run !== run || !Array.isArray(d.lines)) return; | |
| 445 | d.lines.forEach(function (text) { if (typeof text === 'string') add(draw(text)); }); | |
| 448 | 446 | }); |
| 449 | // The run is over: stop appending. The row above settles on its final | |
| 450 | // word the next time the page's own "Now" region repaints, which the | |
| 451 | // \`run.finished\` event this very moment triggers. | |
| 452 | source.addEventListener('done', function () { source.close(); }); | |
| 453 | 447 | })(); |
| 454 | 448 | `; |
| 455 | 449 | |
| @@ -2065,6 +2059,13 @@ export const liveRegionsJs = /* js */ ` | ||
| 2065 | 2059 | if (source.readyState === 2 && retry === null) retry = setTimeout(connect, 3000); |
| 2066 | 2060 | }); |
| 2067 | 2061 | source.addEventListener('forge', onForge); |
| 2062 | // New lines of a run's log: not history, so no id to resume from. Handed | |
| 2063 | // to whoever tails a run on this page (\`appTailJs\`). | |
| 2064 | source.addEventListener('run.log', function (event) { | |
| 2065 | var data; | |
| 2066 | try { data = JSON.parse(event.data); } catch (err) { return; } | |
| 2067 | document.dispatchEvent(new CustomEvent('forge:run-log', { detail: data })); | |
| 2068 | }); | |
| 2068 | 2069 | } |
| 2069 | 2070 | function onForge(event) { |
| 2070 | 2071 | if (event.lastEventId) since = event.lastEventId; |
test/app-tail-events.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The app page's log tail rides the page's one `/events` connection: it opens | |
| 3 | * no EventSource of its own and appends the `run.log` frames for its run. | |
| 4 | */ | |
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { appTailJs, liveRegionsJs } from "../src/web/client.ts"; | |
| 7 | ||
| 8 | type Handler = (event: { data: string; lastEventId?: string }) => void; | |
| 9 | ||
| 10 | function node(tag: string) { | |
| 11 | const el = { | |
| 12 | tag, | |
| 13 | className: "", | |
| 14 | textContent: "", | |
| 15 | children: [] as unknown[], | |
| 16 | appendChild(c: unknown) { | |
| 17 | el.children.push(c); | |
| 18 | return c; | |
| 19 | }, | |
| 20 | }; | |
| 21 | return el; | |
| 22 | } | |
| 23 | ||
| 24 | function boot(stream: string) { | |
| 25 | const opened: string[] = []; | |
| 26 | const docHandlers = new Map<string, (e: { detail: unknown }) => void>(); | |
| 27 | const lines: ReturnType<typeof node>[] = []; | |
| 28 | const tail = { | |
| 29 | ...node("div"), | |
| 30 | scrollTop: 0, | |
| 31 | scrollHeight: 0, | |
| 32 | getAttribute: (n: string) => | |
| 33 | (({ "data-stream": stream, "data-who": "crew", "data-cap": "0" }) as Record< | |
| 34 | string, | |
| 35 | string | |
| 36 | >)[n] ?? null, | |
| 37 | appendChild(c: unknown) { | |
| 38 | lines.push(c as ReturnType<typeof node>); | |
| 39 | return c; | |
| 40 | }, | |
| 41 | querySelectorAll: () => lines, | |
| 42 | }; | |
| 43 | const doc = { | |
| 44 | getElementById: (id: string) => (id === "app-run-tail" ? tail : null), | |
| 45 | createElement: (t: string) => node(t), | |
| 46 | createTextNode: (t: string) => ({ textContent: t }), | |
| 47 | addEventListener: (n: string, h: (e: { detail: unknown }) => void) => | |
| 48 | void docHandlers.set(n, h), | |
| 49 | dispatchEvent: (e: { type: string; detail: unknown }) => { | |
| 50 | docHandlers.get(e.type)?.(e); | |
| 51 | return true; | |
| 52 | }, | |
| 53 | hidden: false, | |
| 54 | activeElement: null, | |
| 55 | querySelectorAll: (sel: string) => | |
| 56 | sel === "[data-live]" | |
| 57 | ? [{ id: "now", getAttribute: () => "run.*", hasAttribute: () => false }] | |
| 58 | : [], | |
| 59 | getElementsByTagName: () => [], | |
| 60 | }; | |
| 61 | const sources: Record<string, Map<string, Handler>> = {}; | |
| 62 | class ES { | |
| 63 | constructor(url: string) { | |
| 64 | opened.push(url); | |
| 65 | sources[url] = this.on; | |
| 66 | } | |
| 67 | on = new Map<string, Handler>(); | |
| 68 | readyState = 1; | |
| 69 | addEventListener(n: string, h: Handler) { | |
| 70 | this.on.set(n, h); | |
| 71 | } | |
| 72 | close() {} | |
| 73 | } | |
| 74 | class CE { | |
| 75 | type: string; | |
| 76 | detail: unknown; | |
| 77 | constructor(type: string, init: { detail: unknown }) { | |
| 78 | this.type = type; | |
| 79 | this.detail = init.detail; | |
| 80 | } | |
| 81 | } | |
| 82 | const fake = { | |
| 83 | document: doc, | |
| 84 | window: { addEventListener() {}, setInterval: () => 0, clearInterval() {} }, | |
| 85 | location: { href: "http://x/app" }, | |
| 86 | EventSource: ES, | |
| 87 | CustomEvent: CE, | |
| 88 | DOMParser: class {}, | |
| 89 | setTimeout: () => 0, | |
| 90 | clearTimeout() {}, | |
| 91 | fetch: async () => ({}), | |
| 92 | }; | |
| 93 | for (const js of [liveRegionsJs, appTailJs]) | |
| 94 | new Function(...Object.keys(fake), js)(...Object.values(fake)); | |
| 95 | return { opened, sources, lines }; | |
| 96 | } | |
| 97 | ||
| 98 | const STREAM = "/api/v1/repos/a/b/runs/run1/log?follow=1"; | |
| 99 | ||
| 100 | describe("app page log tail", () => { | |
| 101 | test("opens no EventSource besides /events", () => { | |
| 102 | const { opened } = boot(STREAM); | |
| 103 | expect(opened).toEqual(["/events"]); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a run.log frame for its run appends its lines once; other runs are ignored", () => { | |
| 107 | const { sources, lines } = boot(STREAM); | |
| 108 | const handler = sources["/events"]?.get("run.log"); | |
| 109 | expect(handler).toBeDefined(); | |
| 110 | handler?.({ data: JSON.stringify({ run: "run1", lines: ["hello", "world"], truncated: false }) }); | |
| 111 | expect(lines.length).toBe(2); | |
| 112 | handler?.({ data: JSON.stringify({ run: "other", lines: ["nope"], truncated: false }) }); | |
| 113 | handler?.({ data: "not json" }); | |
| 114 | expect(lines.length).toBe(2); | |
| 115 | }); | |
| 116 | }); |
new above
offline