Public: anyone can read this; the crew can change it.
The front door goes quiet the moment you ship #229
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/215-the-front-door-goes-quiet-the-moment-you → main 1 commit fork
5 files
changed +472
−25
src/core/runs.ts +24
| @@ -167,6 +167,30 @@ export function getRun(db: Database, runId: string): Run | undefined { | ||
| 167 | 167 | return row ? toRun(row) : undefined; |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | /** | |
| 171 | * Several runs by id, in one query — for a caller that already has a batch | |
| 172 | * of run ids from elsewhere (an environment's `runId`, a `failedDeploy`'s) | |
| 173 | * and wants the rows without asking once per id. | |
| 174 | */ | |
| 175 | export function getRunsByIds( | |
| 176 | db: Database, | |
| 177 | runIds: readonly string[], | |
| 178 | ): Map<string, Run> { | |
| 179 | const out = new Map<string, Run>(); | |
| 180 | const unique = [...new Set(runIds)]; | |
| 181 | const CHUNK = 400; | |
| 182 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 183 | const batch = unique.slice(i, i + CHUNK); | |
| 184 | const rows = db | |
| 185 | .query<RunRow, string[]>( | |
| 186 | `SELECT * FROM run WHERE id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`, | |
| 187 | ) | |
| 188 | .all(...batch); | |
| 189 | for (const row of rows) out.set(row.id, toRun(row)); | |
| 190 | } | |
| 191 | return out; | |
| 192 | } | |
| 193 | ||
| 170 | 194 | /** |
| 171 | 195 | * Take a queued run. The UPDATE's WHERE is the claim: a second worker's |
| 172 | 196 | * changes count is zero and it moves on. Returns the run as running, or |
src/web/studio/home.ts +44 −9
| @@ -26,7 +26,7 @@ import { asksPanel, decisionsFor, writableAppsOf } from "./asks.ts"; | ||
| 26 | 26 | import { withLikely } from "./leans.ts"; |
| 27 | 27 | import { changeHref } from "./nouns.ts"; |
| 28 | 28 | import { stopForm } from "./run.ts"; |
| 29 | import { shelfRows } from "./shelf.ts"; | |
| 29 | import { productionFactsForRepos, shelfRows } from "./shelf.ts"; | |
| 30 | 30 | import { pullWord, wordBadge, type WordCtx } from "./words.ts"; |
| 31 | 31 | |
| 32 | 32 | /** |
| @@ -45,7 +45,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 45 | 45 | // Read once, shown twice: the panel below is these same asks, and the |
| 46 | 46 | // shelf counts them into each row's pill. |
| 47 | 47 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); |
| 48 | const appRows = await shelfRows(ctx, appRepos, { decisions }); | |
| 48 | // Read once, shown twice again: a shipped or failed deploy is a row's own | |
| 49 | // word, and — when nothing is running and no change waits — the rail's. | |
| 50 | const productionFacts = productionFactsForRepos(ctx, appRepos); | |
| 51 | const appRows = await shelfRows(ctx, appRepos, { decisions, productionFacts }); | |
| 49 | 52 | |
| 50 | 53 | // What the crew cost today across every app on the shelf, and how many |
| 51 | 54 | // are paused at their ceiling right now — the owner's first question at |
| @@ -98,7 +101,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 98 | 101 | ); |
| 99 | 102 | const runActors = getUsersByIds( |
| 100 | 103 | db, |
| 101 | running.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 104 | [ | |
| 105 | ...running.map((r) => r.actorId), | |
| 106 | ...[...productionFacts.values()].map((f) => f.actorId), | |
| 107 | ].filter((id): id is string => id !== null), | |
| 102 | 108 | ); |
| 103 | 109 | const byId = new Map(appRepos.map((r) => [r.id, r])); |
| 104 | 110 | // Who is working is shown by name here because every app on this shelf is |
| @@ -119,6 +125,15 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 119 | 125 | running.length === 0 |
| 120 | 126 | ? [...openPullsForRepos(db, appRepos.map((r) => r.id)).values()] |
| 121 | 127 | : []; |
| 128 | // Still nothing waiting for a look: a deploy that just landed or just | |
| 129 | // failed is news too, newest first — the same facts the shelf's rows | |
| 130 | // read, shared rather than asked for twice. | |
| 131 | const shipped = | |
| 132 | running.length === 0 && openPulls.length === 0 | |
| 133 | ? [...productionFacts.entries()] | |
| 134 | .sort(([, a], [, b]) => (a.at < b.at ? 1 : -1)) | |
| 135 | .slice(0, 10) | |
| 136 | : []; | |
| 122 | 137 | const now = |
| 123 | 138 | running.length > 0 |
| 124 | 139 | ? listCard( |
| @@ -153,11 +168,8 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 153 | 168 | }), |
| 154 | 169 | { head: nowHead }, |
| 155 | 170 | ) |
| 156 | : openPulls.length === 0 | |
| 157 | ? card(html`<p class="muted small">Nobody is working right now.</p>`, { | |
| 158 | head: nowHead, | |
| 159 | }) | |
| 160 | : listCard( | |
| 171 | : openPulls.length > 0 | |
| 172 | ? listCard( | |
| 161 | 173 | await Promise.all( |
| 162 | 174 | openPulls.slice(0, 10).map(async (pull) => { |
| 163 | 175 | const repo = byId.get(pull.repoId)!; |
| @@ -182,7 +194,30 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 182 | 194 | }), |
| 183 | 195 | ), |
| 184 | 196 | { head: nowHead }, |
| 185 | ); | |
| 197 | ) | |
| 198 | : shipped.length > 0 | |
| 199 | ? listCard( | |
| 200 | shipped.map(([repoId, fact]) => { | |
| 201 | const repo = byId.get(repoId)!; | |
| 202 | const href = `${repoBase(repo)}/runs/${fact.runId}`; | |
| 203 | const who = | |
| 204 | fact.actorId === null | |
| 205 | ? "crew" | |
| 206 | : (runActors.get(fact.actorId)?.username ?? "crew"); | |
| 207 | return row({ | |
| 208 | href, | |
| 209 | nav: true, | |
| 210 | lead: icon(fact.word === "shipped" ? "check" : "alert"), | |
| 211 | title: html`<a href="${href}">${who} · ${repo.name}</a>`, | |
| 212 | meta: html`${fact.note} · ${time(fact.at)}`, | |
| 213 | trail: wordBadge(fact.word), | |
| 214 | }); | |
| 215 | }), | |
| 216 | { head: nowHead }, | |
| 217 | ) | |
| 218 | : card(html`<p class="muted small">Nobody is working right now.</p>`, { | |
| 219 | head: nowHead, | |
| 220 | }); | |
| 186 | 221 | |
| 187 | 222 | const body = html`<h1 class="sr-only">Studio</h1> |
| 188 | 223 | ${costLine} |
src/web/studio/shelf.ts +101 −16
| @@ -16,18 +16,25 @@ | ||
| 16 | 16 | * |
| 17 | 17 | * What a row says it is doing, in order: a run still in flight (that is the |
| 18 | 18 | * thing moving); else an open change, whoever opened it — the row is about |
| 19 | * the app, not about who typed it; else the last run's own word, its | |
| 20 | * history; else quiet, or told-but-stuck when nobody on the crew can take | |
| 21 | * it. | |
| 19 | * the app, not about who typed it; else the most recent of the last run's | |
| 20 | * own word and production's own fact (`words.ts`'s `productionFact` — a | |
| 21 | * deploy that just landed or just failed), preferring the run's word when it | |
| 22 | * already says *shipped* so a change stays credited to whoever's own history | |
| 23 | * carries it rather than to the deploy behind it; else quiet, or | |
| 24 | * told-but-stuck when nobody on the crew can take it. | |
| 22 | 25 | */ |
| 23 | 26 | |
| 24 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 27 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; | |
| 25 | 28 | import { appsForRepos } from "../../core/apps.ts"; |
| 26 | 29 | import { reposWithBuilder } from "../../core/agents.ts"; |
| 27 | 30 | import { reposWithUnclaimedTell } from "../../core/issues.ts"; |
| 28 | 31 | import { openPullsForRepos } from "../../core/pulls.ts"; |
| 29 | 32 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 30 | import { isTerminal, latestAgentRunsForRepos } from "../../core/runs.ts"; | |
| 33 | import { | |
| 34 | getRunsByIds, | |
| 35 | isTerminal, | |
| 36 | latestAgentRunsForRepos, | |
| 37 | } from "../../core/runs.ts"; | |
| 31 | 38 | import { getUsersByIds } from "../../core/users.ts"; |
| 32 | 39 | import { repoDir } from "../../git/repos.ts"; |
| 33 | 40 | import type { Ctx } from "../../http/context.ts"; |
| @@ -36,12 +43,59 @@ import { html, type Html } from "../html.ts"; | ||
| 36 | 43 | import { dot, envPhrase, envTone, pill, row, time } from "../ui.ts"; |
| 37 | 44 | import { decisionsFor, type Decision } from "./asks.ts"; |
| 38 | 45 | import { withLikely } from "./leans.ts"; |
| 39 | import { pullWord, runWord, type WordCtx } from "./words.ts"; | |
| 46 | import { | |
| 47 | productionFact, | |
| 48 | pullWord, | |
| 49 | runWord, | |
| 50 | type ProductionFact, | |
| 51 | type WordCtx, | |
| 52 | } from "./words.ts"; | |
| 40 | 53 | |
| 41 | 54 | /** Reads the caller has already made, so the shelf does not make them again. */ |
| 42 | 55 | export interface ShelfReads { |
| 43 | 56 | /** What asks the viewer across these apps — the asks pill counts them. */ |
| 44 | 57 | readonly decisions?: readonly Decision[] | undefined; |
| 58 | /** Production's own fact for each app — a deploy that just landed or just | |
| 59 | * failed. The home rail reads the same map for its own rows. */ | |
| 60 | readonly productionFacts?: ReadonlyMap<string, ProductionFact> | undefined; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Production's own fact for every one of these repositories, in one batch: | |
| 65 | * `runtime.status` is in-memory (no query), so the only read is the run rows | |
| 66 | * a fact points to — one `IN` for the whole shelf, never one per row. | |
| 67 | */ | |
| 68 | export function productionFactsForRepos( | |
| 69 | ctx: Ctx, | |
| 70 | repos: readonly Repo[], | |
| 71 | ): Map<string, ProductionFact> { | |
| 72 | const db = ctx.app.db; | |
| 73 | const runtime = appsRuntime(ctx.app); | |
| 74 | const out = new Map<string, ProductionFact>(); | |
| 75 | if (runtime === undefined) return out; | |
| 76 | const records = appsForRepos( | |
| 77 | db, | |
| 78 | repos.map((r) => r.id), | |
| 79 | ); | |
| 80 | const productions = new Map<string, EnvStatus | undefined>(); | |
| 81 | const runIds = new Set<string>(); | |
| 82 | for (const repo of repos) { | |
| 83 | const record = records.get(repo.id); | |
| 84 | const production = record | |
| 85 | ? runtime.status(record).find((e) => e.env === "production") | |
| 86 | : undefined; | |
| 87 | productions.set(repo.id, production); | |
| 88 | if (production?.runId !== undefined && production.createdAt !== undefined) | |
| 89 | runIds.add(production.runId); | |
| 90 | if (production?.failedDeploy !== undefined) | |
| 91 | runIds.add(production.failedDeploy.runId); | |
| 92 | } | |
| 93 | const runs = getRunsByIds(db, [...runIds]); | |
| 94 | for (const [repoId, production] of productions) { | |
| 95 | const fact = productionFact(production, runs); | |
| 96 | if (fact !== undefined) out.set(repoId, fact); | |
| 97 | } | |
| 98 | return out; | |
| 45 | 99 | } |
| 46 | 100 | |
| 47 | 101 | /** |
| @@ -71,11 +125,17 @@ export async function shelfRows( | ||
| 71 | 125 | db, |
| 72 | 126 | repos.map((r) => r.id), |
| 73 | 127 | ); |
| 128 | // Production's own fact, per app, one batch — a deploy that just landed or | |
| 129 | // just failed, the other half of what "the last thing that happened" can | |
| 130 | // mean now that it is not only an agent run. | |
| 131 | const productionFacts = | |
| 132 | reads.productionFacts ?? productionFactsForRepos(ctx, repos); | |
| 74 | 133 | const actors = getUsersByIds( |
| 75 | 134 | db, |
| 76 | [...latestRuns.values()] | |
| 77 | .map((r) => r.actorId) | |
| 78 | .filter((id): id is string => id !== null), | |
| 135 | [ | |
| 136 | ...[...latestRuns.values()].map((r) => r.actorId), | |
| 137 | ...[...productionFacts.values()].map((f) => f.actorId), | |
| 138 | ].filter((id): id is string => id !== null), | |
| 79 | 139 | ); |
| 80 | 140 | // The newest open change of every app, one read — a person's own change |
| 81 | 141 | // counts here too, not only one an agent run produced. |
| @@ -118,18 +178,41 @@ export async function shelfRows( | ||
| 118 | 178 | runtime, |
| 119 | 179 | record, |
| 120 | 180 | }; |
| 121 | const word = useOpenPull | |
| 122 | ? await pullWord(wordCtx, openPull) | |
| 123 | : latest === undefined | |
| 181 | const agentWord = | |
| 182 | useOpenPull || latest === undefined | |
| 124 | 183 | ? undefined |
| 125 | 184 | : await runWord(wordCtx, latest); |
| 185 | const agentAt = | |
| 186 | latest === undefined ? undefined : (latest.finishedAt ?? latest.createdAt); | |
| 187 | const deployFact = | |
| 188 | useOpenPull || runIsMoving ? undefined : productionFacts.get(repo.id); | |
| 189 | // Production's fact only displaces the run's own word when the run's | |
| 190 | // word is not already *shipped* — a change stays credited to whoever's | |
| 191 | // history carries it, never double-told as the deploy behind it — or | |
| 192 | // when it is a failure at least as new: #200's case outranks a stale | |
| 193 | // *shipped* the same way a fresher one would. | |
| 194 | const useDeployFact = | |
| 195 | deployFact !== undefined && | |
| 196 | (agentWord === undefined || | |
| 197 | deployFact.word === "stopped" || | |
| 198 | agentWord.word !== "shipped") && | |
| 199 | (agentAt === undefined || deployFact.at >= agentAt); | |
| 200 | const word = useOpenPull | |
| 201 | ? await pullWord(wordCtx, openPull) | |
| 202 | : useDeployFact | |
| 203 | ? { word: deployFact!.word, note: deployFact!.note } | |
| 204 | : agentWord; | |
| 126 | 205 | const who = useOpenPull |
| 127 | 206 | ? ownerFacts.has(repo.id) |
| 128 | 207 | ? openPull.authorName |
| 129 | 208 | : undefined |
| 130 | : latest?.actorId == null || !ownerFacts.has(repo.id) | |
| 131 | ? undefined | |
| 132 | : actors.get(latest.actorId)?.username; | |
| 209 | : useDeployFact | |
| 210 | ? deployFact!.actorId == null || !ownerFacts.has(repo.id) | |
| 211 | ? undefined | |
| 212 | : actors.get(deployFact!.actorId)?.username | |
| 213 | : latest?.actorId == null || !ownerFacts.has(repo.id) | |
| 214 | ? undefined | |
| 215 | : actors.get(latest.actorId)?.username; | |
| 133 | 216 | const asks = asksByRepo.get(repo.id) ?? 0; |
| 134 | 217 | // The badge is production's own word (`envPhrase`) — whether a newer |
| 135 | 218 | // push failed behind what is serving is a fact of the environment, not |
| @@ -166,7 +249,9 @@ export async function shelfRows( | ||
| 166 | 249 | ? html`· now` |
| 167 | 250 | : useOpenPull |
| 168 | 251 | ? html`· ${time(openPull.updatedAt)}` |
| 169 | : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`; | |
| 252 | : useDeployFact | |
| 253 | ? html`· ${time(deployFact!.at)}` | |
| 254 | : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`; | |
| 170 | 255 | // Two apps can share a bare name across owners — a row for one that is |
| 171 | 256 | // not the viewer's own names its owner, everywhere a row can appear. |
| 172 | 257 | const mine = user !== undefined && repo.ownerId === user.id; |
src/web/studio/words.ts +85
| @@ -186,6 +186,91 @@ export async function runWord( | ||
| 186 | 186 | return { word: "planned", note: run.summary || "nothing to show yet" }; |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | /** | |
| 190 | * How long a production deploy stays news on the shelf and the front door's | |
| 191 | * rail, once nothing has happened since: long enough to survive a coffee | |
| 192 | * break after shipping, short enough that a deploy from last week does not | |
| 193 | * crowd out *quiet* as the invitation to tell the crew something. A run in | |
| 194 | * flight or an open change is never bounded this way — only the one fact | |
| 195 | * here that has no other way to expire. | |
| 196 | */ | |
| 197 | export const DEPLOY_NEWS_MS = 24 * 60 * 60_000; | |
| 198 | ||
| 199 | /** The one production fact worth telling beside a run or a change: a deploy | |
| 200 | * that just landed, or one that just failed. */ | |
| 201 | export interface ProductionFact { | |
| 202 | readonly word: Extract<Word, "shipped" | "stopped">; | |
| 203 | readonly note: string; | |
| 204 | readonly actorId: string | null; | |
| 205 | /** The deploy run this fact is about — a link to its log. */ | |
| 206 | readonly runId: string; | |
| 207 | /** The instant to sort and print this fact by. */ | |
| 208 | readonly at: string; | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * The rule the front door's *happening* half follows for a deploy, decided | |
| 213 | * once so the next case (a stalled deploy, a rollback) rebases on this | |
| 214 | * rather than growing a fifth special case: production's own status names | |
| 215 | * two candidate facts — the deploy now serving, and, when a newer one was | |
| 216 | * attempted and failed behind it, that failure (#200's case) — and this | |
| 217 | * picks whichever is news, preferring the failure when both are and the | |
| 218 | * failure is not older. Neither counts once `DEPLOY_NEWS_MS` has passed | |
| 219 | * without anything newer. | |
| 220 | * | |
| 221 | * A deploy no push asked for — a redeploy someone pressed, the boot | |
| 222 | * reconcile every restart runs — carries no `createdAt` (`apps/runtime.ts`'s | |
| 223 | * `PUSH_REASONS`), so it is never news: a person would not call it one, and | |
| 224 | * without this the front door would read *shipped* after every restart. | |
| 225 | * | |
| 226 | * `runs` is the batch lookup of whatever run ids `production` names, read | |
| 227 | * once for the whole shelf by the caller — never a query here. | |
| 228 | */ | |
| 229 | export function productionFact( | |
| 230 | production: EnvStatus | undefined, | |
| 231 | runs: ReadonlyMap<string, Run>, | |
| 232 | now = Date.now(), | |
| 233 | ): ProductionFact | undefined { | |
| 234 | if (production === undefined) return undefined; | |
| 235 | const news = (at: string | undefined): at is string => | |
| 236 | at !== undefined && now - Date.parse(at) <= DEPLOY_NEWS_MS; | |
| 237 | ||
| 238 | const failedRun = | |
| 239 | production.failedDeploy === undefined | |
| 240 | ? undefined | |
| 241 | : runs.get(production.failedDeploy.runId); | |
| 242 | const failedAt = failedRun?.finishedAt ?? failedRun?.createdAt; | |
| 243 | const failed: ProductionFact | undefined = | |
| 244 | production.failedDeploy !== undefined && failedRun !== undefined && news(failedAt) | |
| 245 | ? { | |
| 246 | word: "stopped", | |
| 247 | note: `${production.failedDeploy.commitOid.slice(0, 7)} did not come up — ${production.failedDeploy.error}`, | |
| 248 | actorId: failedRun.actorId, | |
| 249 | runId: failedRun.id, | |
| 250 | at: failedAt!, | |
| 251 | } | |
| 252 | : undefined; | |
| 253 | ||
| 254 | const shippedAt = production.servingAt ?? production.createdAt; | |
| 255 | const shipped: ProductionFact | undefined = | |
| 256 | production.state === "running" && | |
| 257 | production.createdAt !== undefined && | |
| 258 | production.runId !== undefined && | |
| 259 | news(shippedAt) | |
| 260 | ? { | |
| 261 | word: "shipped", | |
| 262 | note: `production is serving ${production.commitOid.slice(0, 7)}`, | |
| 263 | actorId: runs.get(production.runId)?.actorId ?? null, | |
| 264 | runId: production.runId, | |
| 265 | at: shippedAt!, | |
| 266 | } | |
| 267 | : undefined; | |
| 268 | ||
| 269 | if (failed !== undefined && shipped !== undefined) | |
| 270 | return failed.at >= shipped.at ? failed : shipped; | |
| 271 | return failed ?? shipped; | |
| 272 | } | |
| 273 | ||
| 189 | 274 | const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { |
| 190 | 275 | working: "loader", |
| 191 | 276 | look: "eye", |
test/studio.test.ts +218
| @@ -4323,6 +4323,224 @@ describe("an open change is never quiet on the front door", () => { | ||
| 4323 | 4323 | }); |
| 4324 | 4324 | }); |
| 4325 | 4325 | |
| 4326 | /** | |
| 4327 | * Issue #215: once a merged change closes, it drops out of | |
| 4328 | * `openPullsForRepos`, and before this the shelf and the rail had nothing | |
| 4329 | * left to say about it but "quiet" — the row's *happening* half spoke only | |
| 4330 | * for an agent run, never a deploy. `productionFact` (`words.ts`) reads | |
| 4331 | * production's own status for the fact a merge or a push leaves behind: | |
| 4332 | * the deploy that landed, or one that failed behind what still serves. | |
| 4333 | */ | |
| 4334 | describe("a deploy that just landed is news on the front door", () => { | |
| 4335 | let dataDir: string; | |
| 4336 | let app: App; | |
| 4337 | let servers: Servers; | |
| 4338 | let base: string; | |
| 4339 | let runtime: AppsRuntime; | |
| 4340 | let repo: Repo; | |
| 4341 | let record: AppRecord; | |
| 4342 | let quietRepo: Repo; | |
| 4343 | let alice: User; | |
| 4344 | let cookie: string; | |
| 4345 | let deployRun: Run; | |
| 4346 | ||
| 4347 | interface PreviewSlot { | |
| 4348 | readonly app: AppRecord; | |
| 4349 | readonly env: EnvName; | |
| 4350 | readonly chain: Promise<void>; | |
| 4351 | readonly restarts: number[]; | |
| 4352 | deploying?: unknown; | |
| 4353 | live?: unknown; | |
| 4354 | lastError?: string; | |
| 4355 | lastRunId?: string; | |
| 4356 | } | |
| 4357 | const putEnv = ( | |
| 4358 | appRecord: AppRecord, | |
| 4359 | env: EnvName, | |
| 4360 | slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">, | |
| 4361 | ): void => { | |
| 4362 | (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set( | |
| 4363 | `${appRecord.id}/${env}`, | |
| 4364 | { app: appRecord, env, chain: Promise.resolve(), restarts: [], ...slot }, | |
| 4365 | ); | |
| 4366 | }; | |
| 4367 | ||
| 4368 | const get = (path: string) => | |
| 4369 | fetch(`${base}${path}`, { | |
| 4370 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 4371 | redirect: "manual", | |
| 4372 | }); | |
| 4373 | ||
| 4374 | const shelfRowFor = (home: string, href: string): string => { | |
| 4375 | const at = home.indexOf(`href="${href}"`); | |
| 4376 | expect(at).toBeGreaterThan(-1); | |
| 4377 | const start = home.lastIndexOf("<li>", at); | |
| 4378 | return home.slice(start, home.indexOf("</li>", at) + 5); | |
| 4379 | }; | |
| 4380 | ||
| 4381 | beforeAll(async () => { | |
| 4382 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-shipped-")); | |
| 4383 | app = createApp({ dataDir, port: 0 }); | |
| 4384 | servers = await serve(app); | |
| 4385 | base = servers.url; | |
| 4386 | // Uncalled `.start()`: this runtime never subscribes to the bus, so | |
| 4387 | // nothing here spins a real process — its `envs` are only ever what | |
| 4388 | // `putEnv` puts there, the same fiction the live-fragment tests use. | |
| 4389 | runtime = new AppsRuntime(app, { | |
| 4390 | domain: "shipped.test", | |
| 4391 | scheme: "http", | |
| 4392 | publicPort: servers.port, | |
| 4393 | forgeUrl: servers.url, | |
| 4394 | concurrency: 1, | |
| 4395 | }); | |
| 4396 | const a = await createUser(app.db, { | |
| 4397 | username: "alice", | |
| 4398 | email: "[email protected]", | |
| 4399 | password: "a-good-passphrase", | |
| 4400 | adminIfFirst: true, | |
| 4401 | }); | |
| 4402 | if (!a.ok) throw new Error(a.error); | |
| 4403 | alice = a.value; | |
| 4404 | cookie = ( | |
| 4405 | ( | |
| 4406 | await fetch(`${base}/signin`, { | |
| 4407 | method: "POST", | |
| 4408 | body: new URLSearchParams({ | |
| 4409 | username: "alice", | |
| 4410 | password: "a-good-passphrase", | |
| 4411 | }), | |
| 4412 | redirect: "manual", | |
| 4413 | }) | |
| 4414 | ).headers.get("set-cookie") ?? "" | |
| 4415 | ).split(";")[0]!; | |
| 4416 | ||
| 4417 | const made = await createRepo(app, alice, { | |
| 4418 | name: "sap", | |
| 4419 | visibility: "private", | |
| 4420 | description: "a sap for notes", | |
| 4421 | }); | |
| 4422 | if (!made.ok) throw new Error(String(made.error)); | |
| 4423 | repo = made.value; | |
| 4424 | const seeded = await commitToBranch(app, alice, repo, { | |
| 4425 | message: "Start", | |
| 4426 | files: [{ path: "README.md", content: "sap\n" }], | |
| 4427 | }); | |
| 4428 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 4429 | const enabled = await enableApp(app, alice, repo, { slug: "sap" }); | |
| 4430 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 4431 | record = getAppByRepo(app.db, repo.id)!; | |
| 4432 | ||
| 4433 | deployRun = createRun(app.db, { | |
| 4434 | repoId: repo.id, | |
| 4435 | kind: "deploy", | |
| 4436 | env: "production", | |
| 4437 | commitOid: "6c99218", | |
| 4438 | reason: "merge", | |
| 4439 | actorId: alice.id, | |
| 4440 | }); | |
| 4441 | claimRun(app.db, deployRun.id); | |
| 4442 | deployRun = | |
| 4443 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }) ?? | |
| 4444 | deployRun; | |
| 4445 | const servingAt = new Date().toISOString(); | |
| 4446 | putEnv(record, "production", { | |
| 4447 | live: { | |
| 4448 | commitOid: "6c99218", | |
| 4449 | handle: { port: 4321 }, | |
| 4450 | runId: deployRun.id, | |
| 4451 | since: servingAt, | |
| 4452 | servingAt, | |
| 4453 | createdAt: servingAt, | |
| 4454 | manifest: { tty: null }, | |
| 4455 | }, | |
| 4456 | }); | |
| 4457 | ||
| 4458 | // A second app, otherwise identical, that never shipped — the control | |
| 4459 | // that must keep reading quiet. | |
| 4460 | const madeQuiet = await createRepo(app, alice, { | |
| 4461 | name: "quiet2", | |
| 4462 | visibility: "private", | |
| 4463 | description: "says nothing", | |
| 4464 | }); | |
| 4465 | if (!madeQuiet.ok) throw new Error(String(madeQuiet.error)); | |
| 4466 | const seededQuiet = await commitToBranch(app, alice, madeQuiet.value, { | |
| 4467 | message: "Start", | |
| 4468 | files: [{ path: "README.md", content: "quiet\n" }], | |
| 4469 | }); | |
| 4470 | if (!seededQuiet.ok) throw new Error(JSON.stringify(seededQuiet.error)); | |
| 4471 | const enabledQuiet = await enableApp(app, alice, madeQuiet.value, { | |
| 4472 | slug: "quiet2", | |
| 4473 | }); | |
| 4474 | if (!enabledQuiet.ok) throw new Error(String(enabledQuiet.error)); | |
| 4475 | quietRepo = madeQuiet.value; | |
| 4476 | }); | |
| 4477 | ||
| 4478 | afterAll(async () => { | |
| 4479 | servers.stop(); | |
| 4480 | app.close(); | |
| 4481 | await rm(dataDir, { recursive: true, force: true }); | |
| 4482 | }); | |
| 4483 | ||
| 4484 | test("the shelf row reads shipped, with who and when, not quiet", async () => { | |
| 4485 | const home = await (await get("/")).text(); | |
| 4486 | const shelfRow = shelfRowFor(home, "/alice/sap"); | |
| 4487 | expect(shelfRow).not.toContain("quiet — tell the crew something"); | |
| 4488 | expect(shelfRow).toContain('data-word="shipped"'); | |
| 4489 | expect(shelfRow).toContain("alice"); | |
| 4490 | }); | |
| 4491 | ||
| 4492 | test('"Happening now" links to what shipped instead of reporting silence', async () => { | |
| 4493 | const home = await (await get("/")).text(); | |
| 4494 | expect(home).not.toContain("Nobody is working right now."); | |
| 4495 | expect(home).toContain(`href="/alice/sap/runs/${deployRun.id}"`); | |
| 4496 | }); | |
| 4497 | ||
| 4498 | test("an app with no recent deploy still reads quiet", async () => { | |
| 4499 | const home = await (await get("/")).text(); | |
| 4500 | const shelfRow = shelfRowFor(home, `/alice/${quietRepo.name}`); | |
| 4501 | expect(shelfRow).toContain("quiet — tell the crew something"); | |
| 4502 | }); | |
| 4503 | ||
| 4504 | test("a deploy no push asked for is not news: a redeploy or the boot reconcile does not read shipped", async () => { | |
| 4505 | const restartedAt = new Date().toISOString(); | |
| 4506 | putEnv(record, "production", { | |
| 4507 | live: { | |
| 4508 | commitOid: "6c99218", | |
| 4509 | handle: { port: 4321 }, | |
| 4510 | runId: deployRun.id, | |
| 4511 | since: restartedAt, | |
| 4512 | servingAt: restartedAt, | |
| 4513 | // No `createdAt`: nothing asked a push for this one — a redeploy | |
| 4514 | // someone pressed, or the boot reconcile every restart runs. | |
| 4515 | manifest: { tty: null }, | |
| 4516 | }, | |
| 4517 | }); | |
| 4518 | const home = await (await get("/")).text(); | |
| 4519 | const shelfRow = shelfRowFor(home, "/alice/sap"); | |
| 4520 | expect(shelfRow).not.toContain('data-word="shipped"'); | |
| 4521 | expect(shelfRow).toContain("quiet — tell the crew something"); | |
| 4522 | }); | |
| 4523 | ||
| 4524 | test("a deploy older than the news window reads quiet again, not shipped forever", async () => { | |
| 4525 | const old = new Date(Date.now() - 25 * 60 * 60_000).toISOString(); | |
| 4526 | putEnv(record, "production", { | |
| 4527 | live: { | |
| 4528 | commitOid: "6c99218", | |
| 4529 | handle: { port: 4321 }, | |
| 4530 | runId: deployRun.id, | |
| 4531 | since: old, | |
| 4532 | servingAt: old, | |
| 4533 | createdAt: old, | |
| 4534 | manifest: { tty: null }, | |
| 4535 | }, | |
| 4536 | }); | |
| 4537 | const home = await (await get("/")).text(); | |
| 4538 | const shelfRow = shelfRowFor(home, "/alice/sap"); | |
| 4539 | expect(shelfRow).not.toContain('data-word="shipped"'); | |
| 4540 | expect(shelfRow).toContain("quiet — tell the crew something"); | |
| 4541 | }); | |
| 4542 | }); | |
| 4543 | ||
| 4326 | 4544 | /** |
| 4327 | 4545 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 4328 | 4546 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline