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

Merged opened this in answer to

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

Merged

mason/forge:mason/10295-a-new-or-migrated-app-tells-its-owner-wh → main 6 commits fork 8 threads · 8 unresolved

  • Merged. mason/10295-a-new-or-migrated-app-tells-its-owner-wh was merged into main as e970e0d.

Review

Sign in to review this change.
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).
outdated

Plain words (principle 8): one long arrow-chain paragraph. Use a short list of states, and fix 'Read, not stored' (a cache exists).

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 @@
2222
2323 import { newAccountLines } from "../actions/accounts.ts";
2424 import { mailCeilingLines } from "../actions/mail.ts";
25 import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts";
2526 import type { Database } from "bun:sqlite";
2627 import { outboundFetch } from "../kernel/outbound.ts";
2728 import type { App } from "../app.ts";
@@ -61,10 +62,13 @@ const BACKOFF_MAX_MS = HOUR_MS;
6162 const WAKES = new Set([
6263 "clog.started",
6364 "run.finished",
65 "app.serving",
6466 "pull.opened",
6567 "pull.pushed",
6668 "pull.reviewed",
6769 "pull.merged",
70 // A new app's crew pull closed without landing: it needs its owner (`apps/onboarding.ts`).
71 "pull.closed",
6872 "issue.closed",
6973 "issue.commented",
7074 "repo.settings_updated",
@@ -500,6 +504,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
500504 ...redLines(app, user, now()),
501505 ...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.

502506 ...clogLines(app, user),
507 ...onboardedLines(app.db, user, now()),
503508 ];
504509 const before = seen.get(userId);
505510 // 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 {
546551 }
547552
548553 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);
549556 if (stopped || !WAKES.has(event.kind)) return;
550557 const own =
551558 event.kind === "account.notify_set" ? event.actorId : undefined;
src/tui/screens/deploys.ts +10 −1
@@ -17,6 +17,9 @@
1717 * each: `#N title · check <word> · <preview address or none>`. A guest sees
1818 * none.
1919 *
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 *
2023 * A writer who is a person — never an agent — gets `b`: type a commit's
2124 * full id and `previewBuild` serves it at `<slug>--b<sha7>`, as the API's
2225 * `POST …/builds/preview` does (`docs/features/build-preview.md`).
@@ -55,6 +58,7 @@
5558 * then says the repository is no longer an app.
5659 */
5760
61 import { appOnboardingLine } from "../../apps/onboarding.ts";
5862 import {
5963 appPace,
6064 appPreviews,
@@ -211,6 +215,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
211215 let trend = "";
212216 let pace = "";
213217 let side: string[] = [];
218 /** Where a new or migrated app stands until production runs, or "". */
219 let onboarding = "";
214220 let egress: string[] = [];
215221 let pulls: AppPullLine[] = [];
216222 /** 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 {
238244 trend = seen === undefined ? "" : trendLine(seen.loadDays);
239245 pace = appPace(f.app, f.user, repo);
240246 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}`}`;
241249 egress = egressLines(f, repo);
242250 pulls = appPullLines(f.app, f.user, repo);
243251 previews = appPreviews(f.app, f.user, repo);
@@ -422,6 +430,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
422430 (trend === "" ? 0 : 1) +
423431 (pace === "" ? 0 : 1) +
424432 side.length +
433 (onboarding === "" ? 0 : 1) +
425434 egress.length +
426435 (named === "" ? 0 : 1),
427436 ),
@@ -452,7 +461,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
452461 style(c.mutedFg, c.bg),
453462 head!.x + head!.w,
454463 );
455 [visitors, trend, pace, ...side, named, ...egress]
464 [onboarding, visitors, trend, pace, ...side, named, ...egress]
456465 .filter((line) => line !== "")
457466 .forEach((line, i) =>
458467 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 */ `
190190 return isApp && data && data.kind && data.kind.indexOf('run.') === 0 &&
191191 data.payload && data.payload.kind === 'deploy';
192192 }
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 }
193202 // 'app.serving' fires at the swap-in — the instant a preview answers —
194203 // which is seconds to half a minute before its tests (and so its
195204 // run.finished) are done. It carries no run.kind, so it is matched on its
@@ -286,7 +295,7 @@ export const liveClientJs = /* js */ `
286295 // it still offers the refresh. A run or a serving moves only the swap.
287296 if (data.kind !== 'pull.merged') return;
288297 }
289 if (isDeploy(data) || isServing(data)) { refreshApp(); return; }
298 if (isDeploy(data) || isServing(data) || movesOnboarding(data)) { refreshApp(); return; }
290299 if (relevant(data) && conv && (data.kind === thread + '.commented')) {
291300 appendNew();
292301 } 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
1313 import { sealedScope } from "../../core/sealed.ts";
1414 import { terminalFor } from "../../actions/apps.ts";
1515 import { envPull, previewEnv } from "../../apps/data.ts";
16 import { appOnboardingLine } from "../../apps/onboarding.ts";
1617 import {
1718 appsRuntime,
1819 type AppsRuntime,
@@ -27,6 +28,7 @@ import { loadRepo, repoBase } from "../chrome.ts";
2728 import { testsClause } from "./words.ts";
2829 import { html, when, type Html } from "../html.ts";
2930 import { fragment, missingFragment } from "../layout.ts";
31 import { onboardingRow } from "../onboarding_row.ts";
3032 import {
3133 badge,
3234 btn,
@@ -299,6 +301,9 @@ export function appPanels(
299301 // The state in one word, and under it the facts — why it is not up, and
300302 // the deploy whose log says the rest.
301303 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;
302307 const head = html`<div class="page-head">
303308 <div class="stack stack-tight">
304309 <h1 class="bar">
@@ -322,7 +327,9 @@ export function appPanels(
322327 : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`}
323328 ${when(copy, (c) => html`· ${testCopyLink(c)}`)}
324329 </p>
325 ${when(production, (env) => envFacts(base, env))}
330 ${onboarding === undefined
331 ? when(production, (env) => envFacts(base, env))
332 : onboardingRow(onboarding)}
326333 ${when(writer, () =>
327334 terminalLine(
328335 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs