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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10299-calm-pass-a-repository-and-its-app-say-t → main 8 commits fork 4 threads · 4 unresolved
17 files
changed +293
−98
docs/design/attention-budget.json +8 −8
| @@ -13,25 +13,25 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | "words": 197, | |
| 17 | "blocks": 29, | |
| 16 | "words": 116, | |
| 17 | "blocks": 18, | |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | "words": 197, | |
| 22 | "blocks": 29, | |
| 21 | "words": 116, | |
| 22 | "blocks": 18, | |
| 23 | 23 | "colours": 2 |
| 24 | 24 | } |
| 25 | 25 | }, |
| 26 | 26 | "app": { |
| 27 | 27 | "390": { |
| 28 | "words": 197, | |
| 29 | "blocks": 29, | |
| 28 | "words": 116, | |
| 29 | "blocks": 18, | |
| 30 | 30 | "colours": 2 |
| 31 | 31 | }, |
| 32 | 32 | "1280": { |
| 33 | "words": 197, | |
| 34 | "blocks": 29, | |
| 33 | "words": 116, | |
| 34 | "blocks": 18, | |
| 35 | 35 | "colours": 2 |
| 36 | 36 | } |
| 37 | 37 | }, |
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 | ||
| 22 | 22 | | Page | 390 px | 1280 px | |
| 23 | 23 | |---|---|---| |
| 24 | 24 | | 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 | | |
| 27 | 27 | | pull | 133 / 22 / 4 | 133 / 22 / 4 | |
| 28 | 28 | | issue | 76 / 19 / 2 | 76 / 19 / 2 | |
| 29 | 29 | | 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 | ||
| 31 | 31 | | runs | 46 / 8 / 1 | 46 / 8 / 1 | |
| 32 | 32 | | run | 34 / 11 / 3 | 34 / 11 / 3 | |
| 33 | 33 | | 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 { | ||
| 52 | 52 | listCard, |
| 53 | 53 | row, |
| 54 | 54 | } from "../ui.ts"; |
| 55 | import { changesHref, tellsHref } from "./nouns.ts"; | |
| 56 | 55 | import { answersRegion } from "./answers.ts"; |
| 57 | 56 | import { nowCard, nowRegion } from "./now.ts"; |
| 58 | 57 | import { repoAsks } from "./asks.ts"; |
| @@ -117,9 +116,6 @@ const PREVIEW_LATENCY_MIN = 3; | ||
| 117 | 116 | */ |
| 118 | 117 | const FAILED_RATIO_MIN = 5; |
| 119 | 118 | |
| 120 | /** The start of history, for an "ever" count rather than one week of it. */ | |
| 121 | const EPOCH = new Date(0).toISOString(); | |
| 122 | ||
| 123 | 119 | /** |
| 124 | 120 | * How fast a change has reached its preview this week, worded like the |
| 125 | 121 | * reference instant in `servedPhrase` (`web/ui.ts`): "reaches its preview |
| @@ -276,52 +272,43 @@ export async function studioApp( | ||
| 276 | 272 | ...sealedScope(db, repo, ctx.auth.user), |
| 277 | 273 | }), |
| 278 | 274 | ); |
| 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 | ); | |
| 279 | 281 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); |
| 280 | 282 | // What landed this week, counted by the database like the runs and the |
| 281 | 283 | // spend below — a page of closed pulls would stop at a hundred and the |
| 282 | 284 | // 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)); | |
| 286 | 288 | // The rest is spend, worked out only for a viewer who is going to see it — |
| 287 | 289 | // counted by the database over the whole week, not off a page of rows. |
| 288 | const weekTotals = ownerFacts | |
| 290 | const weekTotals = ownerFacts && !fresh | |
| 289 | 291 | ? timePhase(req, "spend", () => agentRunTotals(db, repo.id, week)) |
| 290 | 292 | : { runs: 0, spentUsd: 0 }; |
| 291 | 293 | const spent = weekTotals.spentUsd; |
| 292 | const ledgerWeek = ownerFacts | |
| 294 | const ledgerWeek = ownerFacts && !fresh | |
| 293 | 295 | ? timePhase(req, "decisions", () => decisionStats(db, repo.id, week)) |
| 294 | 296 | : []; |
| 295 | const weekLatency = ownerFacts | |
| 297 | const weekLatency = ownerFacts && !fresh | |
| 296 | 298 | ? timePhase(req, "latency", () => previewLatency(db, repo.id, week)) |
| 297 | 299 | : 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 | }); | |
| 312 | 300 | const autonomy = |
| 313 | !ownerFacts || repo.autonomy === "off" | |
| 301 | !ownerFacts || fresh || repo.autonomy === "off" | |
| 314 | 302 | ? undefined |
| 315 | 303 | : timePhase(req, "autonomy", () => autonomyStatus(ctx.app, repo)); |
| 316 | 304 | // 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())); | |
| 318 | 308 | |
| 319 | 309 | // ---- the app itself ---- |
| 320 | 310 | // `deployTailing` says the head card is tailing production's own deploy — |
| 321 | 311 | // 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 | ); | |
| 325 | 312 | |
| 326 | 313 | // ---- the box ---- |
| 327 | 314 | // Telling the crew, and who takes it, is the owner's own front door — a |
| @@ -356,6 +343,7 @@ export async function studioApp( | ||
| 356 | 343 | ownerFacts, |
| 357 | 344 | wordCtx, |
| 358 | 345 | deployTailing, |
| 346 | previewLinks: fresh, | |
| 359 | 347 | }), |
| 360 | 348 | ); |
| 361 | 349 | |
| @@ -363,19 +351,10 @@ export async function studioApp( | ||
| 363 | 351 | // Crew, spend and autonomy are the owner's facts; a viewer who cannot |
| 364 | 352 | // write here gets only the shipped count, never the roster, the money or |
| 365 | 353 | // 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 | |
| 367 | 356 | ? 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( | |
| 379 | 358 | crew.map((c) => |
| 380 | 359 | row({ |
| 381 | 360 | lead: avatar(c.agent.user.username), |
| @@ -394,13 +373,9 @@ export async function studioApp( | ||
| 394 | 373 | ${btn("Change", { href: `${base}/settings`, size: "sm" })}`, |
| 395 | 374 | }, |
| 396 | 375 | ); |
| 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 | |
| 404 | 379 | ? html`<dl> |
| 405 | 380 | <dt>Shipped</dt> |
| 406 | 381 | <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd> |
| @@ -475,28 +450,44 @@ export async function studioApp( | ||
| 475 | 450 | ); |
| 476 | 451 | // The rest of the app's environment — its variables, hostname, access and |
| 477 | 452 | // 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; | |
| 486 | 457 | |
| 487 | 458 | // An app that follows another says so before anything else: whether it |
| 488 | 459 | // is in step is the first thing its owner wants to know. |
| 489 | 460 | const following = await timePhaseAsync(req, "following", () => |
| 490 | 461 | followingOf(ctx, repo), |
| 491 | 462 | ); |
| 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)); | |
| 495 | 466 | const adapter = ownerFacts |
| 496 | 467 | ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo)) |
| 497 | 468 | : null; |
| 498 | 469 | 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", { | |
| 500 | 491 | compact: true, |
| 501 | 492 | ownTitle: true, |
| 502 | 493 | })} |
| @@ -518,8 +509,7 @@ export async function studioApp( | ||
| 518 | 509 | : null} |
| 519 | 510 | <div class="split" data-rail="wide"> |
| 520 | 511 | <div class="stack"> |
| 521 | ${tell} | |
| 522 | ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null} | |
| 512 | ${tellRegion} | |
| 523 | 513 | <div id="app-previews">${previews}</div> |
| 524 | 514 | ${nowRegion(repo, now)} |
| 525 | 515 | ${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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { eventRows, mayView, sealedScope } from "../../core/sealed.ts"; |
| 11 | import { envPull } from "../../apps/data.ts"; | |
| 11 | import { envPull, previewEnv } from "../../apps/data.ts"; | |
| 12 | 12 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; |
| 13 | 13 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 14 | 14 | import { listCrewRoles } from "../../core/agents.ts"; |
| @@ -74,11 +74,14 @@ export async function nowCard( | ||
| 74 | 74 | wordCtx: WordCtx; |
| 75 | 75 | /** Whether the head card is tailing production's own deploy. */ |
| 76 | 76 | deployTailing: boolean; |
| 77 | /** A fresh app has no previews list, so each pull's row links its preview itself. */ | |
| 78 | previewLinks?: boolean; | |
| 77 | 79 | }, |
| 78 | 80 | ): Promise<{ card: Html }> { |
| 79 | 81 | const db = ctx.app.db; |
| 80 | 82 | const base = repoBase(repo); |
| 81 | 83 | const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o; |
| 84 | const previewLinks = o.previewLinks === true && appsRuntime(ctx.app) !== undefined; | |
| 82 | 85 | // A run on a sealed report, and the report, are for who may see it. |
| 83 | 86 | const recent = listRuns(db, repo.id, { kind: "agent", limit: 8 }); |
| 84 | 87 | const sealed = eventRows( |
| @@ -266,12 +269,21 @@ export async function nowCard( | ||
| 266 | 269 | { name: p.authorName, isAgent: p.authorIsAgent }, |
| 267 | 270 | ownerFacts, |
| 268 | 271 | ); |
| 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 | }); | |
| 269 | 281 | return row({ |
| 270 | 282 | href: changeHref(base, p.number), |
| 271 | 283 | nav: true, |
| 272 | 284 | lead: avatar(who), |
| 273 | 285 | title: p.title, |
| 274 | meta: html`${who} · ${noteLine(note)} · ${time(p.createdAt)}`, | |
| 286 | meta, | |
| 275 | 287 | trail: wordBadge(word), |
| 276 | 288 | }); |
| 277 | 289 | }), |
| @@ -358,10 +370,8 @@ export async function appNowFragment(ctx: Ctx): Promise<Response> { | ||
| 358 | 370 | ); |
| 359 | 371 | // The one fact Now borrows from the head card, decided where the head |
| 360 | 372 | // 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), | |
| 365 | 375 | ); |
| 366 | 376 | const rendering = performance.now(); |
| 367 | 377 | const { card } = await nowCard(ctx, repo, record, { |
| @@ -377,6 +387,7 @@ export async function appNowFragment(ctx: Ctx): Promise<Response> { | ||
| 377 | 387 | viewer: ctx.auth.user, |
| 378 | 388 | }, |
| 379 | 389 | deployTailing, |
| 390 | previewLinks: fresh, | |
| 380 | 391 | }); |
| 381 | 392 | const response = fragment(nowRegion(repo, card), { request: req }); |
| 382 | 393 | notePhase(req, "render", performance.now() - rendering); |
src/web/studio/previews.ts +21 −12
| @@ -29,6 +29,7 @@ import { testsClause } from "./words.ts"; | ||
| 29 | 29 | import { html, when, type Html } from "../html.ts"; |
| 30 | 30 | import { fragment, missingFragment } from "../layout.ts"; |
| 31 | 31 | import { onboardingRow } from "../onboarding_row.ts"; |
| 32 | import { hasHistory } from "./fresh.ts"; | |
| 32 | 33 | import { |
| 33 | 34 | badge, |
| 34 | 35 | btn, |
| @@ -286,6 +287,10 @@ export function appPanels( | ||
| 286 | 287 | head: Html; |
| 287 | 288 | previews: Html | null; |
| 288 | 289 | 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; | |
| 289 | 294 | } { |
| 290 | 295 | const db = ctx.app.db; |
| 291 | 296 | const base = repoBase(repo); |
| @@ -293,17 +298,20 @@ export function appPanels( | ||
| 293 | 298 | const envs: EnvStatus[] = envsFor(ctx.app, ctx.auth.user, record); |
| 294 | 299 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); |
| 295 | 300 | const checks = latestRunsForIssues(db, openPulls.map((p) => p.id)); |
| 296 | const previews = previewsPanel(base, runtime, record, openPulls, envs, writer, checks); | |
| 297 | 301 | 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); | |
| 298 | 309 | // The forge's own source shows the forge as its production; the app is its test copy. |
| 299 | 310 | const { url, copy } = shownProduction(ctx.app, repo, runtime?.urlFor(record, "production")); |
| 300 | 311 | const tail = productionTail(ctx, repo, production, writer); |
| 301 | 312 | // The state in one word, and under it the facts — why it is not up, and |
| 302 | 313 | // the deploy whose log says the rest. |
| 303 | 314 | 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; | |
| 307 | 315 | const head = html`<div class="page-head"> |
| 308 | 316 | <div class="stack stack-tight"> |
| 309 | 317 | <h1 class="bar"> |
| @@ -315,22 +323,21 @@ export function appPanels( | ||
| 315 | 323 | : { tone: live.tone }, |
| 316 | 324 | )} |
outdated
Empty description now renders an empty muted paragraph. Wrap it in when(repo.description, ...). |
||
| 317 | 325 | </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}`)} | |
| 320 | 328 | ${url === undefined |
| 321 | 329 | ? null |
| 322 | 330 | : 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" | |
| 325 | 332 | >${url.replace(/^https?:\/\//, "")}</a |
| 326 | 333 | >` |
| 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>`)} | |
| 330 | 337 | ${onboarding === undefined |
| 331 | 338 | ? when(production, (env) => envFacts(base, env)) |
| 332 | 339 | : onboardingRow(onboarding)} |
| 333 | ${when(writer, () => | |
| 340 | ${when(writer && !fresh, () => | |
| 334 | 341 | terminalLine( |
| 335 | 342 | terminalFor(ctx.app, record, "production", production), |
| 336 | 343 | ctx.app.config.sshHostKey, |
| @@ -350,6 +357,8 @@ export function appPanels( | ||
| 350 | 357 | head, |
| 351 | 358 | previews, |
| 352 | 359 | deployTailing: tail.deploying, |
| 360 | fresh, | |
| 361 | history, | |
| 353 | 362 | }; |
| 354 | 363 | } |
| 355 | 364 |
test/app-egress-language.test.ts +2
| @@ -19,6 +19,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 19 | 19 | import { egressNote } from "../src/web/studio/egress.ts"; |
| 20 | 20 | import { seedAdmin } from "./helpers/admin.ts"; |
| 21 | 21 | import { quietLogs } from "./helpers/globals.ts"; |
| 22 | import { giveHistory } from "./helpers/history.ts"; | |
| 22 | 23 | |
| 23 | 24 | quietLogs(); |
| 24 | 25 | |
| @@ -39,6 +40,7 @@ beforeAll(async () => { | ||
| 39 | 40 | repo = r.value; |
| 40 | 41 | const made = createAppRecord(app.db, { repoId: repo.id, slug: "notes", access: "public" }); |
| 41 | 42 | if (!made.ok) throw new Error(made.error); |
| 43 | giveHistory(app.db, repo.id); | |
| 42 | 44 | setCapacity(app.db, "cells.egress", "report", a.value.id); |
| 43 | 45 | noteReached(dataDir, repo.id, "api.example.com", 443); |
| 44 | 46 | noteReached(dataDir, repo.id, "api.example.com", 443); |
test/apps.test.ts +3
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * `apps-new`. The forge they share is `helpers/apps-forge.ts`. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | 14 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 14 | 15 | import { existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 15 | 16 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -59,6 +60,7 @@ import { | ||
| 59 | 60 | stopAppsForge, |
| 60 | 61 | type AppsForge, |
| 61 | 62 | } from "./helpers/apps-forge.ts"; |
| 63 | import { giveHistory } from "./helpers/history.ts"; | |
| 62 | 64 | |
| 63 | 65 | describe("apps: production, previews, data, gates", () => { |
| 64 | 66 | let forge: AppsForge; |
| @@ -631,6 +633,7 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch | ||
| 631 | 633 | visibility: "public", |
| 632 | 634 | }); |
| 633 | 635 | if (!made.ok) throw new Error(String(made.error)); |
| 636 | giveHistory(terminal.app.db, getRepo(terminal.app.db, "alice", "beacon")!.id); | |
| 634 | 637 | terminalCookie = await signedInCookie(terminal.servers); |
| 635 | 638 | })(), |
| 636 | 639 | (async () => { |
test/asks-mute.test.ts +2
| @@ -27,6 +27,7 @@ import { mcpTool } from "./helpers/mcp.ts"; | ||
| 27 | 27 | import { askCountFor, askLinesFor, forgeNeedsFor, repoAsks } from "../src/web/studio/asks.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | 29 | import { listAt } from "./mcp-list.ts"; |
| 30 | import { giveHistory } from "./helpers/history.ts"; | |
| 30 | 31 | |
| 31 | 32 | quietLogs(); |
| 32 | 33 | // 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", () => { | ||
| 169 | 170 | expect(repoAsks(app.db, alice, repo)).toEqual({ count: 1, muted: true }); |
| 170 | 171 | // The repository's page is the app's once it is one. |
| 171 | 172 | expect(createAppRecord(app.db, { repoId: repo.id, slug: "muted", access: "public" }).ok).toBe(true); |
| 173 | giveHistory(app.db, repo.id); | |
| 172 | 174 | const page = await fetch(`${servers.url}/alice/muted`, { headers: headers(cookie) }); |
| 173 | 175 | expect(page.status).toBe(200); |
| 174 | 176 | const text = await page.text(); |
test/audit.test.ts +2
| @@ -25,6 +25,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 25 | 25 | import { openPull } from "../src/actions/pulls.ts"; |
| 26 | 26 | import { createUser, type User } from "../src/core/users.ts"; |
| 27 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | import { giveHistory } from "./helpers/history.ts"; | |
| 28 | 29 | |
| 29 | 30 | /** A forge of its own with alice signed in, for a test that changes what it reads. */ |
| 30 | 31 | async function boot(prefix: string) { |
| @@ -66,6 +67,7 @@ async function seedApps(app: App, alice: User) { | ||
| 66 | 67 | visibility: "public", |
| 67 | 68 | }); |
| 68 | 69 | if (!notes.ok) throw new Error(String(notes.error)); |
| 70 | giveHistory(app.db, getRepo(app.db, "alice", "notes")!.id); | |
| 69 | 71 | |
| 70 | 72 | // An app whose contract does not hold. |
| 71 | 73 | 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 { | ||
| 17 | 17 | onboardingLine, |
| 18 | 18 | ONBOARDING_WINDOW_MS, |
| 19 | 19 | } from "../src/apps/onboarding.ts"; |
| 20 | import { ageApp } from "./helpers/history.ts"; | |
| 20 | 21 | |
| 21 | 22 | const OID = (c: string) => c.repeat(40); |
| 22 | 23 | |
| @@ -133,7 +134,7 @@ describe("only a new app", () => { | ||
| 133 | 134 | test("an app created before this shipped has no line and is never looked at", () => { |
| 134 | 135 | const f = fixture(); |
| 135 | 136 | 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"); | |
| 137 | 138 | const old = { ...f, record: { ...f.record, createdAt: "2026-01-02T00:00:00.000Z" } }; |
| 138 | 139 | const facts = onboardingFacts(old.db, old.repo, old.record, "/owner/exact", old.user, true); |
| 139 | 140 | expect(onboardingLine(facts)).toBeUndefined(); |
test/studio-app-timing.test.ts +4 −1
| @@ -10,6 +10,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 11 | import { proveAll } from "./helpers/verified.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | import { ageApp, giveHistory } from "./helpers/history.ts"; | |
| 13 | 14 | |
| 14 | 15 | quietLogs(); |
| 15 | 16 | |
| @@ -39,6 +40,9 @@ beforeAll(async () => { | ||
| 39 | 40 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 40 | 41 | const enabled = await enableApp(app, u.value, made.value, { slug: "vault" }); |
| 41 | 42 | 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); | |
| 42 | 46 | servers = await serve(app); |
| 43 | 47 | }); |
| 44 | 48 | afterAll(() => { |
| @@ -69,7 +73,6 @@ test("GET /:owner/:name/app names its phases", async () => { | ||
| 69 | 73 | "spend", |
| 70 | 74 | "decisions", |
| 71 | 75 | "latency", |
| 72 | "history", | |
| 73 | 76 | "panels", |
| 74 | 77 | "now", |
| 75 | 78 | "following", |
test/studio-door.test.ts +3
| @@ -51,6 +51,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts"; | ||
| 51 | 51 | import { serve, type Servers } from "../src/http/server.ts"; |
| 52 | 52 | import { promote } from "./helpers/members.ts"; |
| 53 | 53 | import { withScripts } from "./helpers/page-script.ts"; |
| 54 | import { ageApp } from "./helpers/history.ts"; | |
| 54 | 55 | |
| 55 | 56 | let dataDir: string; |
| 56 | 57 | let app: App; |
| @@ -430,6 +431,8 @@ describe("every open pull's preview on the studio page", () => { | ||
| 430 | 431 | const enabled = await enableApp(app, alice, repo, { slug: "widget" }); |
| 431 | 432 | if (!enabled.ok) throw new Error(String(enabled.error)); |
| 432 | 433 | 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); | |
| 433 | 436 | }); |
| 434 | 437 | |
| 435 | 438 | 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"; | ||
| 45 | 45 | import { serve, type Servers } from "../src/http/server.ts"; |
| 46 | 46 | import { NOBODY_ON_CREW } from "../src/web/studio/words.ts"; |
| 47 | 47 | import { servedPhrase } from "../src/web/ui.ts"; |
| 48 | import { ageApp } from "./helpers/history.ts"; | |
| 48 | 49 | |
| 49 | 50 | describe("a stranger sees the app, not the owner's books", () => { |
| 50 | 51 | let dataDir: string; |
| @@ -340,6 +341,8 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 340 | 341 | }); |
| 341 | 342 | expect(on.ok).toBe(true); |
| 342 | 343 | |
| 344 | // An older app: a new one shows only its progress (#10299). | |
| 345 | ageApp(app.db, lamp.id); | |
| 343 | 346 | const empty = await (await get("/alice/lamp", ownerCookie)).text(); |
| 344 | 347 | expect(empty).toContain("Autonomous"); |
| 345 | 348 | expect(empty).toContain("nobody on the crew"); |
| @@ -1060,10 +1063,10 @@ describe("the studio's crew panel on a forge with no agents yet", () => { | ||
| 1060 | 1063 | expect(refused.status).toBe(404); |
| 1061 | 1064 | }); |
| 1062 | 1065 | |
| 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 () => { | |
| 1064 | 1067 | 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"'); | |
| 1067 | 1070 | |
| 1068 | 1071 | const settings = await ( |
| 1069 | 1072 | 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 ( | ||
| 2120 | 2123 | |
| 2121 | 2124 | test("the day-one page a person lands on from New app says one true line, not a week of zeros", async () => { |
| 2122 | 2125 | 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"); | |
| 2125 | 2130 | // Not a single zero from the old panel, and not the scary ratio: the |
| 2126 | 2131 | // forge's own cancelled boot is never counted, and two deploys is well |
| 2127 | 2132 | // 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 ( | ||
| 2133 | 2138 | |
| 2134 | 2139 | test("a stranger sees the same quiet line, not the owner's zeros", async () => { |
| 2135 | 2140 | 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"); | |
| 2137 | 2142 | expect(page).not.toMatch(/Shipped<\/dt>\s*<dd>0 pull requests<\/dd>/); |
| 2138 | 2143 | }); |
| 2139 | 2144 | }); |
test/studio.test.ts +2
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 39 | 39 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; |
| 40 | 40 | import { serve, type Servers } from "../src/http/server.ts"; |
| 41 | import { giveHistory } from "./helpers/history.ts"; | |
| 41 | 42 | |
| 42 | 43 | describe("the studio", () => { |
| 43 | 44 | let dataDir: string; |
| @@ -118,6 +119,7 @@ describe("the studio", () => { | ||
| 118 | 119 | const scout = await createAgent(app, alice, { username: "scout" }); |
| 119 | 120 | if (!scout.ok) throw new Error(String(scout.error)); |
| 120 | 121 | repo = await makeApp("notes", "a place for notes"); |
| 122 | giveHistory(app.db, repo.id); | |
| 121 | 123 | }, 60_000); |
| 122 | 124 | |
| 123 | 125 | afterAll(async () => { |
Mis-indented, and a double blank line follows. Also, 'Environment settings' vanishes on a fresh app for admins: keep it reachable.