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

The app's page tails the crew's log while the app is coming alive #144

Merged opened this

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

Merged

scout/forge:scout/88-the-app-s-page-tails-the-crew-s-log-whil → main 1 commit fork

  • Merged. scout/88-the-app-s-page-tails-the-crew-s-log-whil was merged into main as c72ba52.

Review

Sign in to review this change.
6 files changed +398 −119
src/web/client.ts +98 −56
@@ -256,6 +256,45 @@ export const mirrorJs = /* js */ `
256256 })();
257257 `;
258258
259 /**
260 * Turning one log line into an element, the way every reader of a run's log
261 * draws it: a tool call bolds its name, the forge's own words go quiet and
262 * italic, "you: " gets its own highlighted row, and anything else is the
263 * agent speaking as \`who\`. Shared by \`runLogJs\`, \`studioRunJs\` and
264 * \`appTailJs\` so a fourth live log never grows a fourth copy of it —
265 * interpolated inline, so \`who\` closes over whichever script uses it.
266 */
267 const logLineJs = /* js */ `
268 var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/;
269 function line(kind, speaker, fill) {
270 var el = document.createElement('div');
271 el.className = 'ln ln-' + kind;
272 var w = document.createElement('span');
273 w.className = 'who';
274 w.textContent = speaker;
275 var t = document.createElement('span');
276 t.className = 'txt';
277 fill(t);
278 el.appendChild(w);
279 el.appendChild(t);
280 return el;
281 }
282 function draw(text) {
283 if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); });
284 if (text.indexOf('\\u2192 ') === 0) {
285 var rest = text.slice(2), at = rest.indexOf(' ');
286 return line('tool', '', function (t) {
287 var b = document.createElement('b');
288 b.textContent = at === -1 ? rest : rest.slice(0, at);
289 t.appendChild(b);
290 t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1)));
291 });
292 }
293 if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; });
294 return line('say', who, function (t) { t.textContent = text; });
295 }
296 `;
297
259298 /**
260299 * A run's log, live. The page renders every line already written and, for
261300 * a run still going, refreshes itself without a script; this replaces that
@@ -324,36 +363,9 @@ export const runLogJs = /* js */ `
324363 if (elapsed && !elapsed.getAttribute('data-finished')) elapsed.setAttribute('data-finished', new Date().toISOString());
325364 }
326365
327 var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/;
328366 // The same sorting the page did for the lines it rendered: a speaker
329367 // column and the words, so a reader can tell who said what at a glance.
330 function line(kind, speaker, fill) {
331 var el = document.createElement('div');
332 el.className = 'ln ln-' + kind;
333 var w = document.createElement('span');
334 w.className = 'who';
335 w.textContent = speaker;
336 var t = document.createElement('span');
337 t.className = 'txt';
338 fill(t);
339 el.appendChild(w);
340 el.appendChild(t);
341 return el;
342 }
343 function draw(text) {
344 if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); });
345 if (text.indexOf('→ ') === 0) {
346 var rest = text.slice(2), at = rest.indexOf(' ');
347 return line('tool', '', function (t) {
348 var b = document.createElement('b');
349 b.textContent = at === -1 ? rest : rest.slice(0, at);
350 t.appendChild(b);
351 t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1)));
352 });
353 }
354 if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; });
355 return line('say', who, function (t) { t.textContent = text; });
356 }
368 ${logLineJs}
357369 function add(el) {
358370 log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump);
359371 if (follow) toEnd(); else if (jump) jump.hidden = false;
@@ -517,34 +529,7 @@ export const studioRunJs = /* js */ `
517529 if (elapsed && !elapsed.getAttribute('data-finished')) elapsed.setAttribute('data-finished', new Date().toISOString());
518530 }
519531
520 var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/;
521 function line(kind, speaker, fill) {
522 var el = document.createElement('div');
523 el.className = 'ln ln-' + kind;
524 var w = document.createElement('span');
525 w.className = 'who';
526 w.textContent = speaker;
527 var t = document.createElement('span');
528 t.className = 'txt';
529 fill(t);
530 el.appendChild(w);
531 el.appendChild(t);
532 return el;
533 }
534 function draw(text) {
535 if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); });
536 if (text.indexOf('\\u2192 ') === 0) {
537 var rest = text.slice(2), at = rest.indexOf(' ');
538 return line('tool', '', function (t) {
539 var b = document.createElement('b');
540 b.textContent = at === -1 ? rest : rest.slice(0, at);
541 t.appendChild(b);
542 t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1)));
543 });
544 }
545 if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; });
546 return line('say', who, function (t) { t.textContent = text; });
547 }
532 ${logLineJs}
548533 function add(el) {
549534 log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump);
550535 if (cap) {
@@ -635,6 +620,63 @@ export const studioRunJs = /* js */ `
635620 })();
636621 `;
637622
623 /**
624 * The tail beside a running row on the app's own page in the studio
625 * (\`studio/app.ts\`, \`#app-run-tail\`): the same lines a run's own page
626 * streams, a handful at a time, so a person on the app's page sees the crew
627 * at work without opening the run. \`data-cap\` bounds what stays in the DOM
628 * as new lines arrive — a chatty agent left open cannot grow the page
629 * without bound. There is exactly one of these on a page at once, so it
630 * opens one more stream beside the app's own state stream and the page's
631 * live regions, never one per row. It carries no controls of its own: Stop
632 * lives on the row, the full transcript is one click away, and the row's own
633 * word swaps in once the run is over (the page's existing live region for
634 * "Now" already repaints on \`run.*\`) — this only ever appends lines and
635 * stops.
636 */
637 export const appTailJs = /* js */ `
638 (function () {
639 var tail = document.getElementById('app-run-tail');
640 if (!tail || typeof EventSource === 'undefined') return;
641 var url = tail.getAttribute('data-stream');
642 if (!url) return;
643 var who = tail.getAttribute('data-who') || 'the crew';
644 var cap = parseInt(tail.getAttribute('data-cap') || '0', 10) || 0;
645 // The no-script half refreshed the whole page while this run went; the
646 // stream above does better, so it steps aside the moment it can run.
647 var meta = document.querySelector('meta[http-equiv="refresh"]');
648 if (meta) meta.remove();
649
650 ${logLineJs}
651 function add(el) {
652 tail.appendChild(el);
653 if (cap) {
654 var kept = tail.querySelectorAll('.ln');
655 while (kept.length > cap) { tail.removeChild(kept[0]); kept = tail.querySelectorAll('.ln'); }
656 }
657 tail.scrollTop = tail.scrollHeight;
658 }
659
660 var source = new EventSource(url);
661 var seeded = false;
662 source.addEventListener('line', function (event) {
663 if (!seeded) {
664 if (event.lastEventId === '1') {
665 Array.prototype.slice.call(tail.querySelectorAll('.ln')).forEach(function (el) { tail.removeChild(el); });
666 }
667 seeded = true;
668 }
669 var text = event.data;
670 try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {}
671 add(draw(text));
672 });
673 // The run is over: stop appending. The row above settles on its final
674 // word the next time the page's own "Now" region repaints, which the
675 // \`run.finished\` event this very moment triggers.
676 source.addEventListener('done', function () { source.close(); });
677 })();
678 `;
679
638680 /**
639681 * The code browser: the explorer column, the preview it paints beside itself,
640682 * and the fuzzy file finder.
src/web/studio/app.ts +61 −28
@@ -24,8 +24,9 @@ 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";
2728 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
28 import { asksJs, liveRegionsJs } from "../client.ts";
29 import { appTailJs, asksJs, liveRegionsJs } from "../client.ts";
2930 import { html, when, type Html } from "../html.ts";
3031 import { page } from "../layout.ts";
3132 import { liveClientJs, liveMount } from "../live.ts";
@@ -41,6 +42,7 @@ import {
4142 } from "../ui.ts";
4243 import { asksPanel, decisionsFor } from "./asks.ts";
4344 import { withLikely } from "./leans.ts";
45 import { LOG_TAIL_CAP, logBox } from "./log.ts";
4446 import { appPanels } from "./previews.ts";
4547 import { stopForm } from "./run.ts";
4648 import { studioCss } from "./style.ts";
@@ -220,6 +222,58 @@ export async function studioApp(
220222 // viewer who cannot write here reads "Now" as the app's own story, never
221223 // by the agent's name.
222224 const nameFor = (name: string): string => (ownerFacts ? name : "the crew");
225
226 // The one run actually going, if any: its row gets a tail of its own
227 // log — the last handful of lines, streamed live — beside it. Only one,
228 // so the page never opens a stream per row.
229 const activeRun = rowsNow.find(
230 (r) => r.kind === "agent" && r.state === "running",
231 );
232 const activeTail = activeRun
233 ? logBox({
234 id: "app-run-tail",
235 who: nameFor(
236 activeRun.actorId === null
237 ? "crew"
238 : (actors.get(activeRun.actorId)?.username ?? "crew"),
239 ),
240 cap: LOG_TAIL_CAP,
241 lines: readRunLog(ctx.app.config.dataDir, activeRun.id).lines,
242 stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`,
243 className: "log-tail",
244 })
245 : undefined;
246
247 const runRows: Html[] = [];
248 for (const r of rowsNow) {
249 const { word, note } = await runWord(wordCtx, r);
250 const who = nameFor(
251 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
252 );
253 const about = r.issueId === null ? undefined : issues.get(r.issueId);
254 const stop = stopForm(ctx, repo, r);
255 runRows.push(
256 row({
257 ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }),
258 lead: avatar(who),
259 title: stop
260 ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>`
261 : (about?.title ?? note),
262 meta: html`${who} · ${word === "working" ? "working now" : note}
263 ${word === "working"
264 ? null
265 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
266 trail: stop
267 ? html`<span class="studio-ask-actions"
268 >${wordBadge(word)} ${stop}</span
269 >`
270 : wordBadge(word),
271 }),
272 );
273 if (activeTail !== undefined && r.id === activeRun?.id)
274 runRows.push(html`<li class="log-tail-row">${activeTail}</li>`);
275 }
276
223277 const nowRows: Html[] = [
224278 ...(await Promise.all(
225279 openPulls.map(async (p) => {
@@ -235,32 +289,7 @@ export async function studioApp(
235289 });
236290 }),
237291 )),
238 ...(await Promise.all(
239 rowsNow.map(async (r) => {
240 const { word, note } = await runWord(wordCtx, r);
241 const who = nameFor(
242 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
243 );
244 const about = r.issueId === null ? undefined : issues.get(r.issueId);
245 const stop = stopForm(ctx, repo, r);
246 return row({
247 ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }),
248 lead: avatar(who),
249 title: stop
250 ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>`
251 : (about?.title ?? note),
252 meta: html`${who} · ${word === "working" ? "working now" : note}
253 ${word === "working"
254 ? null
255 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
256 trail: stop
257 ? html`<span class="studio-ask-actions"
258 >${wordBadge(word)} ${stop}</span
259 >`
260 : wordBadge(word),
261 });
262 }),
263 )),
292 ...runRows,
264293 ...waiting.map((i) => {
265294 const who = nameFor(i.assigneeName ?? "crew");
266295 return row({
@@ -444,6 +473,10 @@ export async function studioApp(
444473 palette: repoPalette(repo),
445474 body,
446475 styleCss: studioCss,
447 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
476 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}\n${appTailJs}`,
477 // The no-script half of the tail: while the crew's run goes, the page
478 // refreshes itself, the way the run's own page does; the script above
479 // removes this the moment it can stream lines instead.
480 ...(activeRun ? { refresh: { seconds: 5 } } : {}),
448481 });
449482 }
src/web/studio/log.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * A run's log, windowed: the bound, the truncation notice, and the box both
3 * a run's full transcript (`studio/run.ts`) and a compact tail beside a
4 * running row (`studio/app.ts`, and any page that wants the same peek)
5 * render into. One place for the shape, so a second page never grows a
6 * second copy of it.
7 */
8
9 import { logLine } from "../pages/runs.ts";
10 import { html, when, type Html } from "../html.ts";
11
12 /** At most this many of a run's own lines held in a full transcript at
13 * once — the live script drops the oldest as a new one lands, past this. */
14 export const RUN_TRANSCRIPT_CAP = 400;
15
16 /** A tail beside a row names only the last handful of lines — enough to say
17 * the crew is working, not a second transcript. */
18 export const LOG_TAIL_CAP = 6;
19
20 /** The last `cap` lines of a log, and how many were left out. */
21 export function logWindow(
22 lines: readonly string[],
23 cap: number,
24 ): { shown: readonly string[]; hidden: number } {
25 const shown = lines.slice(-cap);
26 return { shown, hidden: lines.length - shown.length };
27 }
28
29 /**
30 * A bounded window of a run's log: the truncation notice when lines were
31 * left out, the lines themselves in the page's own voice (`logLine`), the
32 * pulse for a run still going, and any control the caller wants inside the
33 * same box (the run page's "newer below" button, say) — the live script
34 * appends before that control, whichever one is there.
35 */
36 export function logBox(opts: {
37 id: string;
38 who: string;
39 cap: number;
40 lines: readonly string[];
41 stream?: string;
42 pulse?: boolean;
43 className?: string;
44 extra?: Html;
45 }): Html {
46 const { shown, hidden } = logWindow(opts.lines, opts.cap);
47 return html`<div
48 class="run-log${opts.className ? ` ${opts.className}` : ""}"
49 id="${opts.id}"
50 data-who="${opts.who}"
51 data-cap="${opts.cap}"
52 ${opts.stream ? html`data-stream="${opts.stream}"` : null}
53 >
54 ${when(
55 hidden > 0,
56 () =>
57 html`<p class="muted small">
58 Holding the last ${opts.cap} lines here; the run's own log keeps
59 the rest.
60 </p>`,
61 )}
62 ${shown.map((l) => logLine(l, opts.who))}
63 ${when(
64 opts.pulse === true,
65 () =>
66 html`<div class="ln ln-pulse" id="${opts.id}-pulse">
67 <span class="who">${opts.who}</span
68 ><span class="txt"><i></i><i></i><i></i></span>
69 </div>`,
70 )}
71 ${opts.extra ?? null}
72 </div>`;
73 }
src/web/studio/run.ts +13 −35
@@ -27,7 +27,8 @@ import { html, when, type Html } from "../html.ts";
2727 import { page } from "../layout.ts";
2828 import { btn, icon } from "../ui.ts";
2929 import { previewCard } from "../pages/apps.ts";
30 import { costText, durationText, logLine, runTitle } from "../pages/runs.ts";
30 import { costText, durationText, runTitle } from "../pages/runs.ts";
31 import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts";
3132 import { studioCss } from "./style.ts";
3233 import { runWord, wordBadge, type WordCtx } from "./words.ts";
3334
@@ -52,10 +53,6 @@ export function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
5253 }
5354
5455
55 /** At most this many of the run's own lines held in the page at once — the
56 * live script drops the oldest as a new one lands, past this. */
57 const RUN_TRANSCRIPT_CAP = 400;
58
5956 /**
6057 * GET /:owner/:name/runs/:id in the studio: who is on it, the tell it came
6158 * from, how long it has gone and what it has cost, the transcript itself,
@@ -95,8 +92,6 @@ export async function studioRun(
9592 const unfinished = !isTerminal(run.state);
9693 const duration = durationText(run);
9794 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
98 const shown = lines.slice(-RUN_TRANSCRIPT_CAP);
99 const hidden = lines.length - shown.length;
10095
10196 // The pull this run's tell has become, once one exists: the tell itself
10297 // when the run is already a pull's own (feedback, a failed check, a
@@ -188,41 +183,24 @@ export async function studioRun(
188183 </p>`,
189184 )}
190185 ${when(
191 shown.length === 0,
186 lines.length === 0,
192187 () =>
193188 html`<p class="muted small" id="run-empty">
194189 Nothing has been logged yet.
195190 </p>`,
196191 )}
197192 <div class="run-live" id="run-live" data-state="${run.state}">
198 <div
199 class="run-log"
200 id="run-log"
201 data-who="${who}"
202 data-cap="${RUN_TRANSCRIPT_CAP}"
203 ${unfinished ? html`data-stream="${stream}"` : null}
204 >
205 ${when(
206 hidden > 0,
207 () =>
208 html`<p class="muted small">
209 Holding the last ${RUN_TRANSCRIPT_CAP} lines here; the run's own
210 log keeps the rest.
211 </p>`,
212 )}
213 ${shown.map((l) => logLine(l, who))}
214 ${when(
215 run.state === "running",
216 () =>
217 html`<div class="ln ln-pulse" id="run-pulse">
218 <span class="who">${who}</span
219 ><span class="txt"><i></i><i></i><i></i></span>
220 </div>`,
221 )}
222 <button type="button" class="run-jump" id="run-jump" hidden>
193 ${logBox({
194 id: "run-log",
195 who,
196 cap: RUN_TRANSCRIPT_CAP,
197 lines,
198 ...(unfinished ? { stream } : {}),
199 pulse: run.state === "running",
200 extra: html`<button type="button" class="run-jump" id="run-jump" hidden>
223201 newer below
224 </button>
225 </div>
202 </button>`,
203 })}
226204 ${when(
227205 writer && run.kind === "agent" && issue !== undefined,
228206 () =>
src/web/style/content.ts +5
@@ -649,6 +649,11 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
649649 .run-summary { margin: 10px 0 0; text-wrap: pretty; }
650650 .run-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
651651 .run-actions form { display: inline-flex; }
652 /* A tail beside a running row (studio/app.ts): the same log, a peek at it
653 — fixed height so nothing around it shifts as lines arrive, and it
654 scrolls itself rather than the page. */
655 .log-tail-row { padding: 0 14px 10px; }
656 .run-log.log-tail { max-height: 8.5em; min-height: 0; margin: 6px 0 0; font-size: 12.5px; }
652657 .table .truncate { max-width: 34ch; display: inline-block; vertical-align: bottom; }
653658 .agent-brief { white-space: pre-wrap; font-size: 13px; color: var(--muted-fg); max-height: 6.5em; overflow: hidden; }
654659
test/studio.test.ts +148
@@ -49,6 +49,7 @@ import { serve, type Servers } from "../src/http/server.ts";
4949 import { pendingLeans, pullWord, runWord, type WordCtx } from "../src/web/pages/studio.ts";
5050 import { servedPhrase } from "../src/web/ui.ts";
5151 import { repoDir } from "../src/git/repos.ts";
52 import { appendRunLog } from "../src/runs/log.ts";
5253
5354 /** One transparent pixel, dropped where a look would sit. */
5455 const PNG = Buffer.from(
@@ -2802,6 +2803,153 @@ describe("working: a run's own page in the studio", () => {
28022803 });
28032804 });
28042805
2806 /**
2807 * Issue #88: the app's page tails the crew's log while it works — the last
2808 * handful of lines beside the running row, live, bounded, and with a
2809 * no-script fallback that behaves like the run page's own. Its own repo and
2810 * app, so no other test's still-running run ever counts as "the active run"
2811 * here.
2812 */
2813 describe("the app's page tails the crew's log while it works", () => {
2814 let dataDir: string;
2815 let app: App;
2816 let servers: Servers;
2817 let base: string;
2818 let repo: Repo;
2819 let alice: User;
2820 let scoutId: string;
2821 let writerCookie: string;
2822 let readerCookie: string;
2823
2824 const signIn = async (username: string): Promise<string> =>
2825 (
2826 (
2827 await fetch(`${base}/signin`, {
2828 method: "POST",
2829 body: new URLSearchParams({
2830 username,
2831 password: "a-good-passphrase",
2832 }),
2833 redirect: "manual",
2834 })
2835 ).headers.get("set-cookie") ?? ""
2836 ).split(";")[0]!;
2837
2838 const get = (path: string, cookie: string) =>
2839 fetch(`${base}${path}`, {
2840 headers: { cookie: `${cookie}; forge_mode=studio` },
2841 redirect: "manual",
2842 });
2843
2844 beforeAll(async () => {
2845 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-tail-"));
2846 app = createApp({ dataDir, port: 0 });
2847 servers = await serve(app);
2848 base = servers.url;
2849 const a = await createUser(app.db, {
2850 username: "alice",
2851 email: "[email protected]",
2852 password: "a-good-passphrase",
2853 adminIfFirst: true,
2854 });
2855 if (!a.ok) throw new Error(a.error);
2856 alice = a.value;
2857 const b = await createUser(app.db, {
2858 username: "bob",
2859 email: "[email protected]",
2860 password: "a-good-passphrase",
2861 });
2862 if (!b.ok) throw new Error(b.error);
2863 writerCookie = await signIn("alice");
2864 readerCookie = await signIn("bob");
2865 const made = await createRepo(app, a.value, {
2866 name: "engine",
2867 visibility: "private",
2868 description: "an app that builds",
2869 });
2870 if (!made.ok) throw new Error(String(made.error));
2871 repo = made.value;
2872 const seeded = await commitToBranch(app, a.value, repo, {
2873 message: "Start",
2874 files: [{ path: "README.md", content: "engine\n" }],
2875 });
2876 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
2877 const enabled = await enableApp(app, a.value, repo, { slug: "engine" });
2878 if (!enabled.ok) throw new Error(String(enabled.error));
2879 const scout = await createAgent(app, a.value, { username: "scout" });
2880 if (!scout.ok) throw new Error(String(scout.error));
2881 scoutId = scout.value.user.id;
2882 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
2883 });
2884
2885 afterAll(async () => {
2886 servers.stop();
2887 app.close();
2888 await rm(dataDir, { recursive: true, force: true });
2889 });
2890
2891 test("the app's page carries the tail of the active run's log, bounded, and refreshes without a script", async () => {
2892 const tell = openIssue(app, alice, repo, { title: "Speed up boot" });
2893 if (!tell.ok) throw new Error(String(tell.error));
2894 const run = createRun(app.db, {
2895 repoId: repo.id,
2896 kind: "agent",
2897 issueId: tell.value.id,
2898 actorId: scoutId,
2899 reason: "assigned",
2900 });
2901 claimRun(app.db, run.id);
2902 for (let i = 0; i < 9; i++)
2903 appendRunLog(app.config.dataDir, run.id, `line ${i}`);
2904
2905 const page = await (await get("/alice/engine", writerCookie)).text();
2906 expect(page).toContain('id="app-run-tail"');
2907 expect(page).toContain(
2908 `data-stream="/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1"`,
2909 );
2910 // Bounded on the first paint too: only the last handful made it, and
2911 // the box says so.
2912 expect(page).not.toContain(">line 0<");
2913 expect(page).toContain(">line 8<");
2914 expect(page).toContain("Holding the last 6 lines here");
2915 // No script, no problem: the page keeps refreshing while the run goes.
2916 expect(page).toContain('http-equiv="refresh"');
2917
2918 const stream = await get(
2919 `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`,
2920 writerCookie,
2921 );
2922 expect(stream.status).toBe(200);
2923
2924 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
2925 const settled = await (await get("/alice/engine", writerCookie)).text();
2926 expect(settled).not.toContain('id="app-run-tail"');
2927 });
2928
2929 test("a reader without access gets the forge's not-found from the page and from the tail's own stream", async () => {
2930 const tell = openIssue(app, alice, repo, { title: "Private boot work" });
2931 if (!tell.ok) throw new Error(String(tell.error));
2932 const run = createRun(app.db, {
2933 repoId: repo.id,
2934 kind: "agent",
2935 issueId: tell.value.id,
2936 actorId: scoutId,
2937 reason: "assigned",
2938 });
2939 claimRun(app.db, run.id);
2940 appendRunLog(app.config.dataDir, run.id, "line 0");
2941
2942 const page = await get("/alice/engine", readerCookie);
2943 expect(page.status).toBe(404);
2944 const stream = await get(
2945 `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`,
2946 readerCookie,
2947 );
2948 expect(stream.status).toBe(404);
2949 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
2950 });
2951 });
2952
28052953 /**
28062954 * Issue #65: a public app's studio page is the app, not the owner's books.
28072955 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs