Public: anyone can read this; the crew can change it.
Live regions have addresses: fetch the region, not the page; 304 when unchanged #322
Check · Review · Land · Live done — Landed and live in production.
realtime-fragments → main 2 commits
14 files
changed +669
−360
docs/STUDIO.md +34 −9
| @@ -304,15 +304,40 @@ how many there are and a filter for whether they are live, each a link | ||
| 304 | 304 | carrying `?live=1` rather than a form, and **New app** beside them. |
| 305 | 305 | `?mode=dev` on the same URL is dev's table of repositories, unchanged. |
| 306 | 306 | |
| 307 | The home keeps itself current. _Your apps_ and _on you_ | |
| 308 | are each a live region (`data-live`, `src/web/client.ts`): a deploy | |
| 309 | serving, a pull opening, closing or merging, a run starting or ending, or an | |
| 310 | assignment changing refetches the home — the very same `studioHome` render | |
| 311 | that drew the first paint, never a second copy of that markup — and swaps | |
| 312 | just that region in place, so a card that is answered goes and a new one | |
| 313 | arrives without a reload. Events within half a second of each other cost one | |
| 314 | fetch. A card mid-decision (its post to the crew still in flight) is never | |
| 315 | swapped out from under the click deciding it. | |
| 307 | The home keeps itself current. _Your apps_ and _on you_ are each a live | |
| 308 | region (`data-live`, `src/web/client.ts`): a deploy serving, a pull | |
| 309 | opening, closing or merging, a run starting or ending, or an assignment | |
| 310 | changing fetches that region again — the very same render that drew the | |
| 311 | first paint, never a second copy of that markup — and swaps it in place, | |
| 312 | so a card that is answered goes and a new one arrives without a reload. | |
| 313 | ||
| 314 | ### Live regions | |
| 315 | ||
| 316 | A page with a script never reloads itself: the `meta refresh` some pages | |
| 317 | carry is the no-script fallback, and the script removes it the moment it | |
| 318 | can stream. Every live region listens to one `EventSource` (`/events`, | |
| 319 | everything the viewer may see) and works like this: | |
| 320 | ||
| 321 | - **An address of its own.** A region carries `data-live-src` — `/live/apps` | |
| 322 | and `/live/on-you` on the home, `<repo>/app/now` for _Now_ on the app | |
| 323 | page, `<repo>/app/state` for its head card — and an event fetches that | |
| 324 | address, not the page. The fragment answers with the same element, id | |
| 325 | and all, rendered by the same function as the page, with its icons | |
| 326 | inlined (`layout.fragment`). A region without an address shares one | |
| 327 | fetch of the page; that is the fallback, not the design. | |
| 328 | - **Nothing painted that did not change.** A fragment's `etag` is a hash of | |
| 329 | its bytes; the client sends it back as `if-none-match` and an unchanged | |
| 330 | region is a 304 with no body. When bytes do arrive, two renders that | |
| 331 | differ only in a relative time ("3m ago") are the same region. | |
| 332 | - **One fetch per burst.** Events within a beat of each other cost one | |
| 333 | fetch per address, never more often than every second and a half; a | |
| 334 | hidden tab fetches nothing and catches up on the first look back. | |
| 335 | - **Never out from under a hand.** A region holding a focused input, or a | |
| 336 | card whose post is still in flight (`data-busy`), is left alone until | |
| 337 | the next event. | |
| 338 | ||
| 339 | Run logs and the crew's transcript stream line by line over their own | |
| 340 | `?follow=1` answers; they are the streamed part of a page, not a region. | |
| 316 | 341 | |
| 317 | 342 | Signed out, `/` in the studio is a stranger's front door, not the dev |
| 318 | 343 | pitch: a shelf of the public apps actually serving — name, one line of |
src/checks/bun-test.ts +3 −1
| @@ -7,7 +7,9 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { CheckDetail } from "../core/runs.ts"; |
| 9 | 9 | |
| 10 | const FAIL_LINE = /^\(fail\) (.+) \[[0-9.]+m?s\]$/; | |
| 10 | // The duration is bun's to print or not: under CI it leaves it off a test | |
| 11 | // that took next to no time, so a name is a name with or without one. | |
| 12 | const FAIL_LINE = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?$/; | |
| 11 | 13 | const PASS_COUNT = /^\s*(\d+) pass$/; |
| 12 | 14 | const FAIL_COUNT = /^\s*(\d+) fail$/; |
| 13 | 15 |
src/http/server.ts +9
| @@ -104,6 +104,8 @@ import { | ||
| 104 | 104 | takeBackSubmit, |
| 105 | 105 | tellSubmit, |
| 106 | 106 | } from "../web/studio/actions.ts"; |
| 107 | import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts"; | |
| 108 | import { appNowFragment } from "../web/studio/now.ts"; | |
| 107 | 109 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 108 | 110 | import { changesList, tellsList } from "../web/studio/lists.ts"; |
| 109 | 111 | import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts"; |
| @@ -425,6 +427,11 @@ export function buildRouter(): Router<Handler> { | ||
| 425 | 427 | router.add("GET", "/migrate/jobs/:job", migrationJobPage); |
| 426 | 428 | // Every app on one canvas; a literal route, so it must sit above /:owner. |
| 427 | 429 | router.add("GET", "/map", globalMapPage); |
| 430 | // Live regions' own addresses (docs/STUDIO.md, "Live regions"): the | |
| 431 | // home's shelf and its "On you" card answer for themselves, so an event | |
| 432 | // fetches a region and not the page. Literal, above /:owner. | |
| 433 | router.add("GET", "/live/apps", liveAppsFragment); | |
| 434 | router.add("GET", "/live/on-you", liveOnYouFragment); | |
| 428 | 435 | router.add("GET", "/runs/:id", runRedirect); |
| 429 | 436 | router.add("GET", "/settings", accountPage); |
| 430 | 437 | router.add("POST", "/settings", accountUpdate); |
| @@ -681,6 +688,8 @@ export function buildRouter(): Router<Handler> { | ||
| 681 | 688 | // The studio page's own live refresh: the head card and previews list, |
| 682 | 689 | // bare, fetched on a deploy run event for the app being viewed. |
| 683 | 690 | router.add("GET", "/:owner/:name/app/state", appStateFragment); |
| 691 | // "Now" on the same page, alone: the region's own address. | |
| 692 | router.add("GET", "/:owner/:name/app/now", appNowFragment); | |
| 684 | 693 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); |
| 685 | 694 | // Where a private app sends a person who arrived by its link. |
| 686 | 695 | router.add("GET", "/apps/open", appsOpen); |
src/web/client.ts +72 −30
| @@ -2031,20 +2031,60 @@ export const liveRegionsJs = /* js */ ` | ||
| 2031 | 2031 | var timer = null; |
| 2032 | 2032 | var last = 0; |
| 2033 | 2033 | var MIN_GAP = 1500; |
| 2034 | function refresh() { | |
| 2035 | timer = null; | |
| 2036 | // Hidden, the page waits: a tab in the background paints nothing, and | |
| 2037 | // the first look back brings everything at once. | |
| 2038 | if (document.hidden) return; | |
| 2039 | var ids = Object.keys(due); | |
| 2040 | due = {}; | |
| 2041 | if (ids.length === 0) return; | |
| 2042 | last = Date.now(); | |
| 2043 | fetch(location.href, { headers: { accept: 'text/html' }, credentials: 'same-origin' }) | |
| 2044 | .then(function (r) { return r.ok ? r.text() : Promise.reject(r); }) | |
| 2034 | // What each address last answered with, so the next ask is conditional: | |
| 2035 | // a region that did not change comes back 304 and paints nothing. | |
| 2036 | var tags = {}; | |
| 2037 | // Relative times ("3m ago") drift on their own; two renders that differ | |
| 2038 | // only there are the same region, and the clock is not a change. | |
| 2039 | function same(a, b) { | |
| 2040 | var t = /<time\\b[^>]*>[^<]*<\\/time>/g; | |
| 2041 | return a.replace(t, '') === b.replace(t, ''); | |
| 2042 | } | |
| 2043 | function swap(fresh, ids) { | |
| 2044 | ids.forEach(function (id) { | |
| 2045 | var mine = document.getElementById(id); | |
| 2046 | var theirs = fresh.getElementById(id); | |
| 2047 | if (!mine || !theirs) return; | |
| 2048 | // A card mid-action (the big view marks it data-busy while its post | |
| 2049 | // is in flight) is never pulled out from under the click; the next | |
| 2050 | // matching event tries the swap again. | |
| 2051 | if (mine.querySelector('[data-busy]')) return; | |
| 2052 | var active = document.activeElement; | |
| 2053 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; | |
| 2054 | if (same(mine.innerHTML, theirs.innerHTML)) return; | |
| 2055 | mine.innerHTML = theirs.innerHTML; | |
| 2056 | Array.prototype.forEach.call(theirs.attributes, function (a) { if (a.name !== 'id') mine.setAttribute(a.name, a.value); }); | |
| 2057 | mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); | |
| 2058 | }); | |
| 2059 | } | |
| 2060 | // A region with an address of its own (data-live-src) answers for itself: | |
| 2061 | // the server renders that region and nothing else, and its etag says | |
| 2062 | // whether anything changed. Regions without one share one fetch of the | |
| 2063 | // page, which is the fallback, not the design. | |
| 2064 | function pull(src, ids) { | |
| 2065 | var own = src !== location.href; | |
| 2066 | var headers = { accept: 'text/html' }; | |
| 2067 | if (tags[src]) headers['if-none-match'] = tags[src]; | |
| 2068 | fetch(src, { headers: headers, credentials: 'same-origin' }) | |
| 2069 | .then(function (r) { | |
| 2070 | if (r.status === 304) return null; | |
| 2071 | if (!r.ok) return Promise.reject(r); | |
| 2072 | var tag = r.headers.get('etag'); | |
| 2073 | if (tag) tags[src] = tag; | |
| 2074 | return r.text(); | |
| 2075 | }) | |
| 2045 | 2076 | .then(function (text) { |
| 2046 | // Parse a copy without the page's own style element: the swap only ever | |
| 2047 | // reads a live region's innerHTML, never the head, and the fresh | |
| 2077 | if (text === null) return; | |
| 2078 | if (own) { | |
| 2079 | // A fragment's icons come inlined (layout.fragment), so it needs | |
| 2080 | // nothing from this page's sprite. | |
| 2081 | var holder = document.createElement('template'); | |
| 2082 | holder.innerHTML = text; | |
| 2083 | swap(holder.content, ids); | |
| 2084 | return; | |
| 2085 | } | |
| 2086 | // Parse a copy without the page's own style element: the swap only | |
| 2087 | // ever reads a region's innerHTML, never the head, and the fresh | |
| 2048 | 2088 | // page carries a fresh nonce the live document's CSP cannot match — |
| 2049 | 2089 | // parsing it would log an inline-style violation for nothing. |
| 2050 | 2090 | var fresh = new DOMParser().parseFromString(text.replace(/<(style)[^>]*>[\\s\\S]*?<\\/\\1>/gi, ''), 'text/html'); |
| @@ -2054,26 +2094,28 @@ export const liveRegionsJs = /* js */ ` | ||
| 2054 | 2094 | var theirSprite = fresh.querySelector('.sprite'); |
| 2055 | 2095 | var mySprite = document.querySelector('.sprite'); |
| 2056 | 2096 | if (theirSprite && mySprite) mySprite.replaceWith(theirSprite); |
| 2057 | ids.forEach(function (id) { | |
| 2058 | var mine = document.getElementById(id); | |
| 2059 | var theirs = fresh.getElementById(id); | |
| 2060 | if (!mine || !theirs) return; | |
| 2061 | // A card mid-decision (asksJs marks it data-busy while its post is | |
| 2062 | // in flight) is never pulled out from under the click that is | |
| 2063 | // deciding it; the next matching event tries the swap again. | |
| 2064 | if (mine.querySelector('[data-busy]')) return; | |
| 2065 | var active = document.activeElement; | |
| 2066 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; | |
| 2067 | // Nothing to paint when nothing changed: an event about this | |
| 2068 | // region that left its markup as it was is not a flicker. | |
| 2069 | if (mine.innerHTML === theirs.innerHTML) return; | |
| 2070 | mine.innerHTML = theirs.innerHTML; | |
| 2071 | Array.prototype.forEach.call(theirs.attributes, function (a) { if (a.name !== 'id') mine.setAttribute(a.name, a.value); }); | |
| 2072 | mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); | |
| 2073 | }); | |
| 2097 | swap(fresh, ids); | |
| 2074 | 2098 | }) |
| 2075 | 2099 | .catch(function () {}); |
| 2076 | 2100 | } |
| 2101 | function refresh() { | |
| 2102 | timer = null; | |
| 2103 | // Hidden, the page waits: a tab in the background paints nothing, and | |
| 2104 | // the first look back brings everything at once. | |
| 2105 | if (document.hidden) return; | |
| 2106 | var ids = Object.keys(due); | |
| 2107 | due = {}; | |
| 2108 | if (ids.length === 0) return; | |
| 2109 | last = Date.now(); | |
| 2110 | var groups = {}; | |
| 2111 | ids.forEach(function (id) { | |
| 2112 | var region = document.getElementById(id); | |
| 2113 | if (!region) return; | |
| 2114 | var src = region.getAttribute('data-live-src') || location.href; | |
| 2115 | (groups[src] = groups[src] || []).push(id); | |
| 2116 | }); | |
| 2117 | Object.keys(groups).forEach(function (src) { pull(src, groups[src]); }); | |
| 2118 | } | |
| 2077 | 2119 | var source = new EventSource('/events'); |
| 2078 | 2120 | // The stream's own state, for a page that shows whether it is connected. |
| 2079 | 2121 | function state(s) { document.dispatchEvent(new CustomEvent('forge:live', { detail: { state: s } })); } |
src/web/layout.ts +26 −12
| @@ -13,6 +13,7 @@ import type { User } from "../core/users.ts"; | ||
| 13 | 13 | import type { Ctx } from "../http/context.ts"; |
| 14 | 14 | import { edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts"; |
| 15 | 15 | import { json } from "../http/respond.ts"; |
| 16 | import { sha256 } from "../kernel/hash.ts"; | |
| 16 | 17 | import { secret } from "../kernel/id.ts"; |
| 17 | 18 | import { |
| 18 | 19 | metaThemeColor, |
| @@ -639,19 +640,32 @@ export function redirect( | ||
| 639 | 640 | * Its icons are inlined rather than referenced: a fragment cannot count on |
| 640 | 641 | * the sprite of the document it is being dropped into, and must not add ids |
| 641 | 642 | * to it (`inlineIcons`, `icons.ts`). |
| 643 | * | |
| 644 | * A fragment names its own bytes: the `etag` is a hash of the body, and a | |
| 645 | * request carrying it back as `if-none-match` gets a 304 and nothing to | |
| 646 | * paint. `cache-control: no-store` keeps the browser's own cache out of it — | |
| 647 | * the live client holds the tag itself and asks conditionally on purpose, | |
| 648 | * so an unchanged region costs one round trip and no repaint. | |
| 642 | 649 | */ |
| 643 | export function fragment(body: Html, status = 200): Response { | |
| 644 | return new Response(inlineIcons(body.text), { | |
| 645 | status, | |
| 646 | headers: { | |
| 647 | "content-type": "text/html; charset=utf-8", | |
| 648 | "content-security-policy": | |
| 649 | "default-src 'none'; style-src 'none'; sandbox", | |
| 650 | "referrer-policy": "same-origin", | |
| 651 | "x-content-type-options": "nosniff", | |
| 652 | "cache-control": "no-store", | |
| 653 | }, | |
| 654 | }); | |
| 650 | export function fragment( | |
| 651 | body: Html, | |
| 652 | o: { status?: number; request?: Request } = {}, | |
| 653 | ): Response { | |
| 654 | const text = inlineIcons(body.text); | |
| 655 | const status = o.status ?? 200; | |
| 656 | const headers: Record<string, string> = { | |
| 657 | "content-type": "text/html; charset=utf-8", | |
| 658 | "content-security-policy": "default-src 'none'; style-src 'none'; sandbox", | |
| 659 | "referrer-policy": "same-origin", | |
| 660 | "x-content-type-options": "nosniff", | |
| 661 | "cache-control": "no-store", | |
| 662 | }; | |
| 663 | if (status !== 200) return new Response(text, { status, headers }); | |
| 664 | const etag = `"${sha256(text).slice(0, 16)}"`; | |
| 665 | headers.etag = etag; | |
| 666 | if (o.request?.headers.get("if-none-match") === etag) | |
| 667 | return new Response(null, { status: 304, headers }); | |
| 668 | return new Response(text, { status, headers }); | |
| 655 | 669 | } |
| 656 | 670 | |
| 657 | 671 | /** Redirect back to a page carrying a message — the shape most handlers want. */ |
src/web/pages/browse.ts +5 −2
| @@ -229,7 +229,9 @@ export async function browseTree(ctx: Ctx): Promise<Response> { | ||
| 229 | 229 | if (!entries.ok) return notFoundPage(ctx); |
| 230 | 230 | |
| 231 | 231 | if (ctx.url.searchParams.get("fragment") === "explorer") |
| 232 | return fragment(explorerFragment(scope.base, ref, path, entries.value)); | |
| 232 | return fragment(explorerFragment(scope.base, ref, path, entries.value), { | |
| 233 | request: ctx.request, | |
| 234 | }); | |
| 233 | 235 | |
| 234 | 236 | const [refs, explorer] = await Promise.all([ |
| 235 | 237 | refSetFrom(scope.source), |
| @@ -266,7 +268,8 @@ export async function browseBlob(ctx: Ctx): Promise<Response> { | ||
| 266 | 268 | blob: blob.value, |
| 267 | 269 | view: blobView(ctx.url.searchParams.get("view")), |
| 268 | 270 | }); |
| 269 | if (ctx.url.searchParams.get("fragment") === "1") return fragment(pane); | |
| 271 | if (ctx.url.searchParams.get("fragment") === "1") | |
| 272 | return fragment(pane, { request: ctx.request }); | |
| 270 | 273 | |
| 271 | 274 | const explorer = await loadExplorer(scope.source, ref, path, true); |
| 272 | 275 | return codePage(ctx, scope, { |
src/web/pages/edit.ts +5 −3
| @@ -979,13 +979,15 @@ export async function deleteFileSubmit(ctx: Ctx): Promise<Response> { | ||
| 979 | 979 | */ |
| 980 | 980 | export async function previewSubmit(ctx: Ctx): Promise<Response> { |
| 981 | 981 | const repo = loadRepo(ctx); |
| 982 | if (!repo) return fragment(html``, 404); | |
| 982 | if (!repo) return fragment(html``, { status: 404 }); | |
| 983 | 983 | const form = await readForm(ctx); |
| 984 | 984 | if (form instanceof Response) return form; |
| 985 | 985 | const scope = localScope(ctx, repo); |
| 986 | if (!scope.caps.write) return fragment(html``, 404); | |
| 986 | if (!scope.caps.write) return fragment(html``, { status: 404 }); | |
| 987 | 987 | const body = formValue(form, "body"); |
| 988 | 988 | const path = formValue(form, "path"); |
| 989 | 989 | const markdown = path === "" || /\.(md|markdown)$/i.test(path); |
| 990 | return fragment(markdown ? renderMarkdown(body) : html`<pre>${body}</pre>`); | |
| 990 | return fragment(markdown ? renderMarkdown(body) : html`<pre>${body}</pre>`, { | |
| 991 | request: ctx.request, | |
| 992 | }); | |
| 991 | 993 | } |
src/web/pages/repo.ts +5 −2
| @@ -1021,7 +1021,9 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1021 | 1021 | |
| 1022 | 1022 | // The explorer's lazy expansion asks for exactly one directory. |
| 1023 | 1023 | if (ctx.url.searchParams.get("fragment") === "explorer") |
| 1024 | return fragment(explorerFragment(scope.base, ref, path, entries.value)); | |
| 1024 | return fragment(explorerFragment(scope.base, ref, path, entries.value), { | |
| 1025 | request: ctx.request, | |
| 1026 | }); | |
| 1025 | 1027 | |
| 1026 | 1028 | const [refs, explorer] = await Promise.all([ |
| 1027 | 1029 | refSetFrom(scope.source), |
| @@ -1063,7 +1065,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1063 | 1065 | |
| 1064 | 1066 | // The preview a script drops into the pane beside the explorer. Same |
| 1065 | 1067 | // renderer, same markup — a preview and a real page load are the same file. |
| 1066 | if (ctx.url.searchParams.get("fragment") === "1") return fragment(pane); | |
| 1068 | if (ctx.url.searchParams.get("fragment") === "1") | |
| 1069 | return fragment(pane, { request: ctx.request }); | |
| 1067 | 1070 | |
| 1068 | 1071 | const explorer = await loadExplorer(scope.source, ref, path, true); |
| 1069 | 1072 | const filename = path.split("/").pop() ?? path; |
src/web/studio/app.ts +14 −257
| @@ -12,26 +12,16 @@ import { appsRuntime } from "../../apps/runtime.ts"; | ||
| 12 | 12 | import { listCrewRoles } from "../../core/agents.ts"; |
| 13 | 13 | import type { AppRecord } from "../../core/apps.ts"; |
| 14 | 14 | import { decisionStats, type DecisionStat } from "../../core/decisions.ts"; |
| 15 | import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; | |
| 16 | import { | |
| 17 | listPulls, | |
| 18 | mergedPullCount, | |
| 19 | openPullsClosing, | |
| 20 | } from "../../core/pulls.ts"; | |
| 15 | import { listPulls, mergedPullCount } from "../../core/pulls.ts"; | |
| 21 | 16 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 22 | 17 | import { |
| 23 | 18 | agentRunTotals, |
| 24 | latestRunsForIssues, | |
| 25 | listRuns, | |
| 26 | 19 | previewLatency, |
| 27 | 20 | type PreviewLatency, |
| 28 | type Run, | |
| 29 | 21 | } from "../../core/runs.ts"; |
| 30 | import { getUsersByIds } from "../../core/users.ts"; | |
| 31 | 22 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 32 | 23 | import { repoDir } from "../../git/repos.ts"; |
| 33 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 34 | import { readRunLogTail } from "../../runs/log.ts"; | |
| 35 | 25 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 36 | 26 | import { appTailJs, liveRegionsJs } from "../client.ts"; |
| 37 | 27 | import { html, when, type Html } from "../html.ts"; |
| @@ -47,19 +37,10 @@ import { | ||
| 47 | 37 | time, |
| 48 | 38 | userTag, |
| 49 | 39 | } from "../ui.ts"; |
| 50 | import { changeHref, changesHref, tellHref, tellsHref } from "./nouns.ts"; | |
| 51 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; | |
| 40 | import { changesHref, tellsHref } from "./nouns.ts"; | |
| 41 | import { nowCard, nowRegion } from "./now.ts"; | |
| 52 | 42 | import { appPanels } from "./previews.ts"; |
| 53 | import { stopForm } from "./run.ts"; | |
| 54 | import { tellWord } from "./tell.ts"; | |
| 55 | import { voiceFor, type Actor } from "./voice.ts"; | |
| 56 | import { | |
| 57 | pullWord, | |
| 58 | runWord, | |
| 59 | servingHistory, | |
| 60 | wordBadge, | |
| 61 | type WordCtx, | |
| 62 | } from "./words.ts"; | |
| 43 | import { servingHistory, type WordCtx } from "./words.ts"; | |
| 63 | 44 | |
| 64 | 45 | /** |
| 65 | 46 | * The week's decisions in one line: how many, how many the decider took |
| @@ -219,17 +200,6 @@ export async function studioApp( | ||
| 219 | 200 | }; |
| 220 | 201 | const crew = listCrewRoles(db, repo.id); |
| 221 | 202 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); |
| 222 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }); | |
| 223 | const actors = getUsersByIds( | |
| 224 | db, | |
| 225 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 226 | ); | |
| 227 | const issues = new Map<string, Issue>(); | |
| 228 | for (const r of runs) | |
| 229 | if (r.issueId !== null && !issues.has(r.issueId)) { | |
| 230 | const i = getIssueById(db, r.issueId); | |
| 231 | if (i) issues.set(r.issueId, i); | |
| 232 | } | |
| 233 | 203 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); |
| 234 | 204 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); |
| 235 | 205 | // What landed this week, counted by the database like the runs and the |
| @@ -313,222 +283,15 @@ export async function studioApp( | ||
| 313 | 283 | </div> |
| 314 | 284 | </form>`; |
| 315 | 285 | |
| 316 | // ---- now: one row per thing, in the five words ---- | |
| 317 | const proposed = new Set(openPulls.map((p) => p.id)); | |
| 318 | const openByIssue = openPullsClosing(db, repo.id); | |
| 319 | const openTells = listIssues(db, repo.id, { | |
| 320 | kind: "issue", | |
| 321 | state: "open", | |
| 322 | limit: 30, | |
| 286 | ||
| 287 | // ---- now: one row per thing, in the five words (studio/now.ts) ---- | |
| 288 | const { card: now, running } = await nowCard(ctx, repo, record, { | |
| 289 | openPulls, | |
| 290 | builderCount: builders.length, | |
| 291 | ownerFacts, | |
| 292 | wordCtx, | |
| 293 | deployTailing, | |
| 323 | 294 | }); |
| 324 | const told = openTells.filter( | |
| 325 | (i) => i.assigneeIsAgent && !openByIssue.has(i.number), | |
| 326 | ); | |
| 327 | // A person can hold a tell exactly as an agent can — #224's own rule for | |
| 328 | // a change applies here too: the front door counts work by what it is, | |
| 329 | // not by who is doing it. Scoped to `assigneeId`, never widened to every | |
| 330 | // open tell, so an ordinary unassigned backlog still stays off this | |
| 331 | // panel. | |
| 332 | const personHeld = openTells.filter( | |
| 333 | (i) => | |
| 334 | i.assigneeId !== null && | |
| 335 | !i.assigneeIsAgent && | |
| 336 | i.id !== repo.directionIssueId && | |
| 337 | !openByIssue.has(i.number), | |
| 338 | ); | |
| 339 | // With nobody on the crew to build, `tellCrew` cannot route a tell to | |
| 340 | // anyone — it stays unassigned forever, not merely unpicked-up like | |
| 341 | // `waiting` below. Scoped to that one honest cause (never to a tell a | |
| 342 | // person simply hasn't assigned while builders exist), so widening this | |
| 343 | // never turns "Now" into a plain issue list. | |
| 344 | const untaken = | |
| 345 | builders.length === 0 | |
| 346 | ? openTells | |
| 347 | .filter( | |
| 348 | (i) => | |
| 349 | i.assigneeId === null && | |
| 350 | i.id !== repo.directionIssueId && | |
| 351 | !openByIssue.has(i.number), | |
| 352 | ) | |
| 353 | .slice(0, 5) | |
| 354 | : []; | |
| 355 | const everRan = latestRunsForIssues( | |
| 356 | db, | |
| 357 | [...told, ...personHeld, ...untaken].map((i) => i.id), | |
| 358 | ["agent"], | |
| 359 | ); | |
| 360 | const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5); | |
| 361 | const held = personHeld.filter((i) => !everRan.has(i.id)).slice(0, 5); | |
| 362 | const stuck = untaken.filter((i) => !everRan.has(i.id)); | |
| 363 | const issueNumberById = new Map( | |
| 364 | [...told, ...personHeld, ...untaken].map((i) => [i.id, i.number]), | |
| 365 | ); | |
| 366 | const rowsNow = runs | |
| 367 | .filter((r) => { | |
| 368 | if (r.issueId === null) return true; | |
| 369 | if (proposed.has(r.issueId)) return false; | |
| 370 | const n = issueNumberById.get(r.issueId) ?? issues.get(r.issueId)?.number; | |
| 371 | return n === undefined || !openByIssue.has(n); | |
| 372 | }) | |
| 373 | .filter( | |
| 374 | (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx, | |
| 375 | ) | |
| 376 | .slice(0, 6); | |
| 377 | // Who signs a "Now" row is the one rule for it, `voiceFor` | |
| 378 | // (`studio/voice.ts`): a person is named to anyone who can see the row — | |
| 379 | // the same name `/changes` and the change's own page give — and only the | |
| 380 | // crew's own work reads as the app's own story for a viewer who cannot | |
| 381 | // see the roster. The avatar takes the same string, so its initials can | |
| 382 | // never disagree with the name beside them. | |
| 383 | const runActor = (r: Run): Actor => ({ | |
| 384 | name: r.actorId === null ? undefined : actors.get(r.actorId)?.username, | |
| 385 | // A run with nobody on it is the crew's own, never a person's. | |
| 386 | isAgent: r.actorId === null || actors.get(r.actorId)?.kind === "agent", | |
| 387 | }); | |
| 388 | ||
| 389 | // The one run actually going, if any: its row gets a tail of its own | |
| 390 | // log — the last handful of lines, streamed live — beside it. Only one, | |
| 391 | // so the page never opens a stream per row. | |
| 392 | const activeRun = rowsNow.find( | |
| 393 | (r) => r.kind === "agent" && r.state === "running", | |
| 394 | ); | |
| 395 | const activeTail = activeRun | |
| 396 | ? logBox({ | |
| 397 | id: "app-run-tail", | |
| 398 | who: voiceFor(runActor(activeRun), ownerFacts), | |
| 399 | cap: LOG_TAIL_CAP, | |
| 400 | lines: readRunLogTail( | |
| 401 | ctx.app.config.dataDir, | |
| 402 | activeRun.id, | |
| 403 | LOG_TAIL_CAP, | |
| 404 | ).lines, | |
| 405 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`, | |
| 406 | className: "log-tail", | |
| 407 | }) | |
| 408 | : undefined; | |
| 409 | ||
| 410 | const runRows: Html[] = []; | |
| 411 | for (const r of rowsNow) { | |
| 412 | const { word, note } = await runWord(wordCtx, r); | |
| 413 | const who = voiceFor(runActor(r), ownerFacts); | |
| 414 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 415 | const stop = stopForm(ctx, repo, r); | |
| 416 | runRows.push( | |
| 417 | row({ | |
| 418 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 419 | lead: avatar(who), | |
| 420 | title: stop | |
| 421 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 422 | : (about?.title ?? note), | |
| 423 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 424 | ${word === "working" | |
| 425 | ? null | |
| 426 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 427 | trail: stop | |
| 428 | ? html`<span class="studio-ask-actions" | |
| 429 | >${wordBadge(word)} ${stop}</span | |
| 430 | >` | |
| 431 | : wordBadge(word), | |
| 432 | }), | |
| 433 | ); | |
| 434 | if (activeTail !== undefined && r.id === activeRun?.id) | |
| 435 | runRows.push(html`<li class="log-tail-row">${activeTail}</li>`); | |
| 436 | } | |
| 437 | ||
| 438 | const nowRows: Html[] = [ | |
| 439 | ...(await Promise.all( | |
| 440 | openPulls.map(async (p) => { | |
| 441 | const { word, note } = await pullWord(wordCtx, p); | |
| 442 | const who = voiceFor( | |
| 443 | { name: p.authorName, isAgent: p.authorIsAgent }, | |
| 444 | ownerFacts, | |
| 445 | ); | |
| 446 | return row({ | |
| 447 | href: changeHref(base, p.number), | |
| 448 | nav: true, | |
| 449 | lead: avatar(who), | |
| 450 | title: p.title, | |
| 451 | meta: html`${who} · ${note} · ${time(p.createdAt)}`, | |
| 452 | trail: wordBadge(word), | |
| 453 | }); | |
| 454 | }), | |
| 455 | )), | |
| 456 | ...runRows, | |
| 457 | ...waiting.map((i) => { | |
| 458 | const who = voiceFor( | |
| 459 | { name: i.assigneeName ?? undefined, isAgent: i.assigneeIsAgent }, | |
| 460 | ownerFacts, | |
| 461 | ); | |
| 462 | return row({ | |
| 463 | href: tellHref(base, i.number), | |
| 464 | nav: true, | |
| 465 | lead: avatar(who), | |
| 466 | title: i.title, | |
| 467 | meta: html`${who} · told ${time(i.createdAt)}, not picked up yet`, | |
| 468 | trail: wordBadge("waiting"), | |
| 469 | }); | |
| 470 | }), | |
| 471 | // Held by a person: `tellWord` — the one place a tell's word is | |
| 472 | // decided (`studio/tell.ts`) — says so honestly, the same word and | |
| 473 | // note the tell's own page and "Everything told" already show for it. | |
| 474 | // `personHeld` has already established the holder is a person, so | |
| 475 | // `voiceFor` names them to every viewer who can see the row. | |
| 476 | ...(await Promise.all( | |
| 477 | held.map(async (i) => { | |
| 478 | const { word, note } = (await tellWord( | |
| 479 | wordCtx, | |
| 480 | repo, | |
| 481 | i, | |
| 482 | undefined, | |
| 483 | )) ?? { | |
| 484 | word: "told" as const, | |
| 485 | note: "held", | |
| 486 | }; | |
| 487 | const who = voiceFor( | |
| 488 | { name: i.assigneeName ?? undefined, isAgent: false }, | |
| 489 | ownerFacts, | |
| 490 | "someone", | |
| 491 | ); | |
| 492 | return row({ | |
| 493 | href: tellHref(base, i.number), | |
| 494 | nav: true, | |
| 495 | lead: avatar(who), | |
| 496 | title: i.title, | |
| 497 | meta: html`${who} · ${note} · ${time(i.createdAt)}`, | |
| 498 | trail: wordBadge(word), | |
| 499 | }); | |
| 500 | }), | |
| 501 | )), | |
| 502 | ...stuck.map((i) => { | |
| 503 | return row({ | |
| 504 | lead: avatar("crew"), | |
| 505 | title: html`<a href="${tellHref(base, i.number)}">${i.title}</a>`, | |
| 506 | meta: html`told ${time(i.createdAt)} · | |
| 507 | <span class="muted">nobody on the crew to take it —</span> | |
| 508 | <a href="${base}/settings">add someone</a>`, | |
| 509 | trail: wordBadge("told"), | |
| 510 | }); | |
| 511 | }), | |
| 512 | ]; | |
| 513 | const now = | |
| 514 | nowRows.length === 0 | |
| 515 | ? card( | |
| 516 | html`<p class="muted small"> | |
| 517 | ${deployTailing | |
| 518 | ? "Production is coming up — the log above has it." | |
| 519 | : "Nothing yet. Tell the crew something."} | |
| 520 | </p>`, | |
| 521 | { | |
| 522 | head: html`${icon("activity")} | |
| 523 | <h2>Now</h2>`, | |
| 524 | }, | |
| 525 | ) | |
| 526 | : listCard(nowRows, { | |
| 527 | head: html`${icon("activity")} | |
| 528 | <h2>Now</h2> | |
| 529 | <span class="spacer"></span> | |
| 530 | ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`, | |
| 531 | }); | |
| 532 | 295 | |
| 533 | 296 | // ---- the rail ---- |
| 534 | 297 | // Crew, spend and autonomy are the owner's facts; a viewer who cannot |
| @@ -659,13 +422,7 @@ export async function studioApp( | ||
| 659 | 422 | <div class="dash-main stack"> |
| 660 | 423 | ${tell} |
| 661 | 424 | <div id="app-previews">${previews}</div> |
| 662 | <div | |
| 663 | id="live-now" | |
| 664 | data-live="run.*,pull.*,issue.*" | |
| 665 | data-live-repo="${repo.ownerName}/${repo.name}" | |
| 666 | > | |
| 667 | ${now} | |
| 668 | </div> | |
| 425 | ${nowRegion(repo, now)} | |
| 669 | 426 | </div> |
| 670 | 427 | <aside class="dash-rail stack"> |
| 671 | 428 | ${crewCard} ${weekCard} ${ownCard} ${more} |
| @@ -682,6 +439,6 @@ export async function studioApp( | ||
| 682 | 439 | // The no-script half of the tail: while the crew's run goes, the page |
| 683 | 440 | // refreshes itself, the way the run's own page does; the script above |
| 684 | 441 | // removes this the moment it can stream lines instead. |
| 685 | ...(activeRun || tailing ? { refresh: { seconds: 5 } } : {}), | |
| 442 | ...(running || tailing ? { refresh: { seconds: 5 } } : {}), | |
| 686 | 443 | }); |
| 687 | 444 | } |
src/web/studio/home.ts +81 −41
| @@ -7,14 +7,14 @@ | ||
| 7 | 7 | * apps grow. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 11 | 10 | import { crewForRepos } from "../../core/agents.ts"; |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | 12 | import { type User } from "../../core/users.ts"; |
| 13 | 13 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; |
| 14 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 15 | import { liveRegionsJs } from "../client.ts"; |
| 16 | import { html, when } from "../html.ts"; | |
| 17 | import { page } from "../layout.ts"; | |
| 16 | import { html, when, type Html } from "../html.ts"; | |
| 17 | import { fragment, page } from "../layout.ts"; | |
| 18 | 18 | import { liveClientJs } from "../live.ts"; |
| 19 | 19 | import { btn, empty, icon, listCard, row } from "../ui.ts"; |
| 20 | 20 | import { onYouCard } from "../pages/site.ts"; |
| @@ -28,23 +28,91 @@ import { servingHistory } from "./words.ts"; | ||
| 28 | 28 | * what a person put on you. Built from the same rows and cards as the rest |
| 29 | 29 | * of the forge, so it reads as the forge. |
| 30 | 30 | */ |
| 31 | export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | |
| 32 | const db = ctx.app.db; | |
| 33 | // The front door leads with what the viewer can write — their own apps | |
| 34 | // and their organisations' — never widened by a stranger's public app | |
| 35 | // (`writableAppsOf`, not the visibility every reader gets from `appsOf`). | |
| 36 | const { repos: appRepos, records } = writableAppsOf(ctx, user); | |
| 37 | const runtime = appsRuntime(ctx.app); | |
| 31 | /** The events that can change a row on the shelf. */ | |
| 32 | const APPS_LIVE = | |
| 33 | "app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged"; | |
| 34 | /** The events that can change what is on you. */ | |
| 35 | const ON_YOU_LIVE = | |
| 36 | "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged"; | |
| 37 | ||
| 38 | /** | |
| 39 | * The shelf: every app the viewer can write, one row each. The front door | |
| 40 | * leads with what the viewer can write — their own apps and their | |
| 41 | * organisations' — never widened by a stranger's public app | |
| 42 | * (`writableAppsOf`, not the visibility every reader gets from `appsOf`). | |
| 43 | */ | |
| 44 | async function shelf( | |
| 45 | ctx: Ctx, | |
| 46 | user: User, | |
| 47 | ): Promise<{ appRepos: readonly Repo[]; apps: Html }> { | |
| 48 | const { repos: appRepos } = writableAppsOf(ctx, user); | |
| 38 | 49 | // Read once, shown twice again: a shipped or failed deploy is a row's own |
| 39 | 50 | // word, and — when nothing is running and no change waits — the rail's. |
| 40 | 51 | const productionFacts = productionFactsForRepos(ctx, appRepos); |
| 41 | 52 | // One memo of production's history for every word on this page: the |
| 42 | // shelf's rows and the Now list below ask it once per repository. | |
| 53 | // shelf's rows ask it once per repository. | |
| 43 | 54 | const history = servingHistory(); |
| 44 | 55 | const appRows = await shelfRows(ctx, appRepos, { |
| 45 | 56 | productionFacts, |
| 46 | 57 | history, |
| 47 | 58 | }); |
| 59 | const apps = | |
| 60 | appRows.length === 0 | |
| 61 | ? empty({ | |
| 62 | icon: "rocket", | |
| 63 | title: "No apps yet", | |
| 64 | note: "Make one, tell the crew what it is, and watch it come up.", | |
| 65 | action: btn("New app", { href: "/apps/new", variant: "primary" }), | |
| 66 | }) | |
| 67 | : listCard(appRows, { | |
| 68 | head: html`${icon("rocket")} | |
| 69 | <h2>Your apps</h2> | |
| 70 | <span class="spacer"></span> | |
| 71 | ${btn("Map", { href: "/map", size: "sm", icon: "map" })} | |
| 72 | ${btn("Audit", { href: "/audit", size: "sm", icon: "shield" })} | |
| 73 | ${btn("Board", { href: "/board", size: "sm", icon: "activity" })} | |
| 74 | ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}`, | |
| 75 | }); | |
| 76 | return { appRepos, apps }; | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Each live region on the home has an address of its own (`data-live-src`), | |
| 81 | * so an event fetches that region — the shelf, or what is on you — and not | |
| 82 | * the page; the fragment answers with the same element, id and all. | |
| 83 | */ | |
| 84 | const appsRegion = (apps: Html): Html => | |
| 85 | html`<div id="live-apps" data-live="${APPS_LIVE}" data-live-src="/live/apps"> | |
| 86 | ${apps} | |
| 87 | </div>`; | |
| 88 | ||
| 89 | const onYouRegion = (ctx: Ctx, user: User): Html => | |
| 90 | html`<div | |
| 91 | id="live-on-you" | |
| 92 | data-live="${ON_YOU_LIVE}" | |
| 93 | data-live-src="/live/on-you" | |
| 94 | > | |
| 95 | ${onYouCard(ctx, user) ?? null} | |
| 96 | </div>`; | |
| 97 | ||
| 98 | // GET /live/apps — the shelf alone, for the region that shows it. | |
| 99 | export async function liveAppsFragment(ctx: Ctx): Promise<Response> { | |
| 100 | const user = ctx.auth.user; | |
| 101 | if (!user) return fragment(html``, { status: 404 }); | |
| 102 | const { apps } = await shelf(ctx, user); | |
| 103 | return fragment(appsRegion(apps), { request: ctx.request }); | |
| 104 | } | |
| 105 | ||
| 106 | // GET /live/on-you — what is on the viewer, alone. | |
| 107 | export function liveOnYouFragment(ctx: Ctx): Response { | |
| 108 | const user = ctx.auth.user; | |
| 109 | if (!user) return fragment(html``, { status: 404 }); | |
| 110 | return fragment(onYouRegion(ctx, user), { request: ctx.request }); | |
| 111 | } | |
| 112 | ||
| 113 | export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | |
| 114 | const db = ctx.app.db; | |
| 115 | const { appRepos, apps } = await shelf(ctx, user); | |
| 48 | 116 | |
| 49 | 117 | // What the crew cost today across every app on the shelf, and how many |
| 50 | 118 | // are paused at their ceiling right now — the owner's first question at |
| @@ -75,42 +143,14 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 75 | 143 | </p>`; |
| 76 | 144 | }); |
| 77 | 145 | |
| 78 | const apps = | |
| 79 | appRows.length === 0 | |
| 80 | ? empty({ | |
| 81 | icon: "rocket", | |
| 82 | title: "No apps yet", | |
| 83 | note: "Make one, tell the crew what it is, and watch it come up.", | |
| 84 | action: btn("New app", { href: "/apps/new", variant: "primary" }), | |
| 85 | }) | |
| 86 | : listCard(appRows, { | |
| 87 | head: html`${icon("rocket")} | |
| 88 | <h2>Your apps</h2> | |
| 89 | <span class="spacer"></span> | |
| 90 | ${btn("Map", { href: "/map", size: "sm", icon: "map" })} | |
| 91 | ${btn("Audit", { href: "/audit", size: "sm", icon: "shield" })} | |
| 92 | ${btn("Board", { href: "/board", size: "sm", icon: "activity" })} | |
| 93 | ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}`, | |
| 94 | }); | |
| 95 | ||
| 96 | 146 | const body = html`<h1 class="sr-only">Studio</h1> |
| 97 | 147 | ${costLine} |
| 98 | 148 | <div class="dash"> |
| 99 | 149 | <div class="dash-main stack"> |
| 100 | <div | |
| 101 | id="live-apps" | |
| 102 | data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged" | |
| 103 | > | |
| 104 | ${apps} | |
| 105 | </div> | |
| 150 | ${appsRegion(apps)} | |
| 106 | 151 | </div> |
| 107 | 152 | <aside class="dash-rail stack"> |
| 108 | <div | |
| 109 | id="live-on-you" | |
| 110 | data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged" | |
| 111 | > | |
| 112 | ${onYouCard(ctx, user) ?? null} | |
| 113 | </div> | |
| 153 | ${onYouRegion(ctx, user)} | |
| 114 | 154 | </aside> |
| 115 | 155 | </div>`; |
| 116 | 156 | return page({ |
src/web/studio/now.ts added +329
| @@ -0,0 +1,329 @@ | ||
| 1 | /** | |
| 2 | * Now — one row per thing going on in an app, in the five words: the open | |
| 3 | * changes, the crew's runs, and the tells nobody has picked up yet. It is | |
| 4 | * one live region on the app page (`#live-now`) with an address of its own | |
| 5 | * (`GET /:owner/:name/app/now`), so a live event fetches this card and | |
| 6 | * nothing else — and a card that did not change comes back as a 304, not | |
| 7 | * a repaint (`layout.fragment`, `client.liveRegionsJs`). | |
| 8 | */ | |
| 9 | ||
| 10 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 11 | import { listCrewRoles } from "../../core/agents.ts"; | |
| 12 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; | |
| 13 | import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; | |
| 14 | import { listPulls, openPullsClosing, type Pull } from "../../core/pulls.ts"; | |
| 15 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 16 | import { latestRunsForIssues, listRuns, type Run } from "../../core/runs.ts"; | |
| 17 | import { getUsersByIds } from "../../core/users.ts"; | |
| 18 | import { repoDir } from "../../git/repos.ts"; | |
| 19 | import type { Ctx } from "../../http/context.ts"; | |
| 20 | import { readRunLogTail } from "../../runs/log.ts"; | |
| 21 | import { loadRepo, repoBase } from "../chrome.ts"; | |
| 22 | import { html, type Html } from "../html.ts"; | |
| 23 | import { fragment } from "../layout.ts"; | |
| 24 | import { avatar, btn, card, icon, listCard, row, time } from "../ui.ts"; | |
| 25 | import { changeHref, tellHref } from "./nouns.ts"; | |
| 26 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; | |
| 27 | import { appPanels } from "./previews.ts"; | |
| 28 | import { stopForm } from "./run.ts"; | |
| 29 | import { tellWord } from "./tell.ts"; | |
| 30 | import { voiceFor, type Actor } from "./voice.ts"; | |
| 31 | import { | |
| 32 | pullWord, | |
| 33 | runWord, | |
| 34 | servingHistory, | |
| 35 | wordBadge, | |
| 36 | type WordCtx, | |
| 37 | } from "./words.ts"; | |
| 38 | ||
| 39 | /** The events that can change what Now says. */ | |
| 40 | export const NOW_LIVE = "run.*,pull.*,issue.*"; | |
| 41 | ||
| 42 | /** | |
| 43 | * The card in its live region: the kinds it listens for, the repository | |
| 44 | * they must be about, and its own address. The fragment answers with this | |
| 45 | * same element, so the client swaps like for like. | |
| 46 | */ | |
| 47 | export function nowRegion(repo: Repo, now: Html): Html { | |
| 48 | return html`<div | |
| 49 | id="live-now" | |
| 50 | data-live="${NOW_LIVE}" | |
| 51 | data-live-repo="${repo.ownerName}/${repo.name}" | |
| 52 | data-live-src="${repoBase(repo)}/app/now" | |
| 53 | > | |
| 54 | ${now} | |
| 55 | </div>`; | |
| 56 | } | |
| 57 | ||
| 58 | export async function nowCard( | |
| 59 | ctx: Ctx, | |
| 60 | repo: Repo, | |
| 61 | record: AppRecord, | |
| 62 | o: { | |
| 63 | openPulls: readonly Pull[]; | |
| 64 | /** How many builders the crew has: none, and an untaken tell is stuck. */ | |
| 65 | builderCount: number; | |
| 66 | ownerFacts: boolean; | |
| 67 | wordCtx: WordCtx; | |
| 68 | /** Whether the head card is tailing production's own deploy. */ | |
| 69 | deployTailing: boolean; | |
| 70 | }, | |
| 71 | ): Promise<{ card: Html; running: boolean }> { | |
| 72 | const db = ctx.app.db; | |
| 73 | const base = repoBase(repo); | |
| 74 | const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o; | |
| 75 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }); | |
| 76 | const actors = getUsersByIds( | |
| 77 | db, | |
| 78 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 79 | ); | |
| 80 | const issues = new Map<string, Issue>(); | |
| 81 | for (const r of runs) | |
| 82 | if (r.issueId !== null && !issues.has(r.issueId)) { | |
| 83 | const i = getIssueById(db, r.issueId); | |
| 84 | if (i) issues.set(r.issueId, i); | |
| 85 | } | |
| 86 | const proposed = new Set(openPulls.map((p) => p.id)); | |
| 87 | const openByIssue = openPullsClosing(db, repo.id); | |
| 88 | const openTells = listIssues(db, repo.id, { | |
| 89 | kind: "issue", | |
| 90 | state: "open", | |
| 91 | limit: 30, | |
| 92 | }); | |
| 93 | const told = openTells.filter( | |
| 94 | (i) => i.assigneeIsAgent && !openByIssue.has(i.number), | |
| 95 | ); | |
| 96 | // A person can hold a tell exactly as an agent can — #224's own rule for | |
| 97 | // a change applies here too: the front door counts work by what it is, | |
| 98 | // not by who is doing it. Scoped to `assigneeId`, never widened to every | |
| 99 | // open tell, so an ordinary unassigned backlog still stays off this | |
| 100 | // panel. | |
| 101 | const personHeld = openTells.filter( | |
| 102 | (i) => | |
| 103 | i.assigneeId !== null && | |
| 104 | !i.assigneeIsAgent && | |
| 105 | i.id !== repo.directionIssueId && | |
| 106 | !openByIssue.has(i.number), | |
| 107 | ); | |
| 108 | // With nobody on the crew to build, `tellCrew` cannot route a tell to | |
| 109 | // anyone — it stays unassigned forever, not merely unpicked-up like | |
| 110 | // `waiting` below. Scoped to that one honest cause (never to a tell a | |
| 111 | // person simply hasn't assigned while builders exist), so widening this | |
| 112 | // never turns "Now" into a plain issue list. | |
| 113 | const untaken = | |
| 114 | builderCount === 0 | |
| 115 | ? openTells | |
| 116 | .filter( | |
| 117 | (i) => | |
| 118 | i.assigneeId === null && | |
| 119 | i.id !== repo.directionIssueId && | |
| 120 | !openByIssue.has(i.number), | |
| 121 | ) | |
| 122 | .slice(0, 5) | |
| 123 | : []; | |
| 124 | const everRan = latestRunsForIssues( | |
| 125 | db, | |
| 126 | [...told, ...personHeld, ...untaken].map((i) => i.id), | |
| 127 | ["agent"], | |
| 128 | ); | |
| 129 | const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5); | |
| 130 | const held = personHeld.filter((i) => !everRan.has(i.id)).slice(0, 5); | |
| 131 | const stuck = untaken.filter((i) => !everRan.has(i.id)); | |
| 132 | const issueNumberById = new Map( | |
| 133 | [...told, ...personHeld, ...untaken].map((i) => [i.id, i.number]), | |
| 134 | ); | |
| 135 | const rowsNow = runs | |
| 136 | .filter((r) => { | |
| 137 | if (r.issueId === null) return true; | |
| 138 | if (proposed.has(r.issueId)) return false; | |
| 139 | const n = issueNumberById.get(r.issueId) ?? issues.get(r.issueId)?.number; | |
| 140 | return n === undefined || !openByIssue.has(n); | |
| 141 | }) | |
| 142 | .filter( | |
| 143 | (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx, | |
| 144 | ) | |
| 145 | .slice(0, 6); | |
| 146 | // Who signs a "Now" row is the one rule for it, `voiceFor` | |
| 147 | // (`studio/voice.ts`): a person is named to anyone who can see the row — | |
| 148 | // the same name `/changes` and the change's own page give — and only the | |
| 149 | // crew's own work reads as the app's own story for a viewer who cannot | |
| 150 | // see the roster. The avatar takes the same string, so its initials can | |
| 151 | // never disagree with the name beside them. | |
| 152 | const runActor = (r: Run): Actor => ({ | |
| 153 | name: r.actorId === null ? undefined : actors.get(r.actorId)?.username, | |
| 154 | // A run with nobody on it is the crew's own, never a person's. | |
| 155 | isAgent: r.actorId === null || actors.get(r.actorId)?.kind === "agent", | |
| 156 | }); | |
| 157 | ||
| 158 | // The one run actually going, if any: its row gets a tail of its own | |
| 159 | // log — the last handful of lines, streamed live — beside it. Only one, | |
| 160 | // so the page never opens a stream per row. | |
| 161 | const activeRun = rowsNow.find( | |
| 162 | (r) => r.kind === "agent" && r.state === "running", | |
| 163 | ); | |
| 164 | const activeTail = activeRun | |
| 165 | ? logBox({ | |
| 166 | id: "app-run-tail", | |
| 167 | who: voiceFor(runActor(activeRun), ownerFacts), | |
| 168 | cap: LOG_TAIL_CAP, | |
| 169 | lines: readRunLogTail( | |
| 170 | ctx.app.config.dataDir, | |
| 171 | activeRun.id, | |
| 172 | LOG_TAIL_CAP, | |
| 173 | ).lines, | |
| 174 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`, | |
| 175 | className: "log-tail", | |
| 176 | }) | |
| 177 | : undefined; | |
| 178 | ||
| 179 | const runRows: Html[] = []; | |
| 180 | for (const r of rowsNow) { | |
| 181 | const { word, note } = await runWord(wordCtx, r); | |
| 182 | const who = voiceFor(runActor(r), ownerFacts); | |
| 183 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 184 | const stop = stopForm(ctx, repo, r); | |
| 185 | runRows.push( | |
| 186 | row({ | |
| 187 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 188 | lead: avatar(who), | |
| 189 | title: stop | |
| 190 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 191 | : (about?.title ?? note), | |
| 192 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 193 | ${word === "working" | |
| 194 | ? null | |
| 195 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 196 | trail: stop | |
| 197 | ? html`<span class="studio-ask-actions" | |
| 198 | >${wordBadge(word)} ${stop}</span | |
| 199 | >` | |
| 200 | : wordBadge(word), | |
| 201 | }), | |
| 202 | ); | |
| 203 | if (activeTail !== undefined && r.id === activeRun?.id) | |
| 204 | runRows.push(html`<li class="log-tail-row">${activeTail}</li>`); | |
| 205 | } | |
| 206 | ||
| 207 | const nowRows: Html[] = [ | |
| 208 | ...(await Promise.all( | |
| 209 | openPulls.map(async (p) => { | |
| 210 | const { word, note } = await pullWord(wordCtx, p); | |
| 211 | const who = voiceFor( | |
| 212 | { name: p.authorName, isAgent: p.authorIsAgent }, | |
| 213 | ownerFacts, | |
| 214 | ); | |
| 215 | return row({ | |
| 216 | href: changeHref(base, p.number), | |
| 217 | nav: true, | |
| 218 | lead: avatar(who), | |
| 219 | title: p.title, | |
| 220 | meta: html`${who} · ${note} · ${time(p.createdAt)}`, | |
| 221 | trail: wordBadge(word), | |
| 222 | }); | |
| 223 | }), | |
| 224 | )), | |
| 225 | ...runRows, | |
| 226 | ...waiting.map((i) => { | |
| 227 | const who = voiceFor( | |
| 228 | { name: i.assigneeName ?? undefined, isAgent: i.assigneeIsAgent }, | |
| 229 | ownerFacts, | |
| 230 | ); | |
| 231 | return row({ | |
| 232 | href: tellHref(base, i.number), | |
| 233 | nav: true, | |
| 234 | lead: avatar(who), | |
| 235 | title: i.title, | |
| 236 | meta: html`${who} · told ${time(i.createdAt)}, not picked up yet`, | |
| 237 | trail: wordBadge("waiting"), | |
| 238 | }); | |
| 239 | }), | |
| 240 | // Held by a person: `tellWord` — the one place a tell's word is | |
| 241 | // decided (`studio/tell.ts`) — says so honestly, the same word and | |
| 242 | // note the tell's own page and "Everything told" already show for it. | |
| 243 | // `personHeld` has already established the holder is a person, so | |
| 244 | // `voiceFor` names them to every viewer who can see the row. | |
| 245 | ...(await Promise.all( | |
| 246 | held.map(async (i) => { | |
| 247 | const { word, note } = (await tellWord( | |
| 248 | wordCtx, | |
| 249 | repo, | |
| 250 | i, | |
| 251 | undefined, | |
| 252 | )) ?? { | |
| 253 | word: "told" as const, | |
| 254 | note: "held", | |
| 255 | }; | |
| 256 | const who = voiceFor( | |
| 257 | { name: i.assigneeName ?? undefined, isAgent: false }, | |
| 258 | ownerFacts, | |
| 259 | "someone", | |
| 260 | ); | |
| 261 | return row({ | |
| 262 | href: tellHref(base, i.number), | |
| 263 | nav: true, | |
| 264 | lead: avatar(who), | |
| 265 | title: i.title, | |
| 266 | meta: html`${who} · ${note} · ${time(i.createdAt)}`, | |
| 267 | trail: wordBadge(word), | |
| 268 | }); | |
| 269 | }), | |
| 270 | )), | |
| 271 | ...stuck.map((i) => { | |
| 272 | return row({ | |
| 273 | lead: avatar("crew"), | |
| 274 | title: html`<a href="${tellHref(base, i.number)}">${i.title}</a>`, | |
| 275 | meta: html`told ${time(i.createdAt)} · | |
| 276 | <span class="muted">nobody on the crew to take it —</span> | |
| 277 | <a href="${base}/settings">add someone</a>`, | |
| 278 | trail: wordBadge("told"), | |
| 279 | }); | |
| 280 | }), | |
| 281 | ]; | |
| 282 | const now = | |
| 283 | nowRows.length === 0 | |
| 284 | ? card( | |
| 285 | html`<p class="muted small"> | |
| 286 | ${deployTailing | |
| 287 | ? "Production is coming up — the log above has it." | |
| 288 | : "Nothing yet. Tell the crew something."} | |
| 289 | </p>`, | |
| 290 | { | |
| 291 | head: html`${icon("activity")} | |
| 292 | <h2>Now</h2>`, | |
| 293 | }, | |
| 294 | ) | |
| 295 | : listCard(nowRows, { | |
| 296 | head: html`${icon("activity")} | |
| 297 | <h2>Now</h2> | |
| 298 | <span class="spacer"></span> | |
| 299 | ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`, | |
| 300 | }); | |
| 301 | return { card: now, running: activeRun !== undefined }; | |
| 302 | } | |
| 303 | ||
| 304 | // GET /:owner/:name/app/now — the card alone, for the region that shows it. | |
| 305 | export async function appNowFragment(ctx: Ctx): Promise<Response> { | |
| 306 | const repo = loadRepo(ctx); | |
| 307 | if (!repo) return fragment(html``, { status: 404 }); | |
| 308 | const db = ctx.app.db; | |
| 309 | const record = getAppByRepo(db, repo.id); | |
| 310 | if (!record) return fragment(html``, { status: 404 }); | |
| 311 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); | |
| 312 | const { card } = await nowCard(ctx, repo, record, { | |
| 313 | openPulls, | |
| 314 | builderCount: listCrewRoles(db, repo.id).filter((c) => c.role === "builder") | |
| 315 | .length, | |
| 316 | ownerFacts: canSeeOwnerFacts(db, repo, ctx.auth.user), | |
| 317 | wordCtx: { | |
| 318 | db, | |
| 319 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 320 | runtime: appsRuntime(ctx.app), | |
| 321 | record, | |
| 322 | history: servingHistory(), | |
| 323 | }, | |
| 324 | // The one fact Now borrows from the head card, decided where the head | |
| 325 | // card decides it, so the two never disagree about what is tailing. | |
| 326 | deployTailing: appPanels(ctx, repo, record, openPulls).deployTailing, | |
| 327 | }); | |
| 328 | return fragment(nowRegion(repo, card), { request: ctx.request }); | |
| 329 | } |
src/web/studio/previews.ts +3 −3
| @@ -306,14 +306,14 @@ export function appPanels( | ||
| 306 | 306 | */ |
| 307 | 307 | export function appStateFragment(ctx: Ctx): Response { |
| 308 | 308 | const repo = loadRepo(ctx); |
| 309 | if (!repo) return fragment(html``, 404); | |
| 309 | if (!repo) return fragment(html``, { status: 404 }); | |
| 310 | 310 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 311 | if (!record) return fragment(html``, 404); | |
| 311 | if (!record) return fragment(html``, { status: 404 }); | |
| 312 | 312 | const openPulls = listPulls(ctx.app.db, repo.id, { |
| 313 | 313 | state: "open", |
| 314 | 314 | limit: 10, |
| 315 | 315 | }); |
| 316 | 316 | const { head, previews } = appPanels(ctx, repo, record, openPulls); |
| 317 | 317 | return fragment(html`<div id="app-head">${head}</div> |
| 318 | <div id="app-previews">${previews}</div>`); | |
| 318 | <div id="app-previews">${previews}</div>`, { request: ctx.request }); | |
| 319 | 319 | } |
test/checks.test.ts +16
| @@ -65,6 +65,22 @@ describe("parseBunTestFailure: a convenience over one known runner, not a genera | ||
| 65 | 65 | ]); |
| 66 | 66 | }); |
| 67 | 67 | |
| 68 | test("a failing test bun printed without a duration — as it does under CI for one that took no time — is still named", () => { | |
| 69 | const lines = [ | |
| 70 | "(pass) a passing test", | |
| 71 | "(fail) first failing test [0.10ms]", | |
| 72 | "(fail) second failing test", | |
| 73 | " 1 pass", | |
| 74 | " 2 fail", | |
| 75 | "Ran 3 tests across 1 file. [9.00ms]", | |
| 76 | ]; | |
| 77 | expect(parseBunTestFailure(lines)).toEqual({ | |
| 78 | passed: 1, | |
| 79 | failed: 2, | |
| 80 | names: ["first failing test", "second failing test"], | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 68 | 84 | test("output that is not bun test's own shape is not guessed at", () => { |
| 69 | 85 | expect( |
| 70 | 86 | parseBunTestFailure(["$ test -f NOPE.md", "test: NOPE.md: No such file or directory"]), |
test/studio.test.ts +67
| @@ -2048,6 +2048,34 @@ describe("the studio home keeps itself current", () => { | ||
| 2048 | 2048 | expect(home).toContain("Write the README"); |
| 2049 | 2049 | }); |
| 2050 | 2050 | |
| 2051 | test("each region on the home has an address of its own, answering with that region alone and 304 when it has not changed", async () => { | |
| 2052 | const page = await (await get()).text(); | |
| 2053 | expect(page).toContain('data-live-src="/live/apps"'); | |
| 2054 | expect(page).toContain('data-live-src="/live/on-you"'); | |
| 2055 | ||
| 2056 | const headers = { cookie: `${cookie}; forge_mode=studio` }; | |
| 2057 | const apps = await fetch(`${base}/live/apps`, { headers }); | |
| 2058 | expect(apps.status).toBe(200); | |
| 2059 | const appsText = await apps.text(); | |
| 2060 | expect(appsText).toContain('id="live-apps"'); | |
| 2061 | expect(appsText).toContain(`${repo.name}`); | |
| 2062 | expect(appsText).not.toContain("<html"); | |
| 2063 | const tag = apps.headers.get("etag"); | |
| 2064 | expect(tag).toMatch(/^"[0-9a-f]{16}"$/); | |
| 2065 | const unchanged = await fetch(`${base}/live/apps`, { | |
| 2066 | headers: { ...headers, "if-none-match": tag! }, | |
| 2067 | }); | |
| 2068 | expect(unchanged.status).toBe(304); | |
| 2069 | ||
| 2070 | const onYou = await fetch(`${base}/live/on-you`, { headers }); | |
| 2071 | expect(onYou.status).toBe(200); | |
| 2072 | expect(await onYou.text()).toContain('id="live-on-you"'); | |
| 2073 | ||
| 2074 | // Signed out, the regions are nobody's. | |
| 2075 | expect((await fetch(`${base}/live/apps`)).status).toBe(404); | |
| 2076 | expect((await fetch(`${base}/live/on-you`)).status).toBe(404); | |
| 2077 | }); | |
| 2078 | ||
| 2051 | 2079 | test("a signed-out request gets a signed-out home: no per-viewer live regions, nothing of alice's", async () => { |
| 2052 | 2080 | const res = await fetch(`${base}/`, { |
| 2053 | 2081 | headers: { cookie: "forge_mode=studio" }, |
| @@ -2643,6 +2671,45 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 2643 | 2671 | expect(refusedPage.status).toBe(404); |
| 2644 | 2672 | }); |
| 2645 | 2673 | |
| 2674 | test("Now has an address of its own: the page's region names it, the fragment answers with the same region, and unchanged bytes come back 304", async () => { | |
| 2675 | const page = await (await get("/alice/vault", owner)).text(); | |
| 2676 | expect(page).toContain('id="live-now"'); | |
| 2677 | expect(page).toContain('data-live-src="/alice/vault/app/now"'); | |
| 2678 | ||
| 2679 | const first = await get("/alice/vault/app/now", owner); | |
| 2680 | expect(first.status).toBe(200); | |
| 2681 | expect(first.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 2682 | expect(first.headers.get("cache-control")).toBe("no-store"); | |
| 2683 | const etag = first.headers.get("etag"); | |
| 2684 | expect(etag).toMatch(/^"[0-9a-f]{16}"$/); | |
| 2685 | const text = await first.text(); | |
| 2686 | expect(text).toContain('id="live-now"'); | |
| 2687 | expect(text).toContain('data-live="run.*,pull.*,issue.*"'); | |
| 2688 | expect(text).toContain('data-live-src="/alice/vault/app/now"'); | |
| 2689 | expect(text).toContain("<h2>Now</h2>"); | |
| 2690 | ||
| 2691 | // The same bytes again: nothing to paint, and nothing sent to paint. | |
| 2692 | const again = await fetch(`${base}/alice/vault/app/now`, { | |
| 2693 | headers: { | |
| 2694 | cookie: `${owner}; forge_mode=studio`, | |
| 2695 | "if-none-match": etag!, | |
| 2696 | }, | |
| 2697 | }); | |
| 2698 | expect(again.status).toBe(304); | |
| 2699 | expect(again.headers.get("etag")).toBe(etag); | |
| 2700 | expect(await again.text()).toBe(""); | |
| 2701 | ||
| 2702 | // A stale tag is answered in full. | |
| 2703 | const stale = await fetch(`${base}/alice/vault/app/now`, { | |
| 2704 | headers: { cookie: `${owner}; forge_mode=studio`, "if-none-match": '"0"' }, | |
| 2705 | }); | |
| 2706 | expect(stale.status).toBe(200); | |
| 2707 | expect(await stale.text()).toContain("<h2>Now</h2>"); | |
| 2708 | ||
| 2709 | expect((await get("/alice/vault/app/now", stranger)).status).toBe(404); | |
| 2710 | expect((await fetch(`${base}/alice/vault/app/now`)).status).toBe(404); | |
| 2711 | }); | |
| 2712 | ||
| 2646 | 2713 | test("a preview flips from deploying to live between two calls, and the link stays correct", async () => { |
| 2647 | 2714 | const alice = getUserByName(app.db, "alice")!; |
| 2648 | 2715 | const branch = await commitToBranch(app, alice, repo, { |
new above
offline