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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2873-a-running-run-page-and-app-page-drop-the → main 1 commit fork
8 files
changed +87
−48
docs/DESIGN.md +1 −1
| @@ -201,7 +201,7 @@ specification. | ||
| 201 | 201 | | **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` | |
| 202 | 202 | | **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 | |
| 203 | 203 | | **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 | | |
| 205 | 205 | | **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 | |
| 206 | 206 | | **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 | |
| 207 | 207 |
src/web/client.ts +6 −5
| @@ -214,9 +214,8 @@ const logLineJs = /* js */ ` | ||
| 214 | 214 | |
| 215 | 215 | /** |
| 216 | 216 | * 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 | |
| 220 | 219 | * agent can pile into the DOM: past it, the oldest line is dropped as a new |
| 221 | 220 | * one arrives, so an hour-long run never grows without bound. |
| 222 | 221 | */ |
| @@ -228,8 +227,6 @@ export const studioRunJs = /* js */ ` | ||
| 228 | 227 | var cap = parseInt(log.getAttribute('data-cap') || '0', 10) || 0; |
| 229 | 228 | var pulse = document.getElementById('run-pulse'); |
| 230 | 229 | var jump = document.getElementById('run-jump'); |
| 231 | var word = document.getElementById('run-word'); | |
| 232 | var summary = document.getElementById('run-summary'); | |
| 233 | 230 | var cancel = document.getElementById('run-cancel'); |
| 234 | 231 | var stop = document.getElementById('run-stop'); |
| 235 | 232 | var steer = document.getElementById('run-steer'); |
| @@ -262,7 +259,11 @@ export const studioRunJs = /* js */ ` | ||
| 262 | 259 | return w === 'look' || w === 'shipped' ? 'success' : w === 'stopped' ? 'danger' : w === 'working' ? 'accent' : 'secondary'; |
| 263 | 260 | } |
| 264 | 261 | 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. | |
| 265 | 264 | function paint(run) { |
| 265 | var word = document.getElementById('run-word'); | |
| 266 | var summary = document.getElementById('run-summary'); | |
| 266 | 267 | if (word) { |
| 267 | 268 | var w = studioWord(run); |
| 268 | 269 | word.setAttribute('data-word', w); |
src/web/studio/app.ts +4 −14
| @@ -272,14 +272,9 @@ export async function studioApp( | ||
| 272 | 272 | : autonomyStatus(ctx.app, repo); |
| 273 | 273 | |
| 274 | 274 | // ---- 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( | |
| 283 | 278 | ctx, |
| 284 | 279 | repo, |
| 285 | 280 | record, |
| @@ -323,7 +318,7 @@ export async function studioApp( | ||
| 323 | 318 | </form>`; |
| 324 | 319 | |
| 325 | 320 | // ---- 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, { | |
| 327 | 322 | openPulls, |
| 328 | 323 | builderCount: builders.length, |
| 329 | 324 | ownerFacts, |
| @@ -507,10 +502,5 @@ export async function studioApp( | ||
| 507 | 502 | palette: repoPalette(ctx, repo), |
| 508 | 503 | body, |
| 509 | 504 | 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 } } : {}), | |
| 515 | 505 | }); |
| 516 | 506 | } |
src/web/studio/now.ts +2 −2
| @@ -71,7 +71,7 @@ export async function nowCard( | ||
| 71 | 71 | /** Whether the head card is tailing production's own deploy. */ |
| 72 | 72 | deployTailing: boolean; |
| 73 | 73 | }, |
| 74 | ): Promise<{ card: Html; running: boolean }> { | |
| 74 | ): Promise<{ card: Html }> { | |
| 75 | 75 | const db = ctx.app.db; |
| 76 | 76 | const base = repoBase(repo); |
| 77 | 77 | const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o; |
| @@ -327,7 +327,7 @@ export async function nowCard( | ||
| 327 | 327 | <span class="spacer"></span> |
| 328 | 328 | ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`, |
| 329 | 329 | }); |
| 330 | return { card: now, running: activeRun !== undefined }; | |
| 330 | return { card: now }; | |
| 331 | 331 | } |
| 332 | 332 | |
| 333 | 333 | // 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( | ||
| 284 | 284 | head: Html; |
| 285 | 285 | previews: Html | null; |
| 286 | 286 | deployTailing: boolean; |
| 287 | tailing: boolean; | |
| 288 | 287 | } { |
| 289 | 288 | const db = ctx.app.db; |
| 290 | 289 | const base = repoBase(repo); |
| @@ -347,7 +346,6 @@ export function appPanels( | ||
| 347 | 346 | head, |
| 348 | 347 | previews, |
| 349 | 348 | deployTailing: tail.deploying, |
| 350 | tailing: tail.box !== undefined, | |
| 351 | 349 | }; |
| 352 | 350 | } |
| 353 | 351 |
src/web/studio/run.ts +17 −12
| @@ -120,7 +120,18 @@ export async function runView( | ||
| 120 | 120 | ? getPull(db, repo.id, issue.number) |
| 121 | 121 | : openPullsClosing(db, repo.id).get(issue.number); |
| 122 | 122 | |
| 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 | > | |
| 124 | 135 | <h1>${title}</h1> |
| 125 | 136 | <div class="issue-meta"> |
| 126 | 137 | ${wordBadge(word, "run-word")} |
| @@ -300,14 +311,12 @@ export async function runView( | ||
| 300 | 311 | )} |
| 301 | 312 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 302 | 313 | ${when( |
| 314 | // The head above is what makes \`liveRegionsJs\` open the one | |
| 315 | // /events connection this log and its finish ride on. | |
| 303 | 316 | 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>`, | |
| 311 | 320 | )} |
| 312 | 321 | ${when(!logHidden, () => logBox({ |
| 313 | 322 | id: "run-log", |
| @@ -405,9 +414,5 @@ export async function runView( | ||
| 405 | 414 | palette: repoPalette(ctx, repo), |
| 406 | 415 | body, |
| 407 | 416 | 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 } } : {}), | |
| 412 | 417 | }); |
| 413 | 418 | } |
test/studio-runs.test.ts +54 −10
| @@ -542,7 +542,7 @@ describe("working: a run's own page in the studio", () => { | ||
| 542 | 542 | for (const arg of opened) expect(arg).toStartWith("'/events"); |
| 543 | 543 | // The connection is really opened: liveRegionsJs only connects when the |
| 544 | 544 | // 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\.\*"/); | |
| 546 | 546 | expect(page).toContain('id="run-conn"'); |
| 547 | 547 | expect(page).not.toContain("follow=1"); |
| 548 | 548 | expect(page).not.toContain("data-stream="); |
| @@ -555,6 +555,45 @@ describe("working: a run's own page in the studio", () => { | ||
| 555 | 555 | expect(page).toContain(">second<"); |
| 556 | 556 | }); |
| 557 | 557 | |
| 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 | ||
| 558 | 597 | test("a long transcript holds its last window, and the whole of it is one step deeper at ?all=1", async () => { |
| 559 | 598 | const run = createRun(app.db, { |
| 560 | 599 | repoId: repo.id, |
| @@ -954,7 +993,7 @@ describe("the app's page tails the crew's log while it works", () => { | ||
| 954 | 993 | await rm(dataDir, { recursive: true, force: true }); |
| 955 | 994 | }); |
| 956 | 995 | |
| 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 () => { | |
| 958 | 997 | const tell = openIssue(app, alice, repo, { title: "Speed up boot" }); |
| 959 | 998 | if (!tell.ok) throw new Error(String(tell.error)); |
| 960 | 999 | const run = createRun(app.db, { |
| @@ -978,8 +1017,12 @@ describe("the app's page tails the crew's log while it works", () => { | ||
| 978 | 1017 | expect(page).not.toContain(">line 0<"); |
| 979 | 1018 | expect(page).toContain(">line 8<"); |
| 980 | 1019 | 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 | ); | |
| 983 | 1026 | |
| 984 | 1027 | const stream = await get( |
| 985 | 1028 | `/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", () => { | ||
| 1170 | 1213 | `data-stream="/api/v1/repos/alice/kindling/runs/${deployRun.id}/log?follow=1"`, |
| 1171 | 1214 | ); |
| 1172 | 1215 | 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"/); | |
| 1176 | 1219 | // Issue #163: the deploy is plainly going, in the head card above — "Now" |
| 1177 | 1220 | // does not also claim nothing is happening, and it does not open a |
| 1178 | 1221 | // 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", () => { | ||
| 1243 | 1286 | expect(page).toContain('id="app-run-tail"'); |
| 1244 | 1287 | expect(page).not.toContain('class="run-log'); |
| 1245 | 1288 | 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"/); | |
| 1248 | 1292 | }); |
| 1249 | 1293 | }); |
| 1250 | 1294 | |
| @@ -1390,7 +1434,7 @@ describe("the app's page tails production's own output while it runs (#284)", () | ||
| 1390 | 1434 | expect(page).not.toContain(">serving request 0<"); |
| 1391 | 1435 | expect(page).toContain(">serving request 8<"); |
| 1392 | 1436 | 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"/); | |
| 1394 | 1438 | |
| 1395 | 1439 | // Issue #284's own point: the run this reads from is long finished — |
| 1396 | 1440 | // 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", () => { | ||
| 360 | 360 | // forge's own words ("checking out …") are marked as such. |
| 361 | 361 | expect(page).toContain('class="ln ln-forge"'); |
| 362 | 362 | 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). | |
| 364 | 365 | // Match the real <meta> / data-stream= attributes — not the same |
| 365 | 366 | // strings inside studioRunJs (a bare includes("http-equiv=…") stays |
| 366 | 367 | // 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", () => { | ||
| 382 | 383 | // The tail is live while the run goes: it names its run (read from /events), not a stream. |
| 383 | 384 | expect(html.includes(`data-run="${run!.id}"`)).toBe(before); |
| 384 | 385 | expect(html.includes("data-stream=")).toBe(false); |
| 385 | expect(hasMetaRefresh(html)).toBe(before); | |
| 386 | expect(hasMetaRefresh(html)).toBe(false); | |
| 386 | 387 | break; |
| 387 | 388 | } |
| 388 | 389 | if (Date.now() >= deadline) { |
new above
offline