Public: anyone can read this; the crew can change it.
A new or migrated app tells its owner where it stands, live, until it runs #10311
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10295-a-new-or-migrated-app-tells-its-owner-wh → main 6 commits fork 8 threads · 8 unresolved
9 files
changed +777
−3
docs/features/onboarding-line.md added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | # Where a new app stands | |
| 2 | ||
| 3 | A new or migrated app says where it stands until production first runs (#10295). | |
| 4 | ||
| 5 | ## What it says | |
| 6 | ||
| 7 | - **Imported.** The crew has not started yet. | |
| 8 | - **Making it run.** The crew has a pull open. The owner reads the agent's name; everyone else reads "the crew". | |
| 9 | - **Check failed.** The owner and the crew read the failing line and a link to the run's log. Anyone else reads only "A check failed." | |
| 10 | - **Retrying.** The crew is at it again after a failed check. Same rule for the failing line. | |
| 11 | - **Preview up.** The pull's preview answers. | |
| 12 | - **Landed.** The pull landed and production is starting. | |
| 13 | - **Stopped.** The pull closed without landing. It needs the owner. | |
| 14 | - **Live.** Production answered. The page says this itself; the owner is told once. | |
| 15 | ||
| 16 | ## Which apps | |
| 17 | ||
| 18 | Only apps created since this shipped, and only for 14 days after they were made. An app that went live stops being looked at a day later. Older apps never show the line and never notify. | |
| 19 | ||
| 20 | ## Where it shows | |
| 21 | ||
| 22 | - **App page.** The head card's facts row. While production has no deploy of its own, the line takes that row's place. Once production has one, the row says what production is doing, as for any app. It swaps with the head when a run or pull event arrives, and only while the row is there. | |
| 23 | - **Terminal.** The first line under the Deploys screen's head. | |
| 24 | - **Notifications.** The owner is told once when the app goes live, and once when its pull closes without landing. A failed check or a retry never notifies. | |
| 25 | ||
| 26 | ## How it works | |
| 27 | ||
| 28 | `src/apps/onboarding.ts` judges the line from the pulls and runs the forge keeps. Pulls are read through `sealedScope`, and the failing line through `mayReadRunLog`, so a viewer learns nothing the repository withholds. | |
| 29 | ||
| 30 | Two things are kept in memory: | |
| 31 | ||
| 32 | - When an app's production first served. It never changes once set. | |
| 33 | - The notifier's last judgment per app. A run or pull event on the app clears it, so a clock tick reads no pulls or runs. |
src/apps/onboarding.ts added +262
| @@ -0,0 +1,262 @@ | ||
| 1 | /** | |
No TUI screen and no docs/features/ how-to: the terminal cannot show this line (both doors). |
||
| 2 | * Where a new or migrated app stands, in one line, until production runs | |
| 3 | * (#10295). The states are listed in `docs/features/onboarding-line.md`. | |
| 4 | * The line is judged from the pulls and runs the forge already keeps; a | |
| 5 | * failure carries the failing line and its log for a viewer who may read | |
| 6 | * run logs, and only "a check failed" for anyone else. | |
| 7 | * | |
| 8 | * Only a new app has a line: one created since this shipped | |
| 9 | * (`ONBOARDING_SINCE`) and in the last `ONBOARDING_WINDOW_MS`. An older app | |
| 10 | * never shows one and never notifies. | |
| 11 | * | |
| 12 | * Plain data in, plain data out: `onboardingLine` judges facts, and | |
| 13 | * `onboardingFacts` reads them. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { Database } from "bun:sqlite"; | |
| 17 | import { mayReadRunLog } from "../actions/runs.ts"; | |
| 18 | import type { App } from "../app.ts"; | |
| 19 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; | |
| 20 | import { listPulls, type Pull } from "../core/pulls.ts"; | |
| 21 | import { canSeeOwnerFacts, getRepoById, type Repo } from "../core/repos.ts"; | |
| 22 | import { listRuns, type Run } from "../core/runs.ts"; | |
| 23 | import { sealedScope } from "../core/sealed.ts"; | |
| 24 | import type { User } from "../core/users.ts"; | |
| 25 | import { failingLine } from "../crew/red_approved.ts"; | |
| 26 | ||
| 27 | export type OnboardingState = | |
| 28 | | "imported" | |
| 29 | | "making" | |
| 30 | | "failed" | |
| 31 | | "retrying" | |
| 32 | | "preview" | |
| 33 | | "deploying" | |
| 34 | | "stopped" | |
| 35 | | "live"; | |
| 36 | ||
| 37 | export interface OnboardingLine { | |
| 38 | readonly state: OnboardingState; | |
| 39 | /** The sentence, ending in a full stop. */ | |
| 40 | readonly text: string; | |
| 41 | /** Where its link goes, relative to the forge; with `linkLabel`. */ | |
| 42 | readonly href?: string; | |
| 43 | readonly linkLabel?: string; | |
| 44 | } | |
| 45 | ||
| 46 | /** How long "live" is still said after production first answered. */ | |
| 47 | export const LIVE_SAID_MS = 24 * 3_600_000; | |
| 48 | ||
| 49 | /** Apps created before this never get a line: they were not new when it shipped. */ | |
| 50 | export const ONBOARDING_SINCE = "2026-11-09T00:00:00.000Z"; | |
| 51 | ||
| 52 | /** How long after it was created an app that never ran still carries its line. */ | |
| 53 | export const ONBOARDING_WINDOW_MS = 14 * 24 * 3_600_000; | |
| 54 | ||
| 55 | /** Whether an app created at `createdAt` is new enough to have a line at `now`. */ | |
| 56 | export function isOnboarding(createdAt: string, now: number): boolean { | |
| 57 | return createdAt >= ONBOARDING_SINCE && now - Date.parse(createdAt) <= ONBOARDING_WINDOW_MS; | |
| 58 | } | |
| 59 | ||
| 60 | export interface OnboardingFacts { | |
| 61 | readonly base: string; | |
| 62 | /** When the app was created. */ | |
| 63 | readonly createdAt: string; | |
| 64 | /** Newest first. */ | |
| 65 | readonly pulls: readonly Pick<Pull, "number" | "state" | "mergedAt" | "authorName" | "authorIsAgent" | "id">[]; | |
| 66 | /** Runs about the newest pull, newest first. */ | |
| 67 | readonly runs: readonly Run[]; | |
| 68 | /** When production first served, if it has. */ | |
| 69 | readonly liveAt: string | null; | |
| 70 | readonly now: number; | |
| 71 | /** Whether the viewer reads the crew by name (the owner does; a visitor reads "the crew"). */ | |
| 72 | readonly named: boolean; | |
| 73 | /** Whether the viewer may read run logs, and so the failing line and its link. */ | |
| 74 | readonly logs: boolean; | |
| 75 | } | |
| 76 | ||
| 77 | const isCheck = (r: Run): boolean => r.kind === "deploy" || r.kind === "check"; | |
| 78 | const active = (r: Run): boolean => r.state === "queued" || r.state === "running"; | |
| 79 | ||
| 80 | const LOG_LABEL = "Failing run log"; | |
| 81 | ||
| 82 | /** What a failed check says: its failing line and log for those who may read logs. */ | |
| 83 | function failure(f: OnboardingFacts, state: "failed" | "retrying", run: Run): OnboardingLine { | |
| 84 | const lead = state === "failed" ? "Check failed" : "Retrying after a failed check"; | |
| 85 | if (!f.logs) return { state, text: state === "failed" ? "A check failed." : "Retrying after a failed check." }; | |
| 86 | return { state, text: `${lead}: ${failingLine(run)}`, href: `${f.base}/runs/${run.id}`, linkLabel: LOG_LABEL }; | |
| 87 | } | |
| 88 | ||
| 89 | export function onboardingLine(f: OnboardingFacts): OnboardingLine | undefined { | |
| 90 | if (!isOnboarding(f.createdAt, f.now)) return undefined; | |
| 91 | if (f.liveAt !== null) { | |
| 92 | if (f.now - Date.parse(f.liveAt) > LIVE_SAID_MS) return undefined; | |
| 93 | return { state: "live", text: "Live.", href: `${f.base}/app`, linkLabel: "The app" }; | |
| 94 | } | |
| 95 | const pull = f.pulls.find((p) => p.state === "open") ?? f.pulls.find((p) => p.mergedAt !== null); | |
| 96 | if (pull === undefined) { | |
| 97 | const closed = f.pulls[0]; | |
| 98 | if (closed !== undefined) | |
| 99 | return { | |
| 100 | state: "stopped", | |
| 101 | text: `The crew stopped: pull #${closed.number} closed without landing. It needs you.`, | |
| 102 | href: `${f.base}/pulls/${closed.number}`, | |
| 103 | linkLabel: `Pull #${closed.number}`, | |
| 104 | }; | |
| 105 | return { state: "imported", text: "Imported. The crew has not started making it run yet." }; | |
| 106 | } | |
| 107 | const by = pull.authorIsAgent ? ` by ${f.named ? pull.authorName : "the crew"}` : ""; | |
| 108 | if (pull.state !== "open") | |
| 109 | return { | |
| 110 | state: "deploying", | |
| 111 | text: `Pull #${pull.number}${by} landed. Production is starting.`, | |
| 112 | href: `${f.base}/pulls/${pull.number}`, | |
| 113 | linkLabel: `Pull #${pull.number}`, | |
| 114 | }; | |
| 115 | const checks = f.runs.filter(isCheck); | |
| 116 | const latest = checks[0]; | |
| 117 | const failedBefore = checks.find((r) => r.state === "failed"); | |
| 118 | const crewBusy = f.runs.some((r) => r.kind === "agent" && active(r)); | |
| 119 | // A preview that answered after the last failure ends the retrying. | |
| 120 | const servedAt = checks.findIndex((r) => r.kind === "deploy" && r.servingAt !== null); | |
| 121 | const failedAt = checks.findIndex((r) => r.state === "failed"); | |
| 122 | if (servedAt >= 0 && (failedAt < 0 || servedAt < failedAt)) | |
| 123 | return { state: "preview", text: `Preview up for pull #${pull.number}.` }; | |
| 124 | if (latest?.state === "failed") return failure(f, crewBusy ? "retrying" : "failed", latest); | |
| 125 | if (latest !== undefined && active(latest) && failedBefore !== undefined) return failure(f, "retrying", failedBefore); | |
| 126 | return { | |
| 127 | state: "making", | |
| 128 | // No link: the open pull is listed, linked, right below on the app page. | |
| 129 | text: `The crew is making it run (pull #${pull.number}${by}).`, | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * When production first served, per app. Once set it never changes, so it is | |
| 135 | * read once; an app that has not served is asked again (it may yet). | |
| 136 | */ | |
| 137 | const liveAtCache = new WeakMap<Database, Map<string, string>>(); | |
| 138 | ||
| 139 | function firstServedAt(db: Database, repoId: string): string | null { | |
| 140 | const seen = liveAtCache.get(db) ?? new Map<string, string>(); | |
| 141 | liveAtCache.set(db, seen); | |
| 142 | const known = seen.get(repoId); | |
| 143 | if (known !== undefined) return known; | |
| 144 | const at = | |
| 145 | db | |
| 146 | .query<{ at: string | null }, [string]>( | |
| 147 | `SELECT MIN(serving_at) AS at FROM run WHERE repo_id = ?1 AND kind = 'deploy' AND env = 'production' AND serving_at IS NOT NULL`, | |
| 148 | ) | |
| 149 | .get(repoId)?.at ?? null; | |
| 150 | if (at !== null) seen.set(repoId, at); | |
| 151 | return at; | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * The facts for one app, as the viewer may see its pulls. An app that is | |
| 156 | * not new, or has served production for longer than `LIVE_SAID_MS`, has | |
| 157 | * nothing to say, so its pulls and runs are not read. `logs` is whether the | |
| 158 | * viewer may read run logs (`mayReadRunLog`). | |
| 159 | */ | |
| 160 | export function onboardingFacts( | |
| 161 | db: Database, | |
| 162 | repo: Repo, | |
| 163 | app: AppRecord, | |
| 164 | base: string, | |
| 165 | viewer: User | undefined, | |
| 166 | logs: boolean, | |
| 167 | now = Date.now(), | |
| 168 | ): OnboardingFacts { | |
| 169 | const named = canSeeOwnerFacts(db, repo, viewer); | |
| 170 | const quiet = { base, createdAt: app.createdAt, pulls: [], runs: [], now, named, logs }; | |
| 171 | if (!isOnboarding(app.createdAt, now)) return { ...quiet, liveAt: null }; | |
| 172 | const liveAt = firstServedAt(db, app.repoId); | |
| 173 | if (liveAt !== null && now - Date.parse(liveAt) > LIVE_SAID_MS) return { ...quiet, liveAt }; | |
| 174 | const pulls = listPulls(db, repo.id, { state: "all", limit: 10, ...sealedScope(db, repo, viewer) }); | |
| 175 | const newest = pulls.find((p) => p.state === "open") ?? pulls.find((p) => p.mergedAt !== null); | |
| 176 | const runs = newest === undefined ? [] : listRuns(db, repo.id, { issueId: newest.id, limit: 30 }); | |
| 177 | return { ...quiet, pulls, runs, liveAt }; | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * The line for one app as the viewer may read it, until production runs: | |
| 182 | * the app page's head and the terminal both call this. Once live, the head | |
| 183 | * says so itself, so "live" is the notifier's alone. | |
| 184 | */ | |
| 185 | export function appOnboardingLine(app: App, repo: Repo, viewer: User | undefined): OnboardingLine | undefined { | |
| 186 | const record = getAppByRepo(app.db, repo.id); | |
| 187 | if (record === undefined || !isOnboarding(record.createdAt, Date.now())) return undefined; | |
| 188 | const logs = mayReadRunLog(app, viewer, repo); | |
| 189 | const line = onboardingLine(onboardingFacts(app.db, repo, record, `/${repo.ownerName}/${repo.name}`, viewer, logs)); | |
| 190 | return line?.state === "live" ? undefined : line; | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * The owner's apps the notifier looks at: new ones (`isOnboarding`) that | |
| 195 | * have not served production for longer than `LIVE_SAID_MS`. One query; | |
| 196 | * every other app is never read. | |
| 197 | */ | |
| 198 | export function onboardingCandidates(db: Database, user: User, now = Date.now()): string[] { | |
| 199 | const since = new Date(Math.max(Date.parse(ONBOARDING_SINCE), now - ONBOARDING_WINDOW_MS)).toISOString(); | |
| 200 | return db | |
| 201 | .query<{ repo_id: string }, [string, string, string]>( | |
| 202 | `SELECT a.repo_id FROM app a JOIN repo r ON r.id = a.repo_id | |
| 203 | WHERE r.owner_id = ?1 AND r.archived_at IS NULL AND a.created_at >= ?2 | |
| 204 | AND NOT EXISTS (SELECT 1 FROM run d WHERE d.repo_id = a.repo_id AND d.kind = 'deploy' | |
| 205 | AND d.env = 'production' AND d.serving_at IS NOT NULL AND d.serving_at < ?3)`, | |
| 206 | ) | |
| 207 | .all(user.id, since, new Date(now - LIVE_SAID_MS).toISOString()) | |
| 208 | .map((r) => r.repo_id); | |
| 209 | } | |
| 210 | ||
| 211 | export interface OnboardedNotice { | |
| 212 | readonly line: string; | |
| 213 | readonly items: readonly string[]; | |
| 214 | readonly link: string; | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * What the notifier last judged per app, kept until a run or pull event on | |
| 219 | * that app (`forgetOnboarding`), so a clock tick reads no pulls or runs. | |
| 220 | * `null` is "nothing to tell". | |
| 221 | */ | |
| 222 | const noticeCache = new WeakMap<Database, Map<string, OnboardedNotice | null>>(); | |
| 223 | ||
| 224 | /** A run or pull moved on this repository: judge its notice afresh next time. */ | |
| 225 | export function forgetOnboarding(db: Database, repoId: string): void { | |
| 226 | noticeCache.get(db)?.delete(repoId); | |
| 227 | } | |
| 228 | ||
| 229 | function onboardedNotice(db: Database, user: User, repoId: string, now: number): OnboardedNotice | null { | |
| 230 | const repo = getRepoById(db, repoId); | |
| 231 | const record = getAppByRepo(db, repoId); | |
| 232 | if (repo === undefined || record === undefined) return null; | |
| 233 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 234 | // The owner may read every log; the notice never quotes one anyway. | |
| 235 | const line = onboardingLine(onboardingFacts(db, repo, record, base, user, true, now)); | |
| 236 | if (line?.state === "live") return { line: `${repo.name} is live.`, items: [`onboarded:${repoId}:live`], link: `${base}/app` }; | |
| 237 | if (line?.state === "stopped") | |
| 238 | return { line: `${repo.name} needs you: ${line.text}`, items: [`onboarded:${repoId}:stopped`], link: line.href ?? `${base}/app` }; | |
| 239 | return null; | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * What the owner is told, once per app: it went live, or it truly needs | |
| 244 | * them (the crew's pull closed without landing). A failed check or a retry | |
| 245 | * is never one: the id is the app and the outcome, so every retry in | |
| 246 | * between is the same silence. | |
| 247 | */ | |
| 248 | export function onboardedLines(db: Database, user: User, now = Date.now()): OnboardedNotice[] { | |
| 249 | if (user.kind !== "user") return []; | |
| 250 | const cache = noticeCache.get(db) ?? new Map<string, OnboardedNotice | null>(); | |
| 251 | noticeCache.set(db, cache); | |
| 252 | const out: OnboardedNotice[] = []; | |
| 253 | for (const repoId of onboardingCandidates(db, user, now)) { | |
| 254 | let notice = cache.get(repoId); | |
| 255 | if (notice === undefined) { | |
| 256 | notice = onboardedNotice(db, user, repoId, now); | |
| 257 | cache.set(repoId, notice); | |
| 258 | } | |
| 259 | if (notice !== null) out.push(notice); | |
| 260 | } | |
| 261 | return out; | |
| 262 | } |
src/crew/notify.ts +7
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | |
| 23 | 23 | import { newAccountLines } from "../actions/accounts.ts"; |
| 24 | 24 | import { mailCeilingLines } from "../actions/mail.ts"; |
| 25 | import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts"; | |
| 25 | 26 | import type { Database } from "bun:sqlite"; |
| 26 | 27 | import { outboundFetch } from "../kernel/outbound.ts"; |
| 27 | 28 | import type { App } from "../app.ts"; |
| @@ -61,10 +62,13 @@ const BACKOFF_MAX_MS = HOUR_MS; | ||
| 61 | 62 | const WAKES = new Set([ |
| 62 | 63 | "clog.started", |
| 63 | 64 | "run.finished", |
| 65 | "app.serving", | |
| 64 | 66 | "pull.opened", |
| 65 | 67 | "pull.pushed", |
| 66 | 68 | "pull.reviewed", |
| 67 | 69 | "pull.merged", |
| 70 | // A new app's crew pull closed without landing: it needs its owner (`apps/onboarding.ts`). | |
| 71 | "pull.closed", | |
| 68 | 72 | "issue.closed", |
| 69 | 73 | "issue.commented", |
| 70 | 74 | "repo.settings_updated", |
| @@ -500,6 +504,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 500 | 504 | ...redLines(app, user, now()), |
| 501 | 505 | ...alarmLines(app, user, now(), options.alarms?.()), |
outdated
This runs a per-app query set for every user on every tick. Skip apps already live past LIVE_SAID_MS, and add a test that fails without that skip.
outdated
Runs for every user every tick and notifies for old apps on first rollout; bound it (app age / marker) and cache per app. |
||
| 502 | 506 | ...clogLines(app, user), |
| 507 | ...onboardedLines(app.db, user, now()), | |
| 503 | 508 | ]; |
| 504 | 509 | const before = seen.get(userId); |
| 505 | 510 | // What is gone may ask again later, and then it is new again. Seen by |
| @@ -546,6 +551,8 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 546 | 551 | } |
| 547 | 552 | |
| 548 | 553 | const unsubscribe = app.bus.subscribe((event) => { |
| 554 | // A run or pull moved: a new app's notice is judged afresh (`apps/onboarding.ts`). | |
| 555 | if (event.repoId !== null && /^(run|pull)\.|^app\.serving$/.test(event.kind)) forgetOnboarding(app.db, event.repoId); | |
| 549 | 556 | if (stopped || !WAKES.has(event.kind)) return; |
| 550 | 557 | const own = |
| 551 | 558 | event.kind === "account.notify_set" ? event.actorId : undefined; |
src/tui/screens/deploys.ts +10 −1
| @@ -17,6 +17,9 @@ | ||
| 17 | 17 | * each: `#N title · check <word> · <preview address or none>`. A guest sees |
| 18 | 18 | * none. |
| 19 | 19 | * |
| 20 | * Until production first runs, the head carries one line of where the app | |
| 21 | * stands (`apps/onboarding.ts`, #10295), as the app page says it. | |
| 22 | * | |
| 20 | 23 | * A writer who is a person — never an agent — gets `b`: type a commit's |
| 21 | 24 | * full id and `previewBuild` serves it at `<slug>--b<sha7>`, as the API's |
| 22 | 25 | * `POST …/builds/preview` does (`docs/features/build-preview.md`). |
| @@ -55,6 +58,7 @@ | ||
| 55 | 58 | * then says the repository is no longer an app. |
| 56 | 59 | */ |
| 57 | 60 | |
| 61 | import { appOnboardingLine } from "../../apps/onboarding.ts"; | |
| 58 | 62 | import { |
| 59 | 63 | appPace, |
| 60 | 64 | appPreviews, |
| @@ -211,6 +215,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 211 | 215 | let trend = ""; |
| 212 | 216 | let pace = ""; |
| 213 | 217 | let side: string[] = []; |
| 218 | /** Where a new or migrated app stands until production runs, or "". */ | |
| 219 | let onboarding = ""; | |
| 214 | 220 | let egress: string[] = []; |
| 215 | 221 | let pulls: AppPullLine[] = []; |
| 216 | 222 | /** The app's previews, or undefined for anyone who may not see them. */ |
| @@ -238,6 +244,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 238 | 244 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| 239 | 245 | pace = appPace(f.app, f.user, repo); |
| 240 | 246 | side = appSideLines(f.app, f.user, repo); |
| 247 | const standing = appOnboardingLine(f.app, repo, f.user); | |
| 248 | onboarding = standing === undefined ? "" : `${standing.text}${standing.href === undefined ? "" : ` ${standing.href}`}`; | |
| 241 | 249 | egress = egressLines(f, repo); |
| 242 | 250 | pulls = appPullLines(f.app, f.user, repo); |
| 243 | 251 | previews = appPreviews(f.app, f.user, repo); |
| @@ -422,6 +430,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 422 | 430 | (trend === "" ? 0 : 1) + |
| 423 | 431 | (pace === "" ? 0 : 1) + |
| 424 | 432 | side.length + |
| 433 | (onboarding === "" ? 0 : 1) + | |
| 425 | 434 | egress.length + |
| 426 | 435 | (named === "" ? 0 : 1), |
| 427 | 436 | ), |
| @@ -452,7 +461,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 452 | 461 | style(c.mutedFg, c.bg), |
| 453 | 462 | head!.x + head!.w, |
| 454 | 463 | ); |
| 455 | [visitors, trend, pace, ...side, named, ...egress] | |
| 464 | [onboarding, visitors, trend, pace, ...side, named, ...egress] | |
| 456 | 465 | .filter((line) => line !== "") |
| 457 | 466 | .forEach((line, i) => |
| 458 | 467 | s.text(head!.x, head!.y + 1 + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w), |
src/web/live.ts +10 −1
| @@ -190,6 +190,15 @@ export const liveClientJs = /* js */ ` | ||
| 190 | 190 | return isApp && data && data.kind && data.kind.indexOf('run.') === 0 && |
| 191 | 191 | data.payload && data.payload.kind === 'deploy'; |
| 192 | 192 | } |
| 193 | // While a new app's head carries where it stands ([data-onboarding], | |
| 194 | // apps/onboarding.ts), the crew's runs and its pulls opening, landing or | |
| 195 | // closing move it too. Once live the row is gone, and so is this. | |
| 196 | function movesOnboarding(data) { | |
| 197 | return isApp && data && data.kind && | |
| 198 | !!document.querySelector('#app-head [data-onboarding]') && | |
| 199 | (data.kind.indexOf('run.') === 0 || data.kind === 'pull.opened' || | |
| 200 | data.kind === 'pull.merged' || data.kind === 'pull.closed'); | |
| 201 | } | |
| 193 | 202 | // 'app.serving' fires at the swap-in — the instant a preview answers — |
| 194 | 203 | // which is seconds to half a minute before its tests (and so its |
| 195 | 204 | // run.finished) are done. It carries no run.kind, so it is matched on its |
| @@ -286,7 +295,7 @@ export const liveClientJs = /* js */ ` | ||
| 286 | 295 | // it still offers the refresh. A run or a serving moves only the swap. |
| 287 | 296 | if (data.kind !== 'pull.merged') return; |
| 288 | 297 | } |
| 289 | if (isDeploy(data) || isServing(data)) { refreshApp(); return; } | |
| 298 | if (isDeploy(data) || isServing(data) || movesOnboarding(data)) { refreshApp(); return; } | |
| 290 | 299 | if (relevant(data) && conv && (data.kind === thread + '.commented')) { |
| 291 | 300 | appendNew(); |
| 292 | 301 | } else if (relevant(data) || !thread) { |
src/web/onboarding_row.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | /** | |
| 2 | * Where a new or migrated app stands (#10295, `apps/onboarding.ts`), as the | |
| 3 | * facts row of the app's head card. While production has no deploy of its | |
| 4 | * own it takes the place of production's facts row, so the head says the | |
| 5 | * app's state once; | |
| 6 | * it rides inside `#app-head`, so the state fragment's swap moves it too. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { OnboardingLine } from "../apps/onboarding.ts"; | |
| 10 | import { html, type Html } from "./html.ts"; | |
| 11 | ||
| 12 | export function onboardingRow(line: OnboardingLine): Html { | |
| 13 | return html`<p class="muted" data-onboarding="${line.state}"> | |
| 14 | ${line.text}${line.href === undefined || line.linkLabel === undefined | |
| 15 | ? null | |
| 16 | : html` <a href="${line.href}">${line.linkLabel}</a>`} | |
| 17 | </p>`; | |
| 18 | } |
src/web/studio/previews.ts +8 −1
| @@ -13,6 +13,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t | ||
| 13 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | 14 | import { terminalFor } from "../../actions/apps.ts"; |
| 15 | 15 | import { envPull, previewEnv } from "../../apps/data.ts"; |
| 16 | import { appOnboardingLine } from "../../apps/onboarding.ts"; | |
| 16 | 17 | import { |
| 17 | 18 | appsRuntime, |
| 18 | 19 | type AppsRuntime, |
| @@ -27,6 +28,7 @@ import { loadRepo, repoBase } from "../chrome.ts"; | ||
| 27 | 28 | import { testsClause } from "./words.ts"; |
| 28 | 29 | import { html, when, type Html } from "../html.ts"; |
| 29 | 30 | import { fragment, missingFragment } from "../layout.ts"; |
| 31 | import { onboardingRow } from "../onboarding_row.ts"; | |
| 30 | 32 | import { |
| 31 | 33 | badge, |
| 32 | 34 | btn, |
| @@ -299,6 +301,9 @@ export function appPanels( | ||
| 299 | 301 | // The state in one word, and under it the facts — why it is not up, and |
| 300 | 302 | // the deploy whose log says the rest. |
| 301 | 303 | 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; | |
| 302 | 307 | const head = html`<div class="page-head"> |
| 303 | 308 | <div class="stack stack-tight"> |
| 304 | 309 | <h1 class="bar"> |
| @@ -322,7 +327,9 @@ export function appPanels( | ||
| 322 | 327 | : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`} |
| 323 | 328 | ${when(copy, (c) => html`· ${testCopyLink(c)}`)} |
| 324 | 329 | </p> |
| 325 | ${when(production, (env) => envFacts(base, env))} | |
| 330 | ${onboarding === undefined | |
| 331 | ? when(production, (env) => envFacts(base, env)) | |
| 332 | : onboardingRow(onboarding)} | |
| 326 | 333 | ${when(writer, () => |
| 327 | 334 | terminalLine( |
| 328 | 335 | terminalFor(ctx.app, record, "production", production), |
test/onboarding-doors.test.ts added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | /** | |
| 2 | * The new-app line at each door (#10295): the app page's head card, its | |
| 3 | * state fragment, the live client, the terminal, the guest, and the | |
| 4 | * notifier's event wiring. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 13 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import type { User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { setNotifyUrl, startNotifier } from "../src/crew/notify.ts"; | |
| 18 | import { liveClientJs, liveMount } from "../src/web/live.ts"; | |
| 19 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 20 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | let dir = ""; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let bearer = ""; | |
| 33 | const repos: Record<string, Repo> = {}; | |
| 34 | const OID = (c: string) => c.repeat(40); | |
| 35 | ||
| 36 | function giveAPull(repo: Repo, n: number): void { | |
| 37 | app.db.run("INSERT OR IGNORE INTO user (id, username, email, password_hash, is_agent) VALUES ('mason-id', 'mason', 'm@x', '', 1)"); | |
| 38 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, 1, 'pull', 'Make it run', 'mason-id')", [`i${n}`, repo.id]); | |
| 39 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'mason/run', ?2, ?3)", [`i${n}`, OID("a"), OID("b")]); | |
| 40 | } | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dir = await mkdtemp(join(tmpdir(), "forge-onboarding-doors-")); | |
| 44 | app = createApp({ dataDir: dir, port: 0 }); | |
| 45 | servers = await serve(app); | |
| 46 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 47 | if (!admin.ok) throw new Error(admin.error); | |
| 48 | alice = admin.value; | |
| 49 | let n = 0; | |
| 50 | for (const [name, visibility] of [["open", "public"], ["sealed", "private"]] as const) { | |
| 51 | const made = await createRepo(app, alice, { name, visibility, description: "" }); | |
| 52 | if (!made.ok) throw new Error(String(made.error)); | |
| 53 | const row = insertApp(app.db, { repoId: made.value.id, slug: name, access: visibility }); | |
| 54 | if (!row.ok) throw new Error(row.error); | |
| 55 | repos[name] = made.value; | |
| 56 | giveAPull(made.value, ++n); | |
| 57 | } | |
| 58 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 59 | method: "POST", | |
| 60 | headers: { "content-type": "application/json" }, | |
| 61 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 62 | }); | |
| 63 | bearer = ((await minted.json()) as { token: string }).token; | |
| 64 | }); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | const get = (path: string, auth: boolean) => | |
| 73 | fetch(`${servers.url}${path}`, { | |
| 74 | headers: { ...(auth ? { authorization: `Bearer ${bearer}` } : {}), cookie: "forge_mode=studio" }, | |
| 75 | }); | |
| 76 | const count = (text: string, needle: string): number => text.split(needle).length - 1; | |
| 77 | ||
| 78 | const headOf = (text: string): string => { | |
| 79 | const at = text.indexOf('id="app-head"'); | |
| 80 | return at < 0 ? "" : text.slice(at, text.indexOf('id="app-previews"', at)); | |
| 81 | }; | |
| 82 | ||
| 83 | test("the line is the head card's facts row, once, on the page and in its state fragment", async () => { | |
| 84 | const page = await (await get("/alice/open/app", true)).text(); | |
| 85 | expect(page).not.toContain('id="app-onboarding"'); | |
| 86 | expect(count(page, "data-onboarding=")).toBe(1); | |
| 87 | expect(headOf(page)).toContain('data-onboarding="making"'); | |
| 88 | expect(headOf(page)).toContain("The crew is making it run (pull #1 by mason)."); | |
| 89 | const fragment = await (await get("/alice/open/app/state", true)).text(); | |
| 90 | expect(count(fragment, "data-onboarding=")).toBe(1); | |
| 91 | expect(headOf(fragment)).toContain("pull #1 by mason"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a guest on a public app reads 'the crew', never the agent's name", async () => { | |
| 95 | const page = await (await get("/alice/open/app", false)).text(); | |
| 96 | expect(page).toContain("by the crew"); | |
| 97 | expect(page).not.toContain("by mason"); | |
| 98 | const fragment = await (await get("/alice/open/app/state", false)).text(); | |
| 99 | expect(fragment).not.toContain("mason"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a guest on a private app learns nothing from the line", async () => { | |
| 103 | for (const path of ["/alice/sealed/app", "/alice/sealed/app/state"]) { | |
| 104 | const res = await get(path, false); | |
| 105 | const text = await res.text(); | |
| 106 | expect(res.status).not.toBe(200); | |
| 107 | expect(text).not.toContain("making it run"); | |
| 108 | expect(text).not.toContain("pull #1"); | |
| 109 | expect(text).not.toContain("mason"); | |
| 110 | } | |
| 111 | }); | |
| 112 | ||
| 113 | const frame = async (who: User | null, repo: Repo): Promise<string> => { | |
| 114 | const { app: tui, session } = createForgeApp( | |
| 115 | app, | |
| 116 | { sshUsername: who?.username ?? "guest", remoteAddress: "127.0.0.1", ...(who === null ? {} : { user: who }) }, | |
| 117 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 118 | ); | |
| 119 | tui.start(); | |
| 120 | await settleApp(tui); | |
| 121 | tui.push(deploysScreen(session, tui.ctx, repo)); | |
| 122 | await settleApp(tui); | |
| 123 | const text = tui.screen.toText(); | |
| 124 | tui.stop(); | |
| 125 | return text; | |
| 126 | }; | |
| 127 | ||
| 128 | test("the terminal's deploys screen says where the app stands; a guest reads 'the crew'", async () => { | |
| 129 | const owner = await frame(alice, repos.open!); | |
| 130 | expect(owner).toContain("The crew is making it run (pull #1 by mason)."); | |
| 131 | const guest = await frame(null, repos.open!); | |
| 132 | expect(guest).toContain("by the crew"); | |
| 133 | expect(guest).not.toContain("mason"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a failed check: the owner reads its failing line and log; a guest on the public app reads only that a check failed", async () => { | |
| 137 | const run = createRun(app.db, { repoId: repos.open!.id, kind: "check", env: "pull-1", issueId: "i1", commitOid: OID("b"), reason: "push" }); | |
| 138 | claimRun(app.db, run.id); | |
| 139 | finishRun(app.db, run.id, { state: "failed", summary: "check failed: 1 test failed", checkDetail: { passed: 3, failed: 1, names: ["boots on its port"] } }); | |
| 140 | ||
| 141 | for (const path of ["/alice/open/app", "/alice/open/app/state"]) { | |
| 142 | const owner = headOf(await (await get(path, true)).text()); | |
| 143 | expect(owner).toContain("Check failed: boots on its port"); | |
| 144 | expect(owner).toContain(`href="/alice/open/runs/${run.id}">Failing run log</a>`); | |
| 145 | ||
| 146 | const guest = headOf(await (await get(path, false)).text()); | |
| 147 | expect(guest).toContain("A check failed."); | |
| 148 | expect(guest).not.toContain("boots on its port"); | |
| 149 | expect(guest).not.toContain(run.id); | |
| 150 | expect(guest).not.toContain("Failing run log"); | |
| 151 | } | |
| 152 | const guestTui = await frame(null, repos.open!); | |
| 153 | expect(guestTui).toContain("A check failed."); | |
| 154 | expect(guestTui).not.toContain("boots on its port"); | |
| 155 | }); | |
| 156 | ||
| 157 | /** Runs the live client on an app page; returns its stream listeners and what it fetched. */ | |
| 158 | async function liveClient(row: boolean) { | |
| 159 | const { Window } = await import("happy-dom"); | |
| 160 | const win = new Window({ url: "http://forge.test/alice/open/app" }); | |
| 161 | win.document.body.innerHTML = `${String(liveMount({ repo: "/alice/open", app: true }))} | |
| 162 | <div id="app-head">${row ? '<p class="muted" data-onboarding="making">The crew is making it run.</p>' : ""}</div> | |
| 163 | <div id="app-previews"></div>`; | |
| 164 | const listeners = new Map<string, (e: { data: string }) => void>(); | |
| 165 | class FakeSource { | |
| 166 | addEventListener(kind: string, f: (e: { data: string }) => void) { | |
| 167 | listeners.set(kind, f); | |
| 168 | } | |
| 169 | close() {} | |
| 170 | } | |
| 171 | const fetched: string[] = []; | |
| 172 | const fakeFetch = async (url: string) => { | |
| 173 | fetched.push(url); | |
| 174 | return { ok: true, status: 200, headers: { get: () => null }, text: async () => "" }; | |
| 175 | }; | |
| 176 | new Function("document", "window", "location", "EventSource", "fetch", liveClientJs)( | |
| 177 | win.document, win, win.location, FakeSource, fakeFetch, | |
| 178 | ); | |
| 179 | const send = async (kind: string, payload: Record<string, unknown> = {}) => { | |
| 180 | listeners.get(kind)?.({ data: JSON.stringify({ kind, payload }) }); | |
| 181 | await new Promise((r) => setTimeout(r, 0)); | |
| 182 | }; | |
| 183 | return { listeners, fetched, send, win }; | |
| 184 | } | |
| 185 | ||
| 186 | test("the live client refreshes the head on the crew's runs and pulls only while the head carries the line", async () => { | |
| 187 | const onboarding = await liveClient(true); | |
| 188 | await onboarding.send("run.started", { kind: "agent" }); | |
| 189 | expect(onboarding.fetched).toEqual(["/alice/open/app/state"]); | |
| 190 | await onboarding.send("pull.closed"); | |
| 191 | expect(onboarding.fetched.length).toBe(2); | |
| 192 | // Not subscribed: a push to a pull is followed through the runs it starts. | |
| 193 | expect(onboarding.listeners.has("pull.pushed")).toBe(false); | |
| 194 | ||
| 195 | const settled = await liveClient(false); | |
| 196 | await settled.send("run.started", { kind: "agent" }); | |
| 197 | await settled.send("pull.closed"); | |
| 198 | expect(settled.fetched).toEqual([]); | |
| 199 | expect(settled.win.document.getElementById("live-pill")!.hasAttribute("hidden")).toBe(false); | |
| 200 | }); | |
| 201 | ||
| 202 | test("the notifier judges a new app afresh on a pull event and tells its owner once it needs them", async () => { | |
| 203 | setNotifyUrl(app.db, alice.id, "https://ntfy.example.test/onboarding"); | |
| 204 | const posts: string[] = []; | |
| 205 | const notifier = startNotifier(app, { | |
| 206 | forgeUrl: "https://forge.example.test", | |
| 207 | drainingSince: () => null, | |
| 208 | send: async (_u, body) => { | |
| 209 | posts.push(body); | |
| 210 | return true; | |
| 211 | }, | |
| 212 | }); | |
| 213 | try { | |
| 214 | await notifier.settled(); | |
| 215 | expect(posts.filter((p) => p.includes("needs you"))).toEqual([]); | |
| 216 | app.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 217 | app.bus.append({ kind: "pull.closed", repoId: repos.open!.id, actorId: alice.id, payload: { number: 1 } }); | |
| 218 | await notifier.settled(); | |
| 219 | expect(posts.filter((p) => p.includes("open needs you")).length).toBe(1); | |
| 220 | } finally { | |
| 221 | notifier.stop(); | |
| 222 | } | |
| 223 | }); |
test/onboarding-line.test.ts added +206
| @@ -0,0 +1,206 @@ | ||
| 1 | /** | |
| 2 | * The new-app status line (#10295): a fresh app walked through each state, | |
| 3 | * and the one notification it earns. | |
| 4 | */ | |
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { migrations } from "../src/core/schema.ts"; | |
| 7 | import { openDb } from "../src/kernel/db.ts"; | |
| 8 | import { claimRun, createRun, finishRun, markServing, type Run } from "../src/core/runs.ts"; | |
| 9 | import { createApp } from "../src/core/apps.ts"; | |
| 10 | import { getRepoById } from "../src/core/repos.ts"; | |
| 11 | import { getUserById } from "../src/core/users.ts"; | |
| 12 | import { | |
| 13 | forgetOnboarding, | |
| 14 | onboardedLines, | |
| 15 | onboardingCandidates, | |
| 16 | onboardingFacts, | |
| 17 | onboardingLine, | |
| 18 | ONBOARDING_WINDOW_MS, | |
| 19 | } from "../src/apps/onboarding.ts"; | |
| 20 | ||
| 21 | const OID = (c: string) => c.repeat(40); | |
| 22 | ||
| 23 | function fixture() { | |
| 24 | const db = openDb(":memory:", migrations); | |
| 25 | db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'owner', 'o@x', '')"); | |
| 26 | db.run("INSERT INTO user (id, username, email, password_hash, is_agent) VALUES ('a1', 'mason', 'm@x', '', 1)"); | |
| 27 | db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'exact')"); | |
| 28 | const repo = getRepoById(db, "r1")!; | |
| 29 | const created = createApp(db, { repoId: "r1", slug: "exact", access: "private" }); | |
| 30 | if (!created.ok) throw new Error("app"); | |
| 31 | const record = created.value; | |
| 32 | const user = getUserById(db, "u1")!; | |
| 33 | return { db, repo, record, user }; | |
| 34 | } | |
| 35 | ||
| 36 | const line = (f: ReturnType<typeof fixture>, logs = true) => | |
| 37 | onboardingLine(onboardingFacts(f.db, f.repo, f.record, "/owner/exact", f.user, logs)); | |
| 38 | ||
| 39 | function openPull(f: ReturnType<typeof fixture>) { | |
| 40 | f.db.run( | |
| 41 | "INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('i1', 'r1', 1, 'pull', 'Make it run', 'a1')", | |
| 42 | ); | |
| 43 | f.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('i1', 'main', 'mason/run', ?1, ?2)", [OID("a"), OID("b")]); | |
| 44 | } | |
| 45 | const check = (f: ReturnType<typeof fixture>, kind: "check" | "deploy" = "check"): Run => | |
| 46 | createRun(f.db, { repoId: "r1", kind, env: kind === "deploy" ? "pull-1" : "pull-1", issueId: "i1", commitOid: OID("b"), reason: "push" }); | |
| 47 | const fail = (f: ReturnType<typeof fixture>, run: Run, name: string) => { | |
| 48 | claimRun(f.db, run.id); | |
| 49 | finishRun(f.db, run.id, { state: "failed", summary: "check failed: 1 test failed", checkDetail: { passed: 3, failed: 1, names: [name] } }); | |
| 50 | }; | |
| 51 | ||
| 52 | describe("a fresh app walked through its states", () => { | |
| 53 | test("imported, then making, failed, retrying, preview, live", () => { | |
| 54 | const f = fixture(); | |
| 55 | expect(line(f)).toMatchObject({ state: "imported" }); | |
| 56 | ||
| 57 | openPull(f); | |
| 58 | const making = line(f)!; | |
| 59 | expect(making.state).toBe("making"); | |
| 60 | expect(making.text).toBe("The crew is making it run (pull #1 by mason)."); | |
| 61 | ||
| 62 | const first = check(f); | |
| 63 | fail(f, first, "bun test: boots fails"); | |
| 64 | const failed = line(f)!; | |
| 65 | expect(failed.state).toBe("failed"); | |
| 66 | expect(failed.text).toBe("Check failed: bun test: boots fails"); | |
| 67 | expect(failed.href).toBe(`/owner/exact/runs/${first.id}`); | |
| 68 | expect(failed.linkLabel).toBe("Failing run log"); | |
| 69 | ||
| 70 | const next = check(f); | |
| 71 | const retrying = line(f)!; | |
| 72 | expect(retrying.state).toBe("retrying"); | |
| 73 | expect(retrying.text).toContain("bun test: boots fails"); | |
| 74 | expect(retrying.href).toBe(`/owner/exact/runs/${first.id}`); | |
| 75 | ||
| 76 | const deploy = check(f, "deploy"); | |
| 77 | claimRun(f.db, deploy.id); | |
| 78 | markServing(f.db, deploy.id); | |
| 79 | claimRun(f.db, next.id); | |
| 80 | finishRun(f.db, next.id, { state: "ok", summary: "check passed" }); | |
| 81 | expect(line(f)!.state).toBe("preview"); | |
| 82 | ||
| 83 | const prod = createRun(f.db, { repoId: "r1", kind: "deploy", env: "production", commitOid: OID("c"), reason: "push" }); | |
| 84 | claimRun(f.db, prod.id); | |
| 85 | markServing(f.db, prod.id); | |
| 86 | const live = line(f)!; | |
| 87 | expect(live.state).toBe("live"); | |
| 88 | expect(live.href).toBe("/owner/exact/app"); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a closed pull with nothing landed says it needs the owner", () => { | |
| 92 | const f = fixture(); | |
| 93 | openPull(f); | |
| 94 | f.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 95 | expect(line(f)).toMatchObject({ state: "stopped" }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the line is gone a day after it went live", () => { | |
| 99 | const f = fixture(); | |
| 100 | const prod = createRun(f.db, { repoId: "r1", kind: "deploy", env: "production", commitOid: OID("c"), reason: "push" }); | |
| 101 | claimRun(f.db, prod.id); | |
| 102 | markServing(f.db, prod.id); | |
| 103 | const facts = onboardingFacts(f.db, f.repo, f.record, "/owner/exact", f.user, true, Date.now() + 25 * 3_600_000); | |
| 104 | expect(onboardingLine(facts)).toBeUndefined(); | |
| 105 | expect(facts.pulls).toEqual([]); | |
| 106 | }); | |
| 107 | }); | |
| 108 | ||
| 109 | describe("a viewer who may not read run logs", () => { | |
| 110 | test("reads that a check failed, never its failing line or a link to its log", () => { | |
| 111 | const f = fixture(); | |
| 112 | openPull(f); | |
| 113 | fail(f, check(f), "bun test: secret path /srv/x"); | |
| 114 | expect(line(f, false)).toEqual({ state: "failed", text: "A check failed." }); | |
| 115 | ||
| 116 | check(f); | |
| 117 | expect(line(f, false)).toEqual({ state: "retrying", text: "Retrying after a failed check." }); | |
| 118 | expect(line(f, true)!.text).toContain("secret path"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a failed check while the crew works again is retrying, with the line for one who may read logs", () => { | |
| 122 | const f = fixture(); | |
| 123 | openPull(f); | |
| 124 | const failed = check(f); | |
| 125 | fail(f, failed, "boots"); | |
| 126 | createRun(f.db, { repoId: "r1", kind: "agent", issueId: "i1", reason: "fix" }); | |
| 127 | expect(line(f, true)).toMatchObject({ state: "retrying", text: "Retrying after a failed check: boots", href: `/owner/exact/runs/${failed.id}` }); | |
| 128 | expect(line(f, false)).toEqual({ state: "retrying", text: "Retrying after a failed check." }); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("only a new app", () => { | |
| 133 | test("an app created before this shipped has no line and is never looked at", () => { | |
| 134 | const f = fixture(); | |
| 135 | openPull(f); | |
| 136 | f.db.run("UPDATE app SET created_at = '2026-01-02T00:00:00.000Z' WHERE repo_id = 'r1'"); | |
| 137 | const old = { ...f, record: { ...f.record, createdAt: "2026-01-02T00:00:00.000Z" } }; | |
| 138 | const facts = onboardingFacts(old.db, old.repo, old.record, "/owner/exact", old.user, true); | |
| 139 | expect(onboardingLine(facts)).toBeUndefined(); | |
| 140 | expect(facts.pulls).toEqual([]); | |
| 141 | expect(onboardingCandidates(f.db, f.user)).toEqual([]); | |
| 142 | f.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 143 | expect(onboardedLines(f.db, f.user)).toEqual([]); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a new app that never ran goes quiet once its window passes", () => { | |
| 147 | const f = fixture(); | |
| 148 | openPull(f); | |
| 149 | f.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 150 | expect(onboardingCandidates(f.db, f.user)).toEqual(["r1"]); | |
| 151 | const later = Date.now() + ONBOARDING_WINDOW_MS + 60_000; | |
| 152 | expect(onboardingCandidates(f.db, f.user, later)).toEqual([]); | |
| 153 | expect(onboardedLines(f.db, f.user, later)).toEqual([]); | |
| 154 | expect(onboardingLine(onboardingFacts(f.db, f.repo, f.record, "/owner/exact", f.user, true, later))).toBeUndefined(); | |
| 155 | }); | |
| 156 | ||
| 157 | test("an app live for more than a day is not a candidate", () => { | |
| 158 | const f = fixture(); | |
| 159 | const prod = createRun(f.db, { repoId: "r1", kind: "deploy", env: "production", commitOid: OID("c"), reason: "push" }); | |
| 160 | claimRun(f.db, prod.id); | |
| 161 | markServing(f.db, prod.id); | |
| 162 | expect(onboardingCandidates(f.db, f.user)).toEqual(["r1"]); | |
| 163 | expect(onboardingCandidates(f.db, f.user, Date.now() + 25 * 3_600_000)).toEqual([]); | |
| 164 | expect(onboardedLines(f.db, f.user, Date.now() + 25 * 3_600_000)).toEqual([]); | |
| 165 | }); | |
| 166 | }); | |
| 167 | ||
| 168 | describe("one notification", () => { | |
| 169 | test("retries say nothing; going live says it once, under one id", () => { | |
| 170 | const f = fixture(); | |
| 171 | openPull(f); | |
| 172 | fail(f, check(f), "boots"); | |
| 173 | expect(onboardedLines(f.db, f.user)).toEqual([]); | |
| 174 | forgetOnboarding(f.db, "r1"); | |
| 175 | fail(f, check(f), "boots again"); | |
| 176 | expect(onboardedLines(f.db, f.user)).toEqual([]); | |
| 177 | ||
| 178 | const prod = createRun(f.db, { repoId: "r1", kind: "deploy", env: "production", commitOid: OID("c"), reason: "push" }); | |
| 179 | claimRun(f.db, prod.id); | |
| 180 | markServing(f.db, prod.id); | |
| 181 | forgetOnboarding(f.db, "r1"); | |
| 182 | const told = onboardedLines(f.db, f.user); | |
| 183 | expect(told).toEqual([{ line: "exact is live.", items: ["onboarded:r1:live"], link: "/owner/exact/app" }]); | |
| 184 | expect(onboardedLines(f.db, f.user)).toEqual(told); | |
| 185 | }); | |
| 186 | ||
| 187 | test("the judgment is kept until a run or pull event on the app forgets it", () => { | |
| 188 | const f = fixture(); | |
| 189 | openPull(f); | |
| 190 | expect(onboardedLines(f.db, f.user)).toEqual([]); | |
| 191 | f.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 192 | // A clock tick with no event reads nothing new. | |
| 193 | expect(onboardedLines(f.db, f.user)).toEqual([]); | |
| 194 | forgetOnboarding(f.db, "r1"); | |
| 195 | expect(onboardedLines(f.db, f.user).map((l) => l.items)).toEqual([["onboarded:r1:stopped"]]); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a pull closed without landing needs the owner", () => { | |
| 199 | const f = fixture(); | |
| 200 | openPull(f); | |
| 201 | f.db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 202 | const told = onboardedLines(f.db, f.user); | |
| 203 | expect(told.length).toBe(1); | |
| 204 | expect(told[0]!.line).toContain("needs you"); | |
| 205 | }); | |
| 206 | }); |
Plain words (principle 8): one long arrow-chain paragraph. Use a short list of states, and fix 'Read, not stored' (a cache exists).