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

A running run page and app page drop the 5-second meta refresh and patch their status from /events #2877

Merged opened this

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

Merged

mason/forge:mason/2873-a-running-run-page-and-app-page-drop-the → main 1 commit fork

  • Merged. mason/2873-a-running-run-page-and-app-page-drop-the was merged into main as e698186.

Review

Sign in to review this change.
8 files changed +87 −48
docs/DESIGN.md +1 −1
@@ -201,7 +201,7 @@ specification.
201201 | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, App (the studio app page)| one line under the where strip: `● running abc1234 · hello.localhost` |
202202 | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; `envFacts()` in the body — commit, how fast it came up, uptime, a link to the deploy run, and the deployment's own note and error, each on its own line; Open (or Wake it) below. It is a pull's preview card; the facts and `envActions()` (Redeploy / Stop, writers only) are functions, not markup, so the studio head and its preview rows render the same ones | a box per environment with the same three lines |
203203 | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line |
204 | **run log** | `pre.run-log` | `data-stream` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script tails the SSE stream and follows the bottom until the reader scrolls up, a `<meta refresh>` does it without one | a scrolling pane that follows the tail |
204 | **run log** | `pre.run-log` | `data-run` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script appends the run's lines from the page's one `/events` stream and follows the bottom until the reader scrolls up; the page never reloads itself | a scrolling pane that follows the tail |
205205 | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents |
206206 | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard |
207207
src/web/client.ts +6 −5
@@ -214,9 +214,8 @@ const logLineJs = /* js */ `
214214
215215 /**
216216 * A run's log, live, painted in the studio's own five words. The page
217 * renders every line already written and, for a run still going, refreshes
218 * itself without a script; this replaces that poll with the log's own event
219 * stream, so a line lands the moment it is written. \`data-cap\` on \`#run-log\` bounds how many lines a long-running
217 * renders every line already written; for a run still going, this appends
218 * each line from the page's one event stream the moment it is written. \`data-cap\` on \`#run-log\` bounds how many lines a long-running
220219 * agent can pile into the DOM: past it, the oldest line is dropped as a new
221220 * one arrives, so an hour-long run never grows without bound.
222221 */
@@ -228,8 +227,6 @@ export const studioRunJs = /* js */ `
228227 var cap = parseInt(log.getAttribute('data-cap') || '0', 10) || 0;
229228 var pulse = document.getElementById('run-pulse');
230229 var jump = document.getElementById('run-jump');
231 var word = document.getElementById('run-word');
232 var summary = document.getElementById('run-summary');
233230 var cancel = document.getElementById('run-cancel');
234231 var stop = document.getElementById('run-stop');
235232 var steer = document.getElementById('run-steer');
@@ -262,7 +259,11 @@ export const studioRunJs = /* js */ `
262259 return w === 'look' || w === 'shipped' ? 'success' : w === 'stopped' ? 'danger' : w === 'working' ? 'accent' : 'secondary';
263260 }
264261 function money(n) { return n < 0.01 ? '<$0.01' : '$' + n.toFixed(2); }
262 // The head is a live region (\`liveRegionsJs\` swaps it from the page
263 // when the run moves), so what lives in it is looked up at paint time.
265264 function paint(run) {
265 var word = document.getElementById('run-word');
266 var summary = document.getElementById('run-summary');
266267 if (word) {
267268 var w = studioWord(run);
268269 word.setAttribute('data-word', w);
src/web/studio/app.ts +4 −14
@@ -272,14 +272,9 @@ export async function studioApp(
272272 : autonomyStatus(ctx.app, repo);
273273
274274 // ---- the app itself ----
275 // `deployTailing` says the head card is tailing production's own deploy,
276 // narrowly — just the "Now" panel's empty-state words below care about
277 // that distinction. `tailing` says the head card carries any live tail at
278 // all, deploy or a running app's own output (`studio/previews.ts`'s
279 // `productionTail`, #284) — the no-script refresh below must keep the
280 // page moving for either, not only for an agent run's tail beside a "Now"
281 // row.
282 const { head, previews, deployTailing, tailing } = appPanels(
275 // `deployTailing` says the head card is tailing production's own deploy —
276 // the "Now" panel's empty-state words below care about that distinction.
277 const { head, previews, deployTailing } = appPanels(
283278 ctx,
284279 repo,
285280 record,
@@ -323,7 +318,7 @@ export async function studioApp(
323318 </form>`;
324319
325320 // ---- now: one row per thing, in the five words (studio/now.ts) ----
326 const { card: now, running } = await nowCard(ctx, repo, record, {
321 const { card: now } = await nowCard(ctx, repo, record, {
327322 openPulls,
328323 builderCount: builders.length,
329324 ownerFacts,
@@ -507,10 +502,5 @@ export async function studioApp(
507502 palette: repoPalette(ctx, repo),
508503 body,
509504 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${appTailJs}`,
510 // The no-script half of the tail: with JavaScript off, the page
511 // refreshes itself while the crew's run goes, the way the run's own page
512 // does; a browser running the script streams lines instead and never
513 // reloads (`layout.ts` writes the refresh inside `<noscript>`).
514 ...(running || tailing ? { refresh: { seconds: 5 } } : {}),
515505 });
516506 }
src/web/studio/now.ts +2 −2
@@ -71,7 +71,7 @@ export async function nowCard(
7171 /** Whether the head card is tailing production's own deploy. */
7272 deployTailing: boolean;
7373 },
74 ): Promise<{ card: Html; running: boolean }> {
74 ): Promise<{ card: Html }> {
7575 const db = ctx.app.db;
7676 const base = repoBase(repo);
7777 const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o;
@@ -327,7 +327,7 @@ export async function nowCard(
327327 <span class="spacer"></span>
328328 ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`,
329329 });
330 return { card: now, running: activeRun !== undefined };
330 return { card: now };
331331 }
332332
333333 // GET /:owner/:name/app/now — the card alone, for the region that shows it.
src/web/studio/previews.ts −2
@@ -284,7 +284,6 @@ export function appPanels(
284284 head: Html;
285285 previews: Html | null;
286286 deployTailing: boolean;
287 tailing: boolean;
288287 } {
289288 const db = ctx.app.db;
290289 const base = repoBase(repo);
@@ -347,7 +346,6 @@ export function appPanels(
347346 head,
348347 previews,
349348 deployTailing: tail.deploying,
350 tailing: tail.box !== undefined,
351349 };
352350 }
353351
src/web/studio/run.ts +17 −12
@@ -120,7 +120,18 @@ export async function runView(
120120 ? getPull(db, repo.id, issue.number)
121121 : openPullsClosing(db, repo.id).get(issue.number);
122122
123 const head = html`<div class="issue-head">
123 // The head — the run's word, cost, turns, clock and summary — is the
124 // page's live region while the run is going: any `run.*` event for this
125 // repository refetches the page and swaps the head in place, so the run
126 // finishing shows its final state without a reload. Its id stays once the
127 // run is over, so the last swap still finds its match.
128 const head = html`<div
129 class="issue-head"
130 id="run-head"
131 ${unfinished
132 ? html`data-live="run.*" data-live-repo="${repo.ownerName}/${repo.name}"`
133 : null}
134 >
124135 <h1>${title}</h1>
125136 <div class="issue-meta">
126137 ${wordBadge(word, "run-word")}
@@ -300,14 +311,12 @@ export async function runView(
300311 )}
301312 <div class="run-live" id="run-live" data-state="${run.state}">
302313 ${when(
314 // The head above is what makes \`liveRegionsJs\` open the one
315 // /events connection this log and its finish ride on.
303316 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>`,
317 () => html`<p class="muted small" id="run-conn" role="status" hidden>
318 Reconnecting; lines written meanwhile appear when it is back.
319 </p>`,
311320 )}
312321 ${when(!logHidden, () => logBox({
313322 id: "run-log",
@@ -405,9 +414,5 @@ export async function runView(
405414 palette: repoPalette(ctx, repo),
406415 body,
407416 scriptJs: `${liveRegionsJs}\n${studioRunJs}`,
408 // The no-script half of a live log: with JavaScript off, the page
409 // refreshes itself while the run is going (`layout.ts` writes it inside
410 // `<noscript>`, so a browser running the stream above never reloads).
411 ...(unfinished ? { refresh: { seconds: 5 } } : {}),
412417 });
413418 }
test/studio-runs.test.ts +54 −10
@@ -542,7 +542,7 @@ describe("working: a run's own page in the studio", () => {
542542 for (const arg of opened) expect(arg).toStartWith("'/events");
543543 // The connection is really opened: liveRegionsJs only connects when the
544544 // page has a [data-live] element, and a script's text proves nothing.
545 expect(page).toMatch(/<span data-live="run\.finished"[^>]*>/);
545 expect(page).toMatch(/<div[^>]*id="run-head"[^>]*data-live="run\.\*"/);
546546 expect(page).toContain('id="run-conn"');
547547 expect(page).not.toContain("follow=1");
548548 expect(page).not.toContain("data-stream=");
@@ -555,6 +555,45 @@ describe("working: a run's own page in the studio", () => {
555555 expect(page).toContain(">second<");
556556 });
557557
558 test("a running run's page never reloads itself: its head is the live region, and once over it shows the final state", async () => {
559 const run = createRun(app.db, {
560 repoId: repo.id,
561 kind: "agent",
562 actorId: scoutId,
563 reason: "assigned",
564 });
565 claimRun(app.db, run.id);
566 noteRunProgress(app.db, run.id, { costUsd: 0.25 });
567 const path = `/alice/engine/runs/${run.id}`;
568 const head = (html: string): string =>
569 html.slice(html.indexOf('id="run-head"'), html.indexOf('id="run-live"'));
570
571 const going = await (await get(path, writerCookie)).text();
572 expect(going).not.toMatch(/<meta\s+http-equiv="refresh"/);
573 // Word, cost and clock sit in one region, swapped in place on any
574 // run.* event about this repository and no other.
575 expect(going).toMatch(
576 /<div\s+class="issue-head"\s+id="run-head"\s+data-live="run\.\*"\s+data-live-repo="alice\/engine"/,
577 );
578 expect(head(going)).toContain('data-word="working"');
579 expect(head(going)).toContain("$0.25 spent");
580 expect(head(going)).toContain("running for");
581
582 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
583 const over = await (await get(path, writerCookie)).text();
584 expect(over).not.toMatch(/<meta\s+http-equiv="refresh"/);
585 // The same region by id, so the swap on run.finished finds its match,
586 // carrying the final word, cost and summary; nothing is listening now.
587 expect(over).toContain('id="run-head"');
588 expect(over).not.toMatch(/id="run-head"[^>]*data-live=/);
589 expect(head(over)).toContain('data-word="planned"');
590 expect(head(over)).toContain("$0.25 spent");
591 expect(head(over)).toContain("took");
592 expect(head(over)).toContain(">planned<");
593 expect(over).not.toContain('id="run-conn"');
594 expect(over).not.toContain('id="run-stop"');
595 });
596
558597 test("a long transcript holds its last window, and the whole of it is one step deeper at ?all=1", async () => {
559598 const run = createRun(app.db, {
560599 repoId: repo.id,
@@ -954,7 +993,7 @@ describe("the app's page tails the crew's log while it works", () => {
954993 await rm(dataDir, { recursive: true, force: true });
955994 });
956995
957 test("the app's page carries the tail of the active run's log, bounded, and refreshes without a script", async () => {
996 test("the app's page carries the tail of the active run's log, bounded, and never reloads itself", async () => {
958997 const tell = openIssue(app, alice, repo, { title: "Speed up boot" });
959998 if (!tell.ok) throw new Error(String(tell.error));
960999 const run = createRun(app.db, {
@@ -978,8 +1017,12 @@ describe("the app's page tails the crew's log while it works", () => {
9781017 expect(page).not.toContain(">line 0<");
9791018 expect(page).toContain(">line 8<");
9801019 expect(page).toContain("Holding the last 6 lines here");
981 // No script, no problem: the page keeps refreshing while the run goes.
982 expect(page).toContain('http-equiv="refresh"');
1020 // The page never reloads itself (#2873): the Now card is a live region
1021 // for this repository's runs, and the tail rides the one /events stream.
1022 expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/);
1023 expect(page).toMatch(
1024 /id="live-now"\s+data-live="run\.\*[^"]*"\s+data-live-repo="alice\/engine"/,
1025 );
9831026
9841027 const stream = await get(
9851028 `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`,
@@ -1170,9 +1213,9 @@ describe("the app's page tails the deploy that is bringing it alive", () => {
11701213 `data-stream="/api/v1/repos/alice/kindling/runs/${deployRun.id}/log?follow=1"`,
11711214 );
11721215 expect(page).toContain("install …");
1173 // No script, no problem: the page keeps refreshing while the deploy
1174 // goes — the actual meta tag, not just the script's own selector text.
1175 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
1216 // The page never reloads itself while the deploy goes (#2873): the head
1217 // card swaps in place on the deploy's run events.
1218 expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/);
11761219 // Issue #163: the deploy is plainly going, in the head card above — "Now"
11771220 // does not also claim nothing is happening, and it does not open a
11781221 // second stream of the same lines to say so.
@@ -1243,8 +1286,9 @@ describe("the app's page tails the deploy that is bringing it alive", () => {
12431286 expect(page).toContain('id="app-run-tail"');
12441287 expect(page).not.toContain('class="run-log');
12451288 expect(page).toContain("Production is quiet");
1246 // Still nothing to fetch — the page keeps polling in case that changes.
1247 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
1289 // Still nothing to fetch, and the page does not reload in case that
1290 // changes (#2873): the head card swaps in place when production moves.
1291 expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/);
12481292 });
12491293 });
12501294
@@ -1390,7 +1434,7 @@ describe("the app's page tails production's own output while it runs (#284)", ()
13901434 expect(page).not.toContain(">serving request 0<");
13911435 expect(page).toContain(">serving request 8<");
13921436 expect(page).toContain("Holding the last 6 lines here");
1393 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
1437 expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/);
13941438
13951439 // Issue #284's own point: the run this reads from is long finished —
13961440 // the tail follows the env that points at it, not the run's own state.
test/web-apps.test.ts +3 −2
@@ -360,7 +360,8 @@ describe("apps, runs and the crew on the web", () => {
360360 // forge's own words ("checking out …") are marked as such.
361361 expect(page).toContain('class="ln ln-forge"');
362362 expect(page).toContain('id="run-elapsed"');
363 // The stream and the no-script refresh exist only while it is going.
363 // The live log exists only while it is going, and the page never
364 // reloads itself either way (#2873).
364365 // Match the real <meta> / data-stream= attributes — not the same
365366 // strings inside studioRunJs (a bare includes("http-equiv=…") stays
366367 // true after the meta is gone because the script still quotes it).
@@ -382,7 +383,7 @@ describe("apps, runs and the crew on the web", () => {
382383 // The tail is live while the run goes: it names its run (read from /events), not a stream.
383384 expect(html.includes(`data-run="${run!.id}"`)).toBe(before);
384385 expect(html.includes("data-stream=")).toBe(false);
385 expect(hasMetaRefresh(html)).toBe(before);
386 expect(hasMetaRefresh(html)).toBe(false);
386387 break;
387388 }
388389 if (Date.now() >= deadline) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs