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

Calm pass: a repository and its app say their state in one line, with depth in place #10380

Merged opened this in answer to

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

Merged

scout/forge:scout/10299-calm-pass-a-repository-and-its-app-say-t → main 8 commits fork 4 threads · 4 unresolved

  • Merged. scout/10299-calm-pass-a-repository-and-its-app-say-t was merged into main as ab8330d.

Review

Sign in to review this change.
17 files changed +293 −98
docs/design/attention-budget.json +8 −8
@@ -13,25 +13,25 @@
1313 },
1414 "repository": {
1515 "390": {
16 "words": 197,
17 "blocks": 29,
16 "words": 116,
17 "blocks": 18,
1818 "colours": 2
1919 },
2020 "1280": {
21 "words": 197,
22 "blocks": 29,
21 "words": 116,
22 "blocks": 18,
2323 "colours": 2
2424 }
2525 },
2626 "app": {
2727 "390": {
28 "words": 197,
29 "blocks": 29,
28 "words": 116,
29 "blocks": 18,
3030 "colours": 2
3131 },
3232 "1280": {
33 "words": 197,
34 "blocks": 29,
33 "words": 116,
34 "blocks": 18,
3535 "colours": 2
3636 }
3737 },
docs/features/attention.md +6 −2
@@ -22,8 +22,8 @@ reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and
2222 | Page | 390 px | 1280 px |
2323 |---|---|---|
2424 | home | 95 / 20 / 2 | 95 / 20 / 2 |
25 | repository | 189 / 28 / 2 | 189 / 28 / 2 |
26 | app | 189 / 28 / 2 | 189 / 28 / 2 |
25 | repository | 116 / 18 / 2 | 116 / 18 / 2 |
26 | app | 116 / 18 / 2 | 116 / 18 / 2 |
2727 | pull | 133 / 22 / 4 | 133 / 22 / 4 |
2828 | issue | 76 / 19 / 2 | 76 / 19 / 2 |
2929 | issues list | 32 / 8 / 2 | 32 / 8 / 2 |
@@ -31,3 +31,7 @@ reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and
3131 | runs | 46 / 8 / 1 | 46 / 8 / 1 |
3232 | run | 34 / 11 / 3 | 34 / 11 / 3 |
3333 | settings | 137 / 32 / 1 | 137 / 32 / 1 |
34
35 ## App page, calmed (#10299)
36
37 A new app that has not run, and has shipped nothing, shows only its progress: the state line (`docs/features/onboarding-line.md`), the box to tell the crew, and Now (`src/web/studio/fresh.ts` judges it). Past that, the empty This week and Crew cards are gone, and the page's second link row (Map, Issues, Pull requests) is gone because the head's tabs already hold them. Before, on the seeded new app: 197 words / 29 blocks (over its 189 / 28 budget on main); after: 116 / 18.
src/web/studio/app.ts +52 −62
@@ -52,7 +52,6 @@ import {
5252 listCard,
5353 row,
5454 } from "../ui.ts";
55 import { changesHref, tellsHref } from "./nouns.ts";
5655 import { answersRegion } from "./answers.ts";
5756 import { nowCard, nowRegion } from "./now.ts";
5857 import { repoAsks } from "./asks.ts";
@@ -117,9 +116,6 @@ const PREVIEW_LATENCY_MIN = 3;
117116 */
118117 const FAILED_RATIO_MIN = 5;
119118
120 /** The start of history, for an "ever" count rather than one week of it. */
121 const EPOCH = new Date(0).toISOString();
122
123119 /**
124120 * How fast a change has reached its preview this week, worded like the
125121 * reference instant in `servedPhrase` (`web/ui.ts`): "reaches its preview
@@ -276,52 +272,43 @@ export async function studioApp(
276272 ...sealedScope(db, repo, ctx.auth.user),
277273 }),
278274 );
275 // A new app that has not run is told apart from one with a past in
276 // `appPanels` (`fresh.ts`): its page shows only its progress, so the
277 // week's reads below are skipped for it.
278 const { head, previews, deployTailing, fresh, history } = timePhase(req, "panels", () =>
279 appPanels(ctx, repo, record, openPulls),
280 );
279281 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
280282 // What landed this week, counted by the database like the runs and the
281283 // spend below — a page of closed pulls would stop at a hundred and the
282284 // cost per shipped change would read high off the short count.
283 const shipped = timePhase(req, "shipped", () =>
284 mergedPullCount(db, repo.id, week),
285 );
285 const shipped = fresh
286 ? 0
287 : timePhase(req, "shipped", () => mergedPullCount(db, repo.id, week));
286288 // The rest is spend, worked out only for a viewer who is going to see it —
287289 // counted by the database over the whole week, not off a page of rows.
288 const weekTotals = ownerFacts
290 const weekTotals = ownerFacts && !fresh
289291 ? timePhase(req, "spend", () => agentRunTotals(db, repo.id, week))
290292 : { runs: 0, spentUsd: 0 };
291293 const spent = weekTotals.spentUsd;
292 const ledgerWeek = ownerFacts
294 const ledgerWeek = ownerFacts && !fresh
293295 ? timePhase(req, "decisions", () => decisionStats(db, repo.id, week))
294296 : [];
295 const weekLatency = ownerFacts
297 const weekLatency = ownerFacts && !fresh
296298 ? timePhase(req, "latency", () => previewLatency(db, repo.id, week))
297299 : undefined;
298 // Whether there is any history at all — ever, not this week's — so a
299 // fresh app reads as "nothing yet" rather than a stretch of zeros and a
300 // scary ratio (#274). A quiet week on an old app still has shipped a
301 // change or taken a run at some point; only an app with neither, ever,
302 // gets the quieter line. Deploys are not part of this: they happen on
303 // their own the moment an app exists, so they say nothing about whether
304 // anyone has done anything yet.
305 const hasHistory = timePhase(req, "history", () => {
306 const everShipped = shipped > 0 || mergedPullCount(db, repo.id, EPOCH) > 0;
307 const everRuns = ownerFacts
308 ? weekTotals.runs > 0 || agentRunTotals(db, repo.id, EPOCH).runs > 0
309 : false;
310 return everShipped || everRuns;
311 });
312300 const autonomy =
313 !ownerFacts || repo.autonomy === "off"
301 !ownerFacts || fresh || repo.autonomy === "off"
314302 ? undefined
315303 : timePhase(req, "autonomy", () => autonomyStatus(ctx.app, repo));
316304 // The governor's reason, one line on the crew card (#7644).
317 const governor = timePhase(req, "governor", () => governorSeen(ctx.app, ctx.auth.user, repo, new Date()));
305 const governor = fresh
306 ? undefined
307 : timePhase(req, "governor", () => governorSeen(ctx.app, ctx.auth.user, repo, new Date()));
318308
319309 // ---- the app itself ----
320310 // `deployTailing` says the head card is tailing production's own deploy —
321311 // the "Now" panel's empty-state words below care about that distinction.
322 const { head, previews, deployTailing } = timePhase(req, "panels", () =>
323 appPanels(ctx, repo, record, openPulls),
324 );
325312
326313 // ---- the box ----
327314 // Telling the crew, and who takes it, is the owner's own front door — a
@@ -356,6 +343,7 @@ export async function studioApp(
356343 ownerFacts,
357344 wordCtx,
358345 deployTailing,
346 previewLinks: fresh,
359347 }),
360348 );
361349
@@ -363,19 +351,10 @@ export async function studioApp(
363351 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
364352 // write here gets only the shipped count, never the roster, the money or
365353 // the ledger — and the page never asks for the rest on their behalf.
366 const crewCard = !ownerFacts
354 // No crew yet: the tell box and the Now row already say so, once.
355 const crewCard = !ownerFacts || crew.length === 0
367356 ? null
368 : crew.length === 0
369 ? card(
370 html`<p class="muted small">
371 Nobody yet. <a href="${base}/settings">Add someone.</a>
372 </p>`,
373 {
374 head: html`${icon("bot")}
375 <h2>Crew</h2>`,
376 },
377 )
378 : listCard(
357 : listCard(
379358 crew.map((c) =>
380359 row({
381360 lead: avatar(c.agent.user.username),
@@ -394,13 +373,9 @@ export async function studioApp(
394373 ${btn("Change", { href: `${base}/settings`, size: "sm" })}`,
395374 },
396375 );
397 const weekCard = card(
398 !hasHistory
399 ? html`<p class="muted small">
400 Nothing to show yet — it fills in once a pull request
401 ships${ownerFacts ? " or the crew takes a run" : ""}.
402 </p>`
403 : !ownerFacts
376 // No history, no card: an empty week explains itself to nobody.
377 const weekCard = !history ? null : card(
378 !ownerFacts
404379 ? html`<dl>
405380 <dt>Shipped</dt>
406381 <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd>
@@ -475,28 +450,44 @@ export async function studioApp(
475450 );
476451 // The rest of the app's environment — its variables, hostname, access and
477452 // domains — is the admin's, on the app's settings page.
478 const more = html`<p class="muted small">
479 <a href="${base}/map">The map</a> ·
480 <a href="${tellsHref(base)}">Issues</a> ·
481 <a href="${changesHref(base)}">Pull requests</a>${when(
482 canAdmin(db, repo, ctx.auth.user),
483 () => html` · <a href="${base}/settings/app">Environment settings</a>`,
484 )}
485 </p>`;
453 // Issues, Pulls and the map are the head's own tabs; only what it lacks is here.
454 const more = canAdmin(db, repo, ctx.auth.user)
455 ? html`<p class="muted small"><a href="${base}/settings/app">Environment settings</a></p>`
456 : null;
486457
487458 // An app that follows another says so before anything else: whether it
488459 // is in step is the first thing its owner wants to know.
489460 const following = await timePhaseAsync(req, "following", () =>
490461 followingOf(ctx, repo),
491462 );
492 const policy = await timePhaseAsync(req, "policy", () =>
493 policyLine(ctx, repo, record),
494 );
463 const policy = fresh
464 ? null
465 : await timePhaseAsync(req, "policy", () => policyLine(ctx, repo, record));
495466 const adapter = ownerFacts
496467 ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo))
outdated

Mis-indented, and a double blank line follows. Also, 'Environment settings' vanishes on a fresh app for admins: keep it reachable.

497468 : null;
498469 const rendering = performance.now();
499 const body = html`${repoHeader(ctx, repo, "app", {
470 const tellRegion = html`${tell}
471 ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null}`;
472 // A new app that has not run yet shows only its progress: the head's
473 // state line, the box to tell the crew, and Now. Everything else waits
474 // until there is something to show.
475 const body = fresh
476 ? html`${repoHeader(ctx, repo, "app", { compact: true, ownTitle: true })}
477 ${following === undefined
478 ? archivedParentLine(ctx, repo)
479 : followingCard(ctx, repo, following)}
480 <div id="app-head">${head}</div>
481 ${ownerFacts ? crewKeyNote(ctx) : null}
482 <div class="stack">
483 ${adapter}
484 ${tellRegion}
485 <div id="app-previews"></div>
486 ${nowRegion(repo, now)}
487 ${more}
488 </div>
489 ${liveMount({ repo: base, app: true })}`
490 : html`${repoHeader(ctx, repo, "app", {
500491 compact: true,
501492 ownTitle: true,
502493 })}
@@ -518,8 +509,7 @@ export async function studioApp(
518509 : null}
519510 <div class="split" data-rail="wide">
520511 <div class="stack">
521 ${tell}
522 ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null}
512 ${tellRegion}
523513 <div id="app-previews">${previews}</div>
524514 ${nowRegion(repo, now)}
525515 ${notesCard(ctx, repo, record)}
src/web/studio/fresh.ts added +18
@@ -0,0 +1,18 @@
1 /**
2 * Whether an app page shows only its progress (#10299): the app is new and has
3 * not run (`apps/onboarding.ts` has a line for it) and nothing was ever
4 * shipped or taken as a run. Anything else is an app with something to show.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { mergedPullCount } from "../../core/pulls.ts";
9 import { agentRunTotals } from "../../core/runs.ts";
10
11 /** The start of history, for an "ever" count rather than one week of it. */
12 const EPOCH = new Date(0).toISOString();
13
14 /** Whether anything was ever shipped here, or (for a viewer who sees them) a run taken. */
15 export function hasHistory(db: Database, repoId: string, ownerFacts: boolean): boolean {

Principle 1 (nothing says itself twice) applies to code too: this duplicates the hasHistory block in app.ts. Make one the source.

16 if (mergedPullCount(db, repoId, EPOCH) > 0) return true;
17 return ownerFacts && agentRunTotals(db, repoId, EPOCH).runs > 0;
18 }
src/web/studio/now.ts +17 −6
@@ -8,7 +8,7 @@
88 */
99
1010 import { eventRows, mayView, sealedScope } from "../../core/sealed.ts";
11 import { envPull } from "../../apps/data.ts";
11 import { envPull, previewEnv } from "../../apps/data.ts";
1212 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1313 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1414 import { listCrewRoles } from "../../core/agents.ts";
@@ -74,11 +74,14 @@ export async function nowCard(
7474 wordCtx: WordCtx;
7575 /** Whether the head card is tailing production's own deploy. */
7676 deployTailing: boolean;
77 /** A fresh app has no previews list, so each pull's row links its preview itself. */
78 previewLinks?: boolean;
7779 },
7880 ): Promise<{ card: Html }> {
7981 const db = ctx.app.db;
8082 const base = repoBase(repo);
8183 const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o;
84 const previewLinks = o.previewLinks === true && appsRuntime(ctx.app) !== undefined;
8285 // A run on a sealed report, and the report, are for who may see it.
8386 const recent = listRuns(db, repo.id, { kind: "agent", limit: 8 });
8487 const sealed = eventRows(
@@ -266,12 +269,21 @@ export async function nowCard(
266269 { name: p.authorName, isAgent: p.authorIsAgent },
267270 ownerFacts,
268271 );
272 const meta = html`${who} · ${noteLine(note)} · ${time(p.createdAt)}`;
273 if (previewLinks)
274 return row({
275 nav: true,
276 lead: avatar(who),
277 title: html`<a href="${changeHref(base, p.number)}">${p.title}</a>`,
278 meta: html`${meta} · <a href="${base}/app/${previewEnv(p.number)}/open">Preview</a>`,
279 trail: wordBadge(word),
280 });
269281 return row({
270282 href: changeHref(base, p.number),
271283 nav: true,
272284 lead: avatar(who),
273285 title: p.title,
274 meta: html`${who} · ${noteLine(note)} · ${time(p.createdAt)}`,
286 meta,
275287 trail: wordBadge(word),
276288 });
277289 }),
@@ -358,10 +370,8 @@ export async function appNowFragment(ctx: Ctx): Promise<Response> {
358370 );
359371 // The one fact Now borrows from the head card, decided where the head
360372 // card decides it, so the two never disagree about what is tailing.
361 const deployTailing = timePhase(
362 req,
363 "panels",
364 () => appPanels(ctx, repo, record, openPulls).deployTailing,
373 const { deployTailing, fresh } = timePhase(req, "panels", () =>
374 appPanels(ctx, repo, record, openPulls),
365375 );
366376 const rendering = performance.now();
367377 const { card } = await nowCard(ctx, repo, record, {
@@ -377,6 +387,7 @@ export async function appNowFragment(ctx: Ctx): Promise<Response> {
377387 viewer: ctx.auth.user,
378388 },
379389 deployTailing,
390 previewLinks: fresh,
380391 });
381392 const response = fragment(nowRegion(repo, card), { request: req });
382393 notePhase(req, "render", performance.now() - rendering);
src/web/studio/previews.ts +21 −12
@@ -29,6 +29,7 @@ import { testsClause } from "./words.ts";
2929 import { html, when, type Html } from "../html.ts";
3030 import { fragment, missingFragment } from "../layout.ts";
3131 import { onboardingRow } from "../onboarding_row.ts";
32 import { hasHistory } from "./fresh.ts";
3233 import {
3334 badge,
3435 btn,
@@ -286,6 +287,10 @@ export function appPanels(
286287 head: Html;
287288 previews: Html | null;
288289 deployTailing: boolean;
290 /** A new app that has not run yet: its page shows only its progress. */
291 fresh: boolean;
292 /** Anything ever shipped here, or (for a viewer who sees them) a run taken. */
293 history: boolean;
289294 } {
290295 const db = ctx.app.db;
291296 const base = repoBase(repo);
@@ -293,17 +298,20 @@ export function appPanels(
293298 const envs: EnvStatus[] = envsFor(ctx.app, ctx.auth.user, record);
294299 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
295300 const checks = latestRunsForIssues(db, openPulls.map((p) => p.id));
296 const previews = previewsPanel(base, runtime, record, openPulls, envs, writer, checks);
297301 const production = envs.find((e) => e.env === "production");
302 // Until production has a deploy of its own to speak of, a new app's facts
303 // row says where it stands instead (`apps/onboarding.ts`).
304 const onboarding = production?.runId === undefined ? appOnboardingLine(ctx.app, repo, ctx.auth.user) : undefined;
305 const history = hasHistory(db, repo.id, writer);
306 const fresh = onboarding !== undefined && !history;
307 // A fresh app's pull is already the Now row; a second list would say it twice.
308 const previews = fresh ? null : previewsPanel(base, runtime, record, openPulls, envs, writer, checks);
298309 // The forge's own source shows the forge as its production; the app is its test copy.
299310 const { url, copy } = shownProduction(ctx.app, repo, runtime?.urlFor(record, "production"));
300311 const tail = productionTail(ctx, repo, production, writer);
301312 // The state in one word, and under it the facts — why it is not up, and
302313 // the deploy whose log says the rest.
303314 const live = envPhrase(production);
304 // Until production has a deploy of its own to speak of, a new app's facts
305 // row says where it stands instead (`apps/onboarding.ts`).
306 const onboarding = production?.runId === undefined ? appOnboardingLine(ctx.app, repo, ctx.auth.user) : undefined;
307315 const head = html`<div class="page-head">
308316 <div class="stack stack-tight">
309317 <h1 class="bar">
@@ -315,22 +323,21 @@ export function appPanels(
315323 : { tone: live.tone },
316324 )}
outdated

Empty description now renders an empty muted paragraph. Wrap it in when(repo.description, ...).

317325 </h1>
318 <p class="muted">
319 ${repo.description || "No description yet."}
326 ${when(repo.description || url !== undefined || copy !== undefined, () => html`<p class="muted">
327 ${when(repo.description, (d) => html`${d}`)}
320328 ${url === undefined
321329 ? null
322330 : copy !== undefined || production?.state === "running"
323 ? html`·
324 <a href="${base}/app/production/open" class="mono"
331 ? html`${repo.description ? "· " : ""}<a href="${base}/app/production/open" class="mono"
325332 >${url.replace(/^https?:\/\//, "")}</a
326333 >`
327 : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`}
328 ${when(copy, (c) => html`· ${testCopyLink(c)}`)}
329 </p>
334 : html`${repo.description ? "· " : ""}<span class="mono">${url.replace(/^https?:\/\//, "")}</span>`}
335 ${when(copy, (c) => html`${repo.description || url !== undefined ? "· " : ""}${testCopyLink(c)}`)}
336 </p>`)}
330337 ${onboarding === undefined
331338 ? when(production, (env) => envFacts(base, env))
332339 : onboardingRow(onboarding)}
333 ${when(writer, () =>
340 ${when(writer && !fresh, () =>
334341 terminalLine(
335342 terminalFor(ctx.app, record, "production", production),
336343 ctx.app.config.sshHostKey,
@@ -350,6 +357,8 @@ export function appPanels(
350357 head,
351358 previews,
352359 deployTailing: tail.deploying,
360 fresh,
361 history,
353362 };
354363 }
355364
test/app-egress-language.test.ts +2
@@ -19,6 +19,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1919 import { egressNote } from "../src/web/studio/egress.ts";
2020 import { seedAdmin } from "./helpers/admin.ts";
2121 import { quietLogs } from "./helpers/globals.ts";
22 import { giveHistory } from "./helpers/history.ts";
2223
2324 quietLogs();
2425
@@ -39,6 +40,7 @@ beforeAll(async () => {
3940 repo = r.value;
4041 const made = createAppRecord(app.db, { repoId: repo.id, slug: "notes", access: "public" });
4142 if (!made.ok) throw new Error(made.error);
43 giveHistory(app.db, repo.id);
4244 setCapacity(app.db, "cells.egress", "report", a.value.id);
4345 noteReached(dataDir, repo.id, "api.example.com", 443);
4446 noteReached(dataDir, repo.id, "api.example.com", 443);
test/apps.test.ts +3
@@ -10,6 +10,7 @@
1010 * `apps-new`. The forge they share is `helpers/apps-forge.ts`.
1111 */
1212
13 import { getRepo } from "../src/core/repos.ts";
1314 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1415 import { existsSync, readFileSync, writeFileSync } from "node:fs";
1516 import { mkdtemp, rm } from "node:fs/promises";
@@ -59,6 +60,7 @@ import {
5960 stopAppsForge,
6061 type AppsForge,
6162 } from "./helpers/apps-forge.ts";
63 import { giveHistory } from "./helpers/history.ts";
6264
6365 describe("apps: production, previews, data, gates", () => {
6466 let forge: AppsForge;
@@ -631,6 +633,7 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch
631633 visibility: "public",
632634 });
633635 if (!made.ok) throw new Error(String(made.error));
636 giveHistory(terminal.app.db, getRepo(terminal.app.db, "alice", "beacon")!.id);
634637 terminalCookie = await signedInCookie(terminal.servers);
635638 })(),
636639 (async () => {
test/asks-mute.test.ts +2
@@ -27,6 +27,7 @@ import { mcpTool } from "./helpers/mcp.ts";
2727 import { askCountFor, askLinesFor, forgeNeedsFor, repoAsks } from "../src/web/studio/asks.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
2929 import { listAt } from "./mcp-list.ts";
30 import { giveHistory } from "./helpers/history.ts";
3031
3132 quietLogs();
3233 // Every test here is HTTP round trips against a server on a shared machine: the 5s default measures its load, not the code.
@@ -169,6 +170,7 @@ describe("muting a repository's asks", () => {
169170 expect(repoAsks(app.db, alice, repo)).toEqual({ count: 1, muted: true });
170171 // The repository's page is the app's once it is one.
171172 expect(createAppRecord(app.db, { repoId: repo.id, slug: "muted", access: "public" }).ok).toBe(true);
173 giveHistory(app.db, repo.id);
172174 const page = await fetch(`${servers.url}/alice/muted`, { headers: headers(cookie) });
173175 expect(page.status).toBe(200);
174176 const text = await page.text();
test/audit.test.ts +2
@@ -25,6 +25,7 @@ import { getRepo } from "../src/core/repos.ts";
2525 import { openPull } from "../src/actions/pulls.ts";
2626 import { createUser, type User } from "../src/core/users.ts";
2727 import { serve, type Servers } from "../src/http/server.ts";
28 import { giveHistory } from "./helpers/history.ts";
2829
2930 /** A forge of its own with alice signed in, for a test that changes what it reads. */
3031 async function boot(prefix: string) {
@@ -66,6 +67,7 @@ async function seedApps(app: App, alice: User) {
6667 visibility: "public",
6768 });
6869 if (!notes.ok) throw new Error(String(notes.error));
70 giveHistory(app.db, getRepo(app.db, "alice", "notes")!.id);
6971
7072 // An app whose contract does not hold.
7173 const broken = await createRepo(app, alice, {
test/fresh-app.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * A new app that has not run shows only its progress (#10299): its state line,
3 * the box to tell the crew, and Now. Once it is older, or has shipped, the
4 * rail and the previews list return; the empty cards never do.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { enableApp } from "../src/actions/apps.ts";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { proveAll } from "./helpers/verified.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { ageApp } from "./helpers/history.ts";
20
21 quietLogs();
22
23 let root = "";
24 let app: App;
25 let servers: Servers;
26 let runtime: AppsRuntime;
27 let cookie = "";
28 let repoId = "";
29
30 const read = async (as: string = cookie): Promise<string> =>
31 (await fetch(`${servers.url}/alice/vault`, { headers: as ? { cookie: as } : {} })).text();
32
33 const signin = async (username: string): Promise<string> => {
34 const r = await fetch(`${servers.url}/signin`, {
35 method: "POST", headers: { origin: servers.url },
36 body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual",
37 });
38 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
39 };
40 let bobCookie = "";
41
42 beforeAll(async () => {
43 root = mkdtempSync(join(tmpdir(), "forge-fresh-"));
44 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
45 const u = await createUser(app.db, {
46 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
47 password: "a-good-passphrase", adminIfFirst: true,
48 });
49 if (!u.ok) throw new Error(u.error);
50 proveAll(app);
51 const bob = await createUser(app.db, {
52 origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase",
53 });
54 if (!bob.ok) throw new Error(bob.error);
55 const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name: "vault", visibility: "public" });
56 if (!made.ok) throw new Error(String(made.error));
57 repoId = made.value.id;
58 for (const [branch, path] of [["main", "README.md"], ["feature/x", "x.ts"]] as const) {
59 const c = await commitToBranch(app, u.value, made.value, {
60 message: "Start", branch, files: [{ path, content: "v\n" }],
61 } as never);
62 if (!c.ok) throw new Error(JSON.stringify(c.error));
63 }
64 const enabled = await enableApp(app, u.value, made.value, { slug: "vault" });
65 if (!enabled.ok) throw new Error(String(enabled.error));
66 const pull = await openPull(app, u.value, made.value, { title: "Add x", baseBranch: "main", headBranch: "feature/x" });
67 if (!pull.ok) throw new Error(String(pull.error));
68 servers = await serve(app);
69 runtime = new AppsRuntime(app, {
70 domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1,
71 });
72 cookie = await signin("alice");
73 bobCookie = await signin("bob");
74 });
75
76 afterAll(async () => {
77 await runtime.stop();
78 servers.stop();
79 app.close();
80 rmSync(root, { recursive: true, force: true });
81 });
82
83 test("a new app that has not run shows its progress, the box and Now, and nothing else", async () => {
84 const page = await read();
85 expect(page).toContain("The crew is making it run");
86 expect(page).toContain("Tell the crew");
87 expect(page).toContain('id="live-now"');
88 for (const gone of ["<aside", "<h2>Previews</h2>", "<h2>This week</h2>", "<h2>Crew</h2>", "No description yet"])
89 expect(page).not.toContain(gone);
90 // No description, no address yet: no empty paragraph under the name.
91 expect(page).not.toMatch(/<p class="muted">\s*<\/p>/);
92 });
93
94 test("the owner still reaches the environment settings on a fresh app", async () => {
95 const page = await read();
96 expect(page).toContain("Environment settings");
97 });
98
99 test("the Now row links the pull's preview, since a fresh app has no previews list", async () => {
100 const page = await read();
101 expect(page).toContain('href="/alice/vault/app/pull-1/open"');
102 expect(page).toContain('href="/alice/vault/pulls/1"');
103 });
104
105 test("a guest and a non-owner see the progress, with no owner facts and no terminal line", async () => {
106 for (const who of ["", bobCookie]) {
107 const page = await read(who);
108 expect(page).toContain('id="live-now"');
109 for (const gone of ["The terminal is off", "ssh ", "action=\"/alice/vault/tell\"", "Environment settings", "<aside", "Spent", "<h2>Crew</h2>"])
110 expect(page).not.toContain(gone);
111 }
112 });
113
114 test("an older app keeps its rail and previews, and still has no empty cards", async () => {
115 ageApp(app.db, repoId);
116 const page = await read();
117 expect(page).toContain("<aside");
118 expect(page).toContain("<h2>Previews</h2>");
119 expect(page).toContain("Environment settings");
120 expect(page).not.toContain("<h2>This week</h2>");
121 expect(page).not.toContain("<h2>Crew</h2>");
122 expect(page).not.toContain("The map</a>");
123 });
test/helpers/history.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * Give an app a history (#10299): a new app that has not run shows only its
3 * progress, so a test of the full page first has the crew take one run there.
4 * The run is finished, so it is history and not work in flight.
5 */
6 import type { Database } from "bun:sqlite";
7 import { createRun, finishRun } from "../../src/core/runs.ts";
8
9 export function giveHistory(db: Database, repoId: string): void {
10 const run = createRun(db, { repoId, kind: "agent", reason: "history" });
11 finishRun(db, run.id, { state: "ok", summary: "done" });
12 }
13
14 /** Make an app older than the window that counts as new, so its page is the full one. */
15 export function ageApp(db: Database, repoId: string): void {
16 db.run("UPDATE app SET created_at = '2026-01-02T00:00:00.000Z' WHERE repo_id = ?1", [repoId]);
17 }
test/onboarding-line.test.ts +2 −1
@@ -17,6 +17,7 @@ import {
1717 onboardingLine,
1818 ONBOARDING_WINDOW_MS,
1919 } from "../src/apps/onboarding.ts";
20 import { ageApp } from "./helpers/history.ts";
2021
2122 const OID = (c: string) => c.repeat(40);
2223
@@ -133,7 +134,7 @@ describe("only a new app", () => {
133134 test("an app created before this shipped has no line and is never looked at", () => {
134135 const f = fixture();
135136 openPull(f);
136 f.db.run("UPDATE app SET created_at = '2026-01-02T00:00:00.000Z' WHERE repo_id = 'r1'");
137 ageApp(f.db, "r1");
137138 const old = { ...f, record: { ...f.record, createdAt: "2026-01-02T00:00:00.000Z" } };
138139 const facts = onboardingFacts(old.db, old.repo, old.record, "/owner/exact", old.user, true);
139140 expect(onboardingLine(facts)).toBeUndefined();
test/studio-app-timing.test.ts +4 −1
@@ -10,6 +10,7 @@ import { createUser } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
1111 import { proveAll } from "./helpers/verified.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
13 import { ageApp, giveHistory } from "./helpers/history.ts";
1314
1415 quietLogs();
1516
@@ -39,6 +40,9 @@ beforeAll(async () => {
3940 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
4041 const enabled = await enableApp(app, u.value, made.value, { slug: "vault" });
4142 if (!enabled.ok) throw new Error(String(enabled.error));
43 // A new app shows only its progress (#10299); this budget is the full page.
44 ageApp(app.db, made.value.id);
45 giveHistory(app.db, made.value.id);
4246 servers = await serve(app);
4347 });
4448 afterAll(() => {
@@ -69,7 +73,6 @@ test("GET /:owner/:name/app names its phases", async () => {
6973 "spend",
7074 "decisions",
7175 "latency",
72 "history",
7376 "panels",
7477 "now",
7578 "following",
test/studio-door.test.ts +3
@@ -51,6 +51,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
5151 import { serve, type Servers } from "../src/http/server.ts";
5252 import { promote } from "./helpers/members.ts";
5353 import { withScripts } from "./helpers/page-script.ts";
54 import { ageApp } from "./helpers/history.ts";
5455
5556 let dataDir: string;
5657 let app: App;
@@ -430,6 +431,8 @@ describe("every open pull's preview on the studio page", () => {
430431 const enabled = await enableApp(app, alice, repo, { slug: "widget" });
431432 if (!enabled.ok) throw new Error(String(enabled.error));
432433 record = getAppByRepo(app.db, repo.id)!;
434 // An older app: a new one shows only its progress, without the list (#10299).
435 ageApp(app.db, repo.id);
433436 });
434437
435438 test("an open pull's preview host and open link render; none do once nothing is open", async () => {
test/studio-shipping.test.ts +11 −6
@@ -45,6 +45,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
4545 import { serve, type Servers } from "../src/http/server.ts";
4646 import { NOBODY_ON_CREW } from "../src/web/studio/words.ts";
4747 import { servedPhrase } from "../src/web/ui.ts";
48 import { ageApp } from "./helpers/history.ts";
4849
4950 describe("a stranger sees the app, not the owner's books", () => {
5051 let dataDir: string;
@@ -340,6 +341,8 @@ describe("a stranger sees the app, not the owner's books", () => {
340341 });
341342 expect(on.ok).toBe(true);
342343
344 // An older app: a new one shows only its progress (#10299).
345 ageApp(app.db, lamp.id);
343346 const empty = await (await get("/alice/lamp", ownerCookie)).text();
344347 expect(empty).toContain("Autonomous");
345348 expect(empty).toContain("nobody on the crew");
@@ -1060,10 +1063,10 @@ describe("the studio's crew panel on a forge with no agents yet", () => {
10601063 expect(refused.status).toBe(404);
10611064 });
10621065
1063 test("the app page's 'Add someone' leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => {
1066 test("the app page's way to add a crew member leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => {
10641067 const appPage = await (await get("/alice/pad", ownerCookie)).text();
1065 expect(appPage).toContain("Nobody yet");
1066 expect(appPage).toContain('href="/alice/pad/settings">Add someone.');
1068 expect(appPage).toContain("Nobody on the crew yet");
1069 expect(appPage).toContain('href="/alice/pad/settings#crew"');
10671070
10681071 const settings = await (
10691072 await get("/alice/pad/settings", ownerCookie)
@@ -2120,8 +2123,10 @@ describe("a one-minute-old app's front door does not lead with a week of zeros (
21202123
21212124 test("the day-one page a person lands on from New app says one true line, not a week of zeros", async () => {
21222125 const page = await (await get("/alice/newborn")).text();
2123 expect(page).toContain("This week");
2124 expect(page).toContain("Nothing to show yet");
2126 // A new app shows only its progress: no week, no rail (#10299).
2127 expect(page).not.toContain("This week");
2128 expect(page).not.toContain("Nothing to show yet");
2129 expect(page).toContain("Tell the crew");
21252130 // Not a single zero from the old panel, and not the scary ratio: the
21262131 // forge's own cancelled boot is never counted, and two deploys is well
21272132 // under the threshold a ratio needs anyway.
@@ -2133,7 +2138,7 @@ describe("a one-minute-old app's front door does not lead with a week of zeros (
21332138
21342139 test("a stranger sees the same quiet line, not the owner's zeros", async () => {
21352140 const page = await (await fetch(`${base}/alice/newborn`)).text();
2136 expect(page).toContain("Nothing to show yet");
2141 expect(page).not.toContain("Nothing to show yet");
21372142 expect(page).not.toMatch(/Shipped<\/dt>\s*<dd>0 pull requests<\/dd>/);
21382143 });
21392144 });
test/studio.test.ts +2
@@ -38,6 +38,7 @@ import {
3838 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3939 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
4040 import { serve, type Servers } from "../src/http/server.ts";
41 import { giveHistory } from "./helpers/history.ts";
4142
4243 describe("the studio", () => {
4344 let dataDir: string;
@@ -118,6 +119,7 @@ describe("the studio", () => {
118119 const scout = await createAgent(app, alice, { username: "scout" });
119120 if (!scout.ok) throw new Error(String(scout.error));
120121 repo = await makeApp("notes", "a place for notes");
122 giveHistory(app.db, repo.id);
121123 }, 60_000);
122124
123125 afterAll(async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs