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

Live regions have addresses: fetch the region, not the page; 304 when unchanged #322

Merged opened this

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

Merged

realtime-fragments → main 2 commits

  • Merged. realtime-fragments was merged into main as 6c9db0e.

Review

Sign in to review this change.
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
304304 carrying `?live=1` rather than a form, and **New app** beside them.
305305 `?mode=dev` on the same URL is dev's table of repositories, unchanged.
306306
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.
316341
317342 Signed out, `/` in the studio is a stranger's front door, not the dev
318343 pitch: a shelf of the public apps actually serving — name, one line of
src/checks/bun-test.ts +3 −1
@@ -7,7 +7,9 @@
77
88 import type { CheckDetail } from "../core/runs.ts";
99
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\])?$/;
1113 const PASS_COUNT = /^\s*(\d+) pass$/;
1214 const FAIL_COUNT = /^\s*(\d+) fail$/;
1315
src/http/server.ts +9
@@ -104,6 +104,8 @@ import {
104104 takeBackSubmit,
105105 tellSubmit,
106106 } from "../web/studio/actions.ts";
107 import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts";
108 import { appNowFragment } from "../web/studio/now.ts";
107109 import { appStateFragment } from "../web/studio/previews.ts";
108110 import { changesList, tellsList } from "../web/studio/lists.ts";
109111 import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts";
@@ -425,6 +427,11 @@ export function buildRouter(): Router<Handler> {
425427 router.add("GET", "/migrate/jobs/:job", migrationJobPage);
426428 // Every app on one canvas; a literal route, so it must sit above /:owner.
427429 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);
428435 router.add("GET", "/runs/:id", runRedirect);
429436 router.add("GET", "/settings", accountPage);
430437 router.add("POST", "/settings", accountUpdate);
@@ -681,6 +688,8 @@ export function buildRouter(): Router<Handler> {
681688 // The studio page's own live refresh: the head card and previews list,
682689 // bare, fetched on a deploy run event for the app being viewed.
683690 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);
684693 router.add("GET", "/:owner/:name/app/:env/open", appOpen);
685694 // Where a private app sends a person who arrived by its link.
686695 router.add("GET", "/apps/open", appsOpen);
src/web/client.ts +72 −30
@@ -2031,20 +2031,60 @@ export const liveRegionsJs = /* js */ `
20312031 var timer = null;
20322032 var last = 0;
20332033 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 })
20452076 .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
20482088 // page carries a fresh nonce the live document's CSP cannot match —
20492089 // parsing it would log an inline-style violation for nothing.
20502090 var fresh = new DOMParser().parseFromString(text.replace(/<(style)[^>]*>[\\s\\S]*?<\\/\\1>/gi, ''), 'text/html');
@@ -2054,26 +2094,28 @@ export const liveRegionsJs = /* js */ `
20542094 var theirSprite = fresh.querySelector('.sprite');
20552095 var mySprite = document.querySelector('.sprite');
20562096 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);
20742098 })
20752099 .catch(function () {});
20762100 }
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 }
20772119 var source = new EventSource('/events');
20782120 // The stream's own state, for a page that shows whether it is connected.
20792121 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";
1313 import type { Ctx } from "../http/context.ts";
1414 import { edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts";
1515 import { json } from "../http/respond.ts";
16 import { sha256 } from "../kernel/hash.ts";
1617 import { secret } from "../kernel/id.ts";
1718 import {
1819 metaThemeColor,
@@ -639,19 +640,32 @@ export function redirect(
639640 * Its icons are inlined rather than referenced: a fragment cannot count on
640641 * the sprite of the document it is being dropped into, and must not add ids
641642 * 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.
642649 */
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 });
655669 }
656670
657671 /** 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> {
229229 if (!entries.ok) return notFoundPage(ctx);
230230
231231 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 });
233235
234236 const [refs, explorer] = await Promise.all([
235237 refSetFrom(scope.source),
@@ -266,7 +268,8 @@ export async function browseBlob(ctx: Ctx): Promise<Response> {
266268 blob: blob.value,
267269 view: blobView(ctx.url.searchParams.get("view")),
268270 });
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 });
270273
271274 const explorer = await loadExplorer(scope.source, ref, path, true);
272275 return codePage(ctx, scope, {
src/web/pages/edit.ts +5 −3
@@ -979,13 +979,15 @@ export async function deleteFileSubmit(ctx: Ctx): Promise<Response> {
979979 */
980980 export async function previewSubmit(ctx: Ctx): Promise<Response> {
981981 const repo = loadRepo(ctx);
982 if (!repo) return fragment(html``, 404);
982 if (!repo) return fragment(html``, { status: 404 });
983983 const form = await readForm(ctx);
984984 if (form instanceof Response) return form;
985985 const scope = localScope(ctx, repo);
986 if (!scope.caps.write) return fragment(html``, 404);
986 if (!scope.caps.write) return fragment(html``, { status: 404 });
987987 const body = formValue(form, "body");
988988 const path = formValue(form, "path");
989989 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 });
991993 }
src/web/pages/repo.ts +5 −2
@@ -1021,7 +1021,9 @@ export async function treePage(ctx: Ctx): Promise<Response> {
10211021
10221022 // The explorer's lazy expansion asks for exactly one directory.
10231023 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 });
10251027
10261028 const [refs, explorer] = await Promise.all([
10271029 refSetFrom(scope.source),
@@ -1063,7 +1065,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
10631065
10641066 // The preview a script drops into the pane beside the explorer. Same
10651067 // 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 });
10671070
10681071 const explorer = await loadExplorer(scope.source, ref, path, true);
10691072 const filename = path.split("/").pop() ?? path;
src/web/studio/app.ts +14 −257
@@ -12,26 +12,16 @@ import { appsRuntime } from "../../apps/runtime.ts";
1212 import { listCrewRoles } from "../../core/agents.ts";
1313 import type { AppRecord } from "../../core/apps.ts";
1414 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";
2116 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2217 import {
2318 agentRunTotals,
24 latestRunsForIssues,
25 listRuns,
2619 previewLatency,
2720 type PreviewLatency,
28 type Run,
2921 } from "../../core/runs.ts";
30 import { getUsersByIds } from "../../core/users.ts";
3122 import { autonomyStatus } from "../../crew/autonomy.ts";
3223 import { repoDir } from "../../git/repos.ts";
3324 import type { Ctx } from "../../http/context.ts";
34 import { readRunLogTail } from "../../runs/log.ts";
3525 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
3626 import { appTailJs, liveRegionsJs } from "../client.ts";
3727 import { html, when, type Html } from "../html.ts";
@@ -47,19 +37,10 @@ import {
4737 time,
4838 userTag,
4939 } 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";
5242 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";
6344
6445 /**
6546 * The week's decisions in one line: how many, how many the decider took
@@ -219,17 +200,6 @@ export async function studioApp(
219200 };
220201 const crew = listCrewRoles(db, repo.id);
221202 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 }
233203 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
234204 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
235205 // What landed this week, counted by the database like the runs and the
@@ -313,222 +283,15 @@ export async function studioApp(
313283 </div>
314284 </form>`;
315285
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,
323294 });
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 });
532295
533296 // ---- the rail ----
534297 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
@@ -659,13 +422,7 @@ export async function studioApp(
659422 <div class="dash-main stack">
660423 ${tell}
661424 <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)}
669426 </div>
670427 <aside class="dash-rail stack">
671428 ${crewCard} ${weekCard} ${ownCard} ${more}
@@ -682,6 +439,6 @@ export async function studioApp(
682439 // The no-script half of the tail: while the crew's run goes, the page
683440 // refreshes itself, the way the run's own page does; the script above
684441 // removes this the moment it can stream lines instead.
685 ...(activeRun || tailing ? { refresh: { seconds: 5 } } : {}),
442 ...(running || tailing ? { refresh: { seconds: 5 } } : {}),
686443 });
687444 }
src/web/studio/home.ts +81 −41
@@ -7,14 +7,14 @@
77 * apps grow.
88 */
99
10 import { appsRuntime } from "../../apps/runtime.ts";
1110 import { crewForRepos } from "../../core/agents.ts";
11 import type { Repo } from "../../core/repos.ts";
1212 import { type User } from "../../core/users.ts";
1313 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
1414 import type { Ctx } from "../../http/context.ts";
1515 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";
1818 import { liveClientJs } from "../live.ts";
1919 import { btn, empty, icon, listCard, row } from "../ui.ts";
2020 import { onYouCard } from "../pages/site.ts";
@@ -28,23 +28,91 @@ import { servingHistory } from "./words.ts";
2828 * what a person put on you. Built from the same rows and cards as the rest
2929 * of the forge, so it reads as the forge.
3030 */
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);
3849 // Read once, shown twice again: a shipped or failed deploy is a row's own
3950 // word, and — when nothing is running and no change waits — the rail's.
4051 const productionFacts = productionFactsForRepos(ctx, appRepos);
4152 // 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.
4354 const history = servingHistory();
4455 const appRows = await shelfRows(ctx, appRepos, {
4556 productionFacts,
4657 history,
4758 });
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);
48116
49117 // What the crew cost today across every app on the shelf, and how many
50118 // 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> {
75143 </p>`;
76144 });
77145
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
96146 const body = html`<h1 class="sr-only">Studio</h1>
97147 ${costLine}
98148 <div class="dash">
99149 <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)}
106151 </div>
107152 <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)}
114154 </aside>
115155 </div>`;
116156 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(
306306 */
307307 export function appStateFragment(ctx: Ctx): Response {
308308 const repo = loadRepo(ctx);
309 if (!repo) return fragment(html``, 404);
309 if (!repo) return fragment(html``, { status: 404 });
310310 const record = getAppByRepo(ctx.app.db, repo.id);
311 if (!record) return fragment(html``, 404);
311 if (!record) return fragment(html``, { status: 404 });
312312 const openPulls = listPulls(ctx.app.db, repo.id, {
313313 state: "open",
314314 limit: 10,
315315 });
316316 const { head, previews } = appPanels(ctx, repo, record, openPulls);
317317 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 });
319319 }
test/checks.test.ts +16
@@ -65,6 +65,22 @@ describe("parseBunTestFailure: a convenience over one known runner, not a genera
6565 ]);
6666 });
6767
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
6884 test("output that is not bun test's own shape is not guessed at", () => {
6985 expect(
7086 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", () => {
20482048 expect(home).toContain("Write the README");
20492049 });
20502050
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
20512079 test("a signed-out request gets a signed-out home: no per-viewer live regions, nothing of alice's", async () => {
20522080 const res = await fetch(`${base}/`, {
20532081 headers: { cookie: "forge_mode=studio" },
@@ -2643,6 +2671,45 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
26432671 expect(refusedPage.status).toBe(404);
26442672 });
26452673
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
26462713 test("a preview flips from deploying to live between two calls, and the link stays correct", async () => {
26472714 const alice = getUserByName(app.db, "alice")!;
26482715 const branch = await commitToBranch(app, alice, repo, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs