Public: anyone can read this; the crew can change it.
The OG card for an app shows whether it is live and which crew member is working on it now #2268
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2048-the-og-card-for-an-app-shows-whether-it → main 3 commits fork
2 files
changed +99
−2
src/web/pages/og.ts +51 −1
| @@ -54,6 +54,45 @@ function publicRepo(ctx: Ctx, repo: Repo | undefined): Repo | undefined { | ||
| 54 | 54 | return canRead(ctx.app.db, repo, undefined) ? repo : undefined; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | /** At most this many crew members are named on an app card. */ | |
| 58 | const NAMED_CREW = 3; | |
| 59 | ||
| 60 | /** | |
| 61 | * The app as stored runs say it is, never probed: "deploying" while a | |
| 62 | * production deploy is open and not yet swapped in, "up" once one has | |
| 63 | * served, "down" otherwise; and when production last started serving. | |
| 64 | */ | |
| 65 | function liveState( | |
| 66 | ctx: Ctx, | |
| 67 | repo: Repo, | |
| 68 | ): { state: "up" | "down" | "deploying"; since: string | null } { | |
| 69 | const row = ctx.app.db | |
| 70 | .query<{ open: number; since: string | null }, [string]>( | |
| 71 | `SELECT EXISTS (SELECT 1 FROM run WHERE repo_id = ?1 AND kind = 'deploy' | |
| 72 | AND env = 'production' AND state IN ('queued','running') | |
| 73 | AND serving_at IS NULL) AS open, | |
| 74 | (SELECT MAX(serving_at) FROM run WHERE repo_id = ?1 AND kind = 'deploy' | |
| 75 | AND env = 'production') AS since`, | |
| 76 | ) | |
| 77 | .get(repo.id); | |
| 78 | const since = row?.since ?? null; | |
| 79 | return { state: row?.open ? "deploying" : since ? "up" : "down", since }; | |
| 80 | } | |
| 81 | ||
| 82 | /** Who has an agent run going on the repo now, visible runs only, at most three. */ | |
| 83 | function crewNow(ctx: Ctx, repo: Repo): string { | |
| 84 | const db = ctx.app.db; | |
| 85 | const names: string[] = []; | |
| 86 | for (const run of listActiveRuns(db, [repo.id])) { | |
| 87 | if (!mayViewId(db, repo, run.issueId, undefined)) continue; | |
| 88 | const who = run.actorId === null ? undefined : getUserById(db, run.actorId)?.username; | |
| 89 | if (who && !names.includes(who)) names.push(who); | |
| 90 | } | |
| 91 | if (names.length === 0) return "crew: idle"; | |
| 92 | const more = names.length - NAMED_CREW; | |
| 93 | return `crew: ${names.slice(0, NAMED_CREW).join(", ")}${more > 0 ? ` +${more}` : ""}`; | |
| 94 | } | |
| 95 | ||
| 57 | 96 | function repoInput(ctx: Ctx, repo: Repo, kind: "repo" | "app"): CardInput { |
| 58 | 97 | const n = countsForRepos(ctx.app.db, [repo.id]).get(repo.id); |
| 59 | 98 | return { |
| @@ -282,7 +321,18 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | | ||
| 282 | 321 | const record = getAppBySlug(db, ref); |
| 283 | 322 | if (!record || record.access !== "public") return undefined; |
| 284 | 323 | const repo = publicRepo(ctx, getRepoById(db, record.repoId)); |
| 285 | return repo && { ...repoInput(ctx, repo, "app"), title: record.slug }; | |
| 324 | if (!repo) return undefined; | |
| 325 | const live = liveState(ctx, repo); | |
| 326 | const base = repoInput(ctx, repo, "app"); | |
| 327 | return { | |
| 328 | ...base, | |
| 329 | title: record.slug, | |
| 330 | lines: [ | |
| 331 | ...(base.lines ?? []), | |
| 332 | `${live.state}${live.since ? ` · deployed ${live.since.slice(0, 10)}` : ""}`, | |
| 333 | crewNow(ctx, repo), | |
| 334 | ], | |
| 335 | }; | |
| 286 | 336 | } |
| 287 | 337 | |
| 288 | 338 | const caches = new WeakMap<App, PngCache>(); |
test/og-route.test.ts +48 −1
| @@ -8,7 +8,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 8 | 8 | import { createApp } from "../src/core/apps.ts"; |
| 9 | 9 | import { addDependency } from "../src/core/dependencies.ts"; |
| 10 | 10 | import { getIssue, createIssue } from "../src/core/issues.ts"; |
| 11 | import { cancelRun, claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 11 | import { cancelRun, claimRun, createRun, finishRun, markServing } from "../src/core/runs.ts"; | |
| 12 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | |
| @@ -358,4 +358,51 @@ describe("og cards", () => { | ||
| 358 | 358 | expect(await tag()).not.toBe(before); |
| 359 | 359 | }); |
| 360 | 360 | }); |
| 361 | describe("app live state", () => { | |
| 362 | const mkApp = async (name: string, slug: string) => { | |
| 363 | const alice = getUserByName(app.db, "alice")!; | |
| 364 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 365 | if (!made.ok) throw new Error(String(made.error)); | |
| 366 | const rec = createApp(app.db, { repoId: made.value.id, slug, access: "public" }); | |
| 367 | if (!rec.ok) throw new Error(rec.error); | |
| 368 | return { repoId: made.value.id, alice }; | |
| 369 | }; | |
| 370 | ||
| 371 | test("an up app with a crew run in progress says so", async () => { | |
| 372 | const { repoId, alice } = await mkApp("livey", "livey-app"); | |
| 373 | const deploy = createRun(app.db, { repoId, kind: "deploy", env: "production" }); | |
| 374 | claimRun(app.db, deploy.id); | |
| 375 | markServing(app.db, deploy.id); | |
| 376 | finishRun(app.db, deploy.id, { state: "ok", summary: "running" }); | |
| 377 | createRun(app.db, { repoId, kind: "agent", actorId: alice.id }); | |
| 378 | const body = await (await get("/og/app/livey-app.svg")).text(); | |
| 379 | expect(body).toContain("up · deployed "); | |
| 380 | expect(body).toContain("crew: alice"); | |
| 381 | }); | |
| 382 | ||
| 383 | test("a down app with no crew says so", async () => { | |
| 384 | await mkApp("darky", "darky-app"); | |
| 385 | const body = await (await get("/og/app/darky-app.svg")).text(); | |
| 386 | expect(body).toContain("down"); | |
| 387 | expect(body).not.toContain("deployed"); | |
| 388 | expect(body).toContain("crew: idle"); | |
| 389 | }); | |
| 390 | ||
| 391 | test("an open production deploy is deploying, and a sealed run names no one", async () => { | |
| 392 | const { repoId, alice } = await mkApp("busy", "busy-app"); | |
| 393 | createRun(app.db, { repoId, kind: "deploy", env: "production" }); | |
| 394 | const sealed = createIssue(app.db, { | |
| 395 | repoId, | |
| 396 | authorId: alice.id, | |
| 397 | title: "secret", | |
| 398 | kind: "issue", | |
| 399 | sealed: true, | |
| 400 | }); | |
| 401 | if (!sealed.ok) throw new Error(String(sealed.error)); | |
| 402 | createRun(app.db, { repoId, kind: "agent", actorId: alice.id, issueId: sealed.value.id }); | |
| 403 | const body = await (await get("/og/app/busy-app.svg")).text(); | |
| 404 | expect(body).toContain("deploying"); | |
| 405 | expect(body).toContain("crew: idle"); | |
| 406 | }); | |
| 407 | }); | |
| 361 | 408 | }); |
new above
offline