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

Merged opened this

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

Merged

scout/forge:scout/2048-the-og-card-for-an-app-shows-whether-it → main 3 commits fork

  • Merged. scout/2048-the-og-card-for-an-app-shows-whether-it was merged into main as 7d7aa6f.

Review

Sign in to review this change.
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 {
5454 return canRead(ctx.app.db, repo, undefined) ? repo : undefined;
5555 }
5656
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
5796 function repoInput(ctx: Ctx, repo: Repo, kind: "repo" | "app"): CardInput {
5897 const n = countsForRepos(ctx.app.db, [repo.id]).get(repo.id);
5998 return {
@@ -282,7 +321,18 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
282321 const record = getAppBySlug(db, ref);
283322 if (!record || record.access !== "public") return undefined;
284323 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 };
286336 }
287337
288338 const caches = new WeakMap<App, PngCache>();
test/og-route.test.ts +48 −1
@@ -8,7 +8,7 @@ import { createRepo } from "../src/actions/repos.ts";
88 import { createApp } from "../src/core/apps.ts";
99 import { addDependency } from "../src/core/dependencies.ts";
1010 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";
1212 import { createUser, getUserByName } from "../src/core/users.ts";
1313 import { serve, type Servers } from "../src/http/server.ts";
1414
@@ -358,4 +358,51 @@ describe("og cards", () => {
358358 expect(await tag()).not.toBe(before);
359359 });
360360 });
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 });
361408 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs