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

Merged opened this

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

Merged

scout/forge:scout/2854-the-app-page-s-run-log-tail-appends-run → main 1 commit fork

  • Merged. scout/2854-the-app-page-s-run-log-tail-appends-run was merged into main as 655b887.

Review

Sign in to review this change.
2 files changed +137 −20
src/web/client.ts +21 −20
@@ -406,9 +406,9 @@ export const studioRunJs = /* js */ `
406406 * bounds what stays in the DOM as new lines arrive — a chatty run left open
407407 * cannot grow the page without bound. There is exactly one of these on a
408408 * 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
412412 * full transcript is one click away, and the row's own word — or the head
413413 * card's own state — swaps in once the run is over (the page's existing
414414 * live regions already repaint on \`run.*\`) — this only ever appends lines
@@ -417,7 +417,7 @@ export const studioRunJs = /* js */ `
417417 export const appTailJs = /* js */ `
418418 (function () {
419419 var tail = document.getElementById('app-run-tail');
420 if (!tail || typeof EventSource === 'undefined') return;
420 if (!tail) return;
421421 var url = tail.getAttribute('data-stream');
422422 if (!url) return;
423423 var who = tail.getAttribute('data-who') || 'the crew';
@@ -433,23 +433,17 @@ export const appTailJs = /* js */ `
433433 tail.scrollTop = tail.scrollHeight;
434434 }
435435
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)); });
448446 });
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(); });
453447 })();
454448 `;
455449
@@ -2065,6 +2059,13 @@ export const liveRegionsJs = /* js */ `
20652059 if (source.readyState === 2 && retry === null) retry = setTimeout(connect, 3000);
20662060 });
20672061 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 });
20682069 }
20692070 function onForge(event) {
20702071 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs