Public: anyone can read this; the crew can change it.
Shipped means production is serving it: the studio's words come from facts, not a regex on a summary #68
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/54-shipped-means-production-is-serving-it-t → main 2 commits fork
5 files
changed +459
−94
docs/STUDIO.md +17 −1
| @@ -19,6 +19,22 @@ Every word on a studio page is one thing in git, and dev mode shows it. | ||
| 19 | 19 | | **shipped** | a merge to the default branch, production redeployed | |
| 20 | 20 | | **asks you** | a pull the crew stopped on, or a green one for a look | |
| 21 | 21 | |
| 22 | Every word above is decided in one place (`runWord` and `pullWord`, | |
| 23 | `src/web/pages/studio.ts`), from facts — never from a run's own summary | |
| 24 | text, which stays the note beside the word. A run's word comes from its | |
| 25 | state, and once it is over, from what it produced: a pull, judged by | |
| 26 | `pullWord`, or nothing to show, which is *planned* rather than a guess at | |
| 27 | *shipped*. A pull's word comes from its own state: open goes by its check | |
| 28 | (`checkRunWord`), closed without a merge is *stopped*, and merged is | |
| 29 | *shipped* only once production's live commit is at or past the one the | |
| 30 | merge carried (an ancestry check in git, not equality — a later merge | |
| 31 | already deploying it still counts). Between a merge and production | |
| 32 | catching up, the word is its own honest one — *working* while production | |
| 33 | deploys it, *stopped* if that deploy failed, *waiting* if production has | |
| 34 | not started on it yet — and it becomes *shipped* on the next render or | |
| 35 | live event, no reload needed. The app page, the change page and the home | |
| 36 | all call the same two functions; nothing here is re-derived per surface. | |
| 37 | ||
| 22 | 38 | These are also a kanban board: tell is the backlog, working is in progress, |
| 23 | 39 | look is review, shipped is done, asks you is blocked. The crew's slot count |
| 24 | 40 | (**Settings → Autonomy**) is the WIP limit. The map is the plan. A week's |
| @@ -106,7 +122,7 @@ mode, renders exactly what it always has. | ||
| 106 | 122 | |
| 107 | 123 | `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself — |
| 108 | 124 | never its diff. The title and the opening post in the author's own words, |
| 109 | who made it and when, the check in the studio's five words (`checkWord`, | |
| 125 | who made it and when, the check in the studio's five words (`pullWord`, | |
| 110 | 126 | `src/web/pages/studio.ts`) with its deploy one click away, the preview's |
| 111 | 127 | state with the link that opens it (`previewCard`, the same card the aside |
| 112 | 128 | carries in dev), and the look at phone and desktop width when one has been |
src/core/pulls.ts +23
| @@ -176,6 +176,29 @@ export function getPull( | ||
| 176 | 176 | return join(db, issue); |
| 177 | 177 | } |
| 178 | 178 | |
| 179 | /** | |
| 180 | * The newest pull, in any state, whose head is this branch of this | |
| 181 | * repository — what an agent run's own `branch` names, and how | |
| 182 | * `landWork` (`crew/runner.ts`) finds the pull it already opened. A pull | |
| 183 | * is its own issue, unrelated in the schema to the issue that asked for | |
| 184 | * it; the branch is the only durable link a run's outcome leaves behind. | |
| 185 | */ | |
| 186 | export function pullForBranch( | |
| 187 | db: Database, | |
| 188 | repoId: string, | |
| 189 | branch: string, | |
| 190 | ): Pull | undefined { | |
| 191 | const row = db | |
| 192 | .query< | |
| 193 | { number: number }, | |
| 194 | [string, string] | |
| 195 | >(`SELECT issue.number AS number FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 196 | WHERE issue.repo_id = ?1 AND pull.head_branch = ?2 | |
| 197 | ORDER BY issue.created_at DESC LIMIT 1`) | |
| 198 | .get(repoId, branch); | |
| 199 | return row ? getPull(db, repoId, row.number) : undefined; | |
| 200 | } | |
| 201 | ||
| 179 | 202 | /** |
| 180 | 203 | * Open pulls, in any repository, whose head is this branch of this one: |
| 181 | 204 | * what a push to a fork moves. |
src/web/pages/runs.ts +2 −2
| @@ -333,7 +333,7 @@ export function runsList(ctx: Ctx): Response { | ||
| 333 | 333 | // ---- one run ---- |
| 334 | 334 | |
| 335 | 335 | // GET /:owner/:name/runs/:id |
| 336 | export function runPage(ctx: Ctx): Response { | |
| 336 | export async function runPage(ctx: Ctx): Promise<Response> { | |
| 337 | 337 | const repo = loadRepo(ctx); |
| 338 | 338 | if (!repo) return notFoundPage(ctx); |
| 339 | 339 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| @@ -346,7 +346,7 @@ export function runPage(ctx: Ctx): Response { | ||
| 346 | 346 | const studio = |
| 347 | 347 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; |
| 348 | 348 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) |
| 349 | return studioRun(ctx, repo, run, lines, truncated); | |
| 349 | return await studioRun(ctx, repo, run, lines, truncated); | |
| 350 | 350 | const base = repoBase(repo); |
| 351 | 351 | const actor = |
| 352 | 352 | run.actorId === null ? undefined : getUserById(ctx.app.db, run.actorId); |
src/web/pages/studio.ts +220 −89
| @@ -7,10 +7,15 @@ | ||
| 7 | 7 | * hand. |
| 8 | 8 | * |
| 9 | 9 | * Words: *tell* is an issue; *working* is a run; *look* is an open pull |
| 10 | * with a green check and a preview; *shipped* is a merge; *asks you* is a | |
| 11 | * pull the crew stopped on, or a green one waiting for a look. | |
| 10 | * with a green check and a preview; *shipped* is a merge production has | |
| 11 | * caught up to; *asks you* is a pull the crew stopped on, or a green one | |
| 12 | * waiting for a look. `runWord` and `pullWord` are the one place these are | |
| 13 | * decided, from facts — never from a run's own summary text. | |
| 12 | 14 | */ |
| 13 | 15 | |
| 16 | import type { Database } from "bun:sqlite"; | |
| 17 | import { mergeBase } from "../../git/merge.ts"; | |
| 18 | import { repoDir } from "../../git/repos.ts"; | |
| 14 | 19 | import { |
| 15 | 20 | addComment, |
| 16 | 21 | assignIssue, |
| @@ -40,6 +45,7 @@ import { | ||
| 40 | 45 | joinMany, |
| 41 | 46 | listPulls, |
| 42 | 47 | openPullsClosing, |
| 48 | pullForBranch, | |
| 43 | 49 | type Pull, |
| 44 | 50 | } from "../../core/pulls.ts"; |
| 45 | 51 | import { |
| @@ -578,7 +584,120 @@ function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null { | ||
| 578 | 584 | |
| 579 | 585 | type Word = "working" | "look" | "shipped" | "stopped" | "waiting" | "planned"; |
| 580 | 586 | |
| 581 | function wordFor(run: Run): { word: Word; note: string } { | |
| 587 | /** | |
| 588 | * Everything the word functions below need to see production: the repo's | |
| 589 | * git directory (an ancestry check reads it), and the runtime plus the | |
| 590 | * app record, when the repository runs one. Built once per page and | |
| 591 | * threaded through — never a second way to ask what is serving. | |
| 592 | */ | |
| 593 | export interface WordCtx { | |
| 594 | readonly db: Database; | |
| 595 | readonly dir: string; | |
| 596 | readonly runtime: AppsRuntime | undefined; | |
| 597 | readonly record: AppRecord | undefined; | |
| 598 | } | |
| 599 | ||
| 600 | /** | |
| 601 | * The pull an agent run produced, if it produced one — not a foreign key | |
| 602 | * (a pull is its own issue, unrelated in the schema to the issue it | |
| 603 | * closes) but the two facts a run leaves behind: a review run's | |
| 604 | * `issueId` is the pull itself, and a builder run's `branch` is the one | |
| 605 | * `landWork` (`crew/runner.ts`) already uses to find its own pull again. | |
| 606 | */ | |
| 607 | function pullFromRun(db: Database, run: Run): Pull | undefined { | |
| 608 | if (run.issueId !== null) { | |
| 609 | const about = getIssueById(db, run.issueId); | |
| 610 | if (about?.kind === "pull") return getPull(db, run.repoId, about.number); | |
| 611 | } | |
| 612 | if (run.branch === "") return undefined; | |
| 613 | return pullForBranch(db, run.repoId, run.branch); | |
| 614 | } | |
| 615 | ||
| 616 | /** Whether production is serving this commit, or something later than it. */ | |
| 617 | async function caughtUp( | |
| 618 | dir: string, | |
| 619 | production: EnvStatus | undefined, | |
| 620 | commitOid: string, | |
| 621 | ): Promise<boolean> { | |
| 622 | if (production === undefined || production.commitOid === "") return false; | |
| 623 | if (production.commitOid === commitOid) return true; | |
| 624 | return (await mergeBase(dir, commitOid, production.commitOid)) === commitOid; | |
| 625 | } | |
| 626 | ||
| 627 | /** A merged pull production has not caught up to yet: its own honest word. */ | |
| 628 | function mergedPullWord( | |
| 629 | production: EnvStatus | undefined, | |
| 630 | commitOid: string, | |
| 631 | ): { word: Word; note: string } { | |
| 632 | if (production?.commitOid === commitOid) { | |
| 633 | if (production.state === "failed") | |
| 634 | return { | |
| 635 | word: "stopped", | |
| 636 | note: production.error ?? "production's deploy of it failed", | |
| 637 | }; | |
| 638 | return { word: "working", note: "production is deploying it now" }; | |
| 639 | } | |
| 640 | return { word: "waiting", note: "merged; waiting for production to redeploy" }; | |
| 641 | } | |
| 642 | ||
| 643 | /** A single open pull's check, in the studio's own words — never dev's | |
| 644 | * "Checks passed/failed": the same five words as the rest of the page. */ | |
| 645 | function checkRunWord(check: CheckSummary): { word: Word; note: string } { | |
| 646 | if (check.state === "ok") | |
| 647 | return { word: "look", note: "green — ready for a look" }; | |
| 648 | if (check.state === "failed") | |
| 649 | return check.run?.servingAt != null | |
| 650 | ? { word: "stopped", note: "serving, but the check failed" } | |
| 651 | : { word: "stopped", note: "failed before it answered" }; | |
| 652 | if (check.state === "running") | |
| 653 | return { | |
| 654 | word: "working", | |
| 655 | note: check.run?.servingAt != null ? "serving — still checking" : "checking now", | |
| 656 | }; | |
| 657 | if (check.state === "queued") | |
| 658 | return { word: "working", note: "queued to check" }; | |
| 659 | if (check.state === "cancelled") | |
| 660 | return { word: "stopped", note: "the check was cancelled" }; | |
| 661 | return { word: "waiting", note: "no deploy of this head yet" }; | |
| 662 | } | |
| 663 | ||
| 664 | /** | |
| 665 | * A pull's word: open goes by its own check, closed without a merge is | |
| 666 | * simply stopped, and merged is *shipped* only once production is at or | |
| 667 | * past the commit that merge carried — the one place that fact is asked, | |
| 668 | * so the app page, the change page, the home and the board never invent | |
| 669 | * their own answer to it. | |
| 670 | */ | |
| 671 | export async function pullWord( | |
| 672 | ctx: WordCtx, | |
| 673 | pull: Pull, | |
| 674 | ): Promise<{ word: Word; note: string }> { | |
| 675 | if (pull.mergedAt !== null) { | |
| 676 | const commitOid = pull.mergedOid ?? pull.headOid; | |
| 677 | if (ctx.runtime === undefined || ctx.record === undefined) | |
| 678 | return { word: "shipped", note: "merged" }; | |
| 679 | const production = ctx.runtime | |
| 680 | .status(ctx.record) | |
| 681 | .find((e) => e.env === "production"); | |
| 682 | if (await caughtUp(ctx.dir, production, commitOid)) | |
| 683 | return { word: "shipped", note: "production is serving it" }; | |
| 684 | return mergedPullWord(production, commitOid); | |
| 685 | } | |
| 686 | if (pull.state === "closed") | |
| 687 | return { word: "stopped", note: "closed without shipping" }; | |
| 688 | return checkRunWord(checkSummary(ctx.db, pull.id, pull.headOid)); | |
| 689 | } | |
| 690 | ||
| 691 | /** | |
| 692 | * An agent run's word: still going by its state, and once it is over, by | |
| 693 | * what it produced — a pull, judged the same way `pullWord` judges any | |
| 694 | * other, or nothing to show, which is *planned* rather than a guess at | |
| 695 | * *shipped*. Never `run.summary`: that stays the note beside the word. | |
| 696 | */ | |
| 697 | export async function runWord( | |
| 698 | ctx: WordCtx, | |
| 699 | run: Run, | |
| 700 | ): Promise<{ word: Word; note: string }> { | |
| 582 | 701 | if (run.state === "queued") |
| 583 | 702 | return run.notBefore === null |
| 584 | 703 | ? { word: "waiting", note: "queued" } |
| @@ -586,12 +705,10 @@ function wordFor(run: Run): { word: Word; note: string } { | ||
| 586 | 705 | if (run.state === "running") return { word: "working", note: "now" }; |
| 587 | 706 | if (run.state === "failed" || run.state === "cancelled") |
| 588 | 707 | return { word: "stopped", note: run.summary || run.state }; |
| 589 | const summary = run.summary || ""; | |
| 590 | if (/opened pull|updated pull/.test(summary)) | |
| 591 | return { word: "look", note: summary }; | |
| 592 | if (/planned|no changes pushed/.test(summary)) | |
| 593 | return { word: "planned", note: summary }; | |
| 594 | return { word: "shipped", note: summary }; | |
| 708 | const pull = pullFromRun(ctx.db, run); | |
| 709 | if (pull === undefined) | |
| 710 | return { word: "planned", note: run.summary || "nothing to show yet" }; | |
| 711 | return await pullWord(ctx, pull); | |
| 595 | 712 | } |
| 596 | 713 | |
| 597 | 714 | const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { |
| @@ -623,22 +740,6 @@ function wordBadge(word: Word, id?: string): Html { | ||
| 623 | 740 | >`; |
| 624 | 741 | } |
| 625 | 742 | |
| 626 | /** A single open pull's check, in the studio's own words — never dev's | |
| 627 | * "Checks passed/failed": the same five words as the rest of the page. */ | |
| 628 | function checkWord(check: CheckSummary): { word: Word; note: string } { | |
| 629 | if (check.state === "ok") | |
| 630 | return { word: "look", note: "green — ready for a look" }; | |
| 631 | if (check.state === "failed") | |
| 632 | return { word: "stopped", note: "the check failed" }; | |
| 633 | if (check.state === "running") | |
| 634 | return { word: "working", note: "checking now" }; | |
| 635 | if (check.state === "queued") | |
| 636 | return { word: "working", note: "queued to check" }; | |
| 637 | if (check.state === "cancelled") | |
| 638 | return { word: "stopped", note: "the check was cancelled" }; | |
| 639 | return { word: "waiting", note: "no deploy of this head yet" }; | |
| 640 | } | |
| 641 | ||
| 642 | 743 | // ---- previews: every open pull's preview, one click away ---- |
| 643 | 744 | |
| 644 | 745 | /** The three words the studio uses for a preview, whatever its check says. */ |
| @@ -807,6 +908,12 @@ export async function studioApp( | ||
| 807 | 908 | ): Promise<Response> { |
| 808 | 909 | const db = ctx.app.db; |
| 809 | 910 | const base = repoBase(repo); |
| 911 | const wordCtx: WordCtx = { | |
| 912 | db, | |
| 913 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 914 | runtime: appsRuntime(ctx.app), | |
| 915 | record, | |
| 916 | }; | |
| 810 | 917 | const crew = listCrewRoles(db, repo.id); |
| 811 | 918 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); |
| 812 | 919 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }); |
| @@ -822,10 +929,6 @@ export async function studioApp( | ||
| 822 | 929 | } |
| 823 | 930 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); |
| 824 | 931 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); |
| 825 | const pullChecks = latestRunsForIssues( | |
| 826 | db, | |
| 827 | openPulls.map((p) => p.id), | |
| 828 | ); | |
| 829 | 932 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); |
| 830 | 933 | const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( |
| 831 | 934 | (r) => r.createdAt >= week, |
| @@ -900,54 +1003,46 @@ export async function studioApp( | ||
| 900 | 1003 | ) |
| 901 | 1004 | .slice(0, 6); |
| 902 | 1005 | const nowRows: Html[] = [ |
| 903 | ...openPulls.map((p) => { | |
| 904 | const check = pullChecks.get(p.id); | |
| 905 | const word: Word = | |
| 906 | check?.state === "ok" | |
| 907 | ? "look" | |
| 908 | : check?.state === "failed" | |
| 909 | ? "stopped" | |
| 910 | : "working"; | |
| 911 | return row({ | |
| 912 | href: `${base}/pulls/${p.number}`, | |
| 913 | nav: true, | |
| 914 | lead: avatarOf(p.authorName), | |
| 915 | title: p.title, | |
| 916 | meta: html`${p.authorName} · | |
| 917 | ${word === "look" | |
| 918 | ? "green, ready for a look" | |
| 919 | : word === "stopped" | |
| 920 | ? "the check failed" | |
| 921 | : "checking"} | |
| 922 | · ${time(p.createdAt)}`, | |
| 923 | trail: wordBadge(word), | |
| 924 | }); | |
| 925 | }), | |
| 926 | ...rowsNow.map((r) => { | |
| 927 | const { word, note } = wordFor(r); | |
| 928 | const who = | |
| 929 | r.actorId === null | |
| 930 | ? "crew" | |
| 931 | : (actors.get(r.actorId)?.username ?? "crew"); | |
| 932 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 933 | const stop = stopForm(ctx, repo, r); | |
| 934 | return row({ | |
| 935 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 936 | lead: avatarOf(who), | |
| 937 | title: stop | |
| 938 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 939 | : (about?.title ?? note), | |
| 940 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 941 | ${word === "working" | |
| 942 | ? null | |
| 943 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 944 | trail: stop | |
| 945 | ? html`<span class="studio-ask-actions" | |
| 946 | >${wordBadge(word)} ${stop}</span | |
| 947 | >` | |
| 948 | : wordBadge(word), | |
| 949 | }); | |
| 950 | }), | |
| 1006 | ...(await Promise.all( | |
| 1007 | openPulls.map(async (p) => { | |
| 1008 | const { word, note } = await pullWord(wordCtx, p); | |
| 1009 | return row({ | |
| 1010 | href: `${base}/pulls/${p.number}`, | |
| 1011 | nav: true, | |
| 1012 | lead: avatarOf(p.authorName), | |
| 1013 | title: p.title, | |
| 1014 | meta: html`${p.authorName} · ${note} · ${time(p.createdAt)}`, | |
| 1015 | trail: wordBadge(word), | |
| 1016 | }); | |
| 1017 | }), | |
| 1018 | )), | |
| 1019 | ...(await Promise.all( | |
| 1020 | rowsNow.map(async (r) => { | |
| 1021 | const { word, note } = await runWord(wordCtx, r); | |
| 1022 | const who = | |
| 1023 | r.actorId === null | |
| 1024 | ? "crew" | |
| 1025 | : (actors.get(r.actorId)?.username ?? "crew"); | |
| 1026 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 1027 | const stop = stopForm(ctx, repo, r); | |
| 1028 | return row({ | |
| 1029 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 1030 | lead: avatarOf(who), | |
| 1031 | title: stop | |
| 1032 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 1033 | : (about?.title ?? note), | |
| 1034 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 1035 | ${word === "working" | |
| 1036 | ? null | |
| 1037 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 1038 | trail: stop | |
| 1039 | ? html`<span class="studio-ask-actions" | |
| 1040 | >${wordBadge(word)} ${stop}</span | |
| 1041 | >` | |
| 1042 | : wordBadge(word), | |
| 1043 | }); | |
| 1044 | }), | |
| 1045 | )), | |
| 951 | 1046 | ...waiting.map((i) => |
| 952 | 1047 | row({ |
| 953 | 1048 | href: `${base}/issues/${i.number}`, |
| @@ -1137,13 +1232,13 @@ const RUN_TRANSCRIPT_CAP = 400; | ||
| 1137 | 1232 | * forms `runPage` offers, posting to the very same routes with the very |
| 1138 | 1233 | * same checks; `?mode=dev` reads the transcript there, unchanged. |
| 1139 | 1234 | */ |
| 1140 | export function studioRun( | |
| 1235 | export async function studioRun( | |
| 1141 | 1236 | ctx: Ctx, |
| 1142 | 1237 | repo: Repo, |
| 1143 | 1238 | run: Run, |
| 1144 | 1239 | lines: readonly string[], |
| 1145 | 1240 | truncated: boolean, |
| 1146 | ): Response { | |
| 1241 | ): Promise<Response> { | |
| 1147 | 1242 | const db = ctx.app.db; |
| 1148 | 1243 | const base = repoBase(repo); |
| 1149 | 1244 | const actor = |
| @@ -1152,7 +1247,13 @@ export function studioRun( | ||
| 1152 | 1247 | run.issueId === null ? undefined : getIssueById(db, run.issueId); |
| 1153 | 1248 | const who = actor?.username ?? "an agent"; |
| 1154 | 1249 | const title = runTitle(run, actor?.username, issue); |
| 1155 | const { word } = wordFor(run); | |
| 1250 | const wordCtx: WordCtx = { | |
| 1251 | db, | |
| 1252 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 1253 | runtime: appsRuntime(ctx.app), | |
| 1254 | record: getAppByRepo(db, repo.id), | |
| 1255 | }; | |
| 1256 | const { word } = await runWord(wordCtx, run); | |
| 1156 | 1257 | const unfinished = !isTerminal(run.state); |
| 1157 | 1258 | const writer = canWrite(db, repo, ctx.auth.user); |
| 1158 | 1259 | const duration = durationText(run); |
| @@ -1365,8 +1466,27 @@ export async function studioPull( | ||
| 1365 | 1466 | const db = ctx.app.db; |
| 1366 | 1467 | const base = repoBase(repo); |
| 1367 | 1468 | const thread = `${base}/pulls/${pull.number}`; |
| 1368 | const check = checkSummary(db, pull.id, pull.headOid); | |
| 1369 | const { word, note } = checkWord(check); | |
| 1469 | const wordCtx: WordCtx = { | |
| 1470 | db, | |
| 1471 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 1472 | runtime: appsRuntime(ctx.app), | |
| 1473 | record, | |
| 1474 | }; | |
| 1475 | const { word, note } = await pullWord(wordCtx, pull); | |
| 1476 | // Before a merge the deploy worth linking is the preview's own check; | |
| 1477 | // after, it is production's — the preview's run may already be torn | |
| 1478 | // down, and the word above is judged by production regardless. | |
| 1479 | let deploy: { id: string; commitOid: string } | undefined; | |
| 1480 | if (pull.mergedAt === null) { | |
| 1481 | const run = checkSummary(db, pull.id, pull.headOid).run; | |
| 1482 | if (run) deploy = { id: run.id, commitOid: run.commitOid }; | |
| 1483 | } else { | |
| 1484 | const production = wordCtx.runtime | |
| 1485 | ?.status(record) | |
| 1486 | .find((e) => e.env === "production"); | |
| 1487 | if (production?.runId) | |
| 1488 | deploy = { id: production.runId, commitOid: production.commitOid }; | |
| 1489 | } | |
| 1370 | 1490 | const decision = decisionsFor(ctx, [repo]).find( |
| 1371 | 1491 | (d) => d.pull?.id === pull.id, |
| 1372 | 1492 | ); |
| @@ -1403,11 +1523,11 @@ export async function studioPull( | ||
| 1403 | 1523 | html`<p class="studio-pull-check"> |
| 1404 | 1524 | ${wordBadge(word)} <span class="muted small">${note}</span> |
| 1405 | 1525 | ${when( |
| 1406 | check.run, | |
| 1407 | (run) => | |
| 1526 | deploy, | |
| 1527 | (d) => | |
| 1408 | 1528 | html`<span class="where-sep" aria-hidden="true">·</span |
| 1409 | ><a href="${base}/runs/${run.id}" | |
| 1410 | >deploy ${run.commitOid.slice(0, 7)}</a | |
| 1529 | ><a href="${base}/runs/${d.id}" | |
| 1530 | >deploy ${d.commitOid.slice(0, 7)}</a | |
| 1411 | 1531 | >`, |
| 1412 | 1532 | )} |
| 1413 | 1533 | </p>`, |
| @@ -1501,13 +1621,24 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 1501 | 1621 | for (const d of decisions) |
| 1502 | 1622 | asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1); |
| 1503 | 1623 | |
| 1504 | const appRows = appRepos.map((repo) => { | |
| 1624 | const appRows = await Promise.all(appRepos.map(async (repo) => { | |
| 1505 | 1625 | const record = records.get(repo.id)!; |
| 1506 | 1626 | const envs = runtime === undefined ? [] : runtime.status(record); |
| 1507 | 1627 | const production = envs.find((e) => e.env === "production"); |
| 1508 | 1628 | const state = production?.state ?? "stopped"; |
| 1509 | 1629 | const latest = latestRuns.get(repo.id); |
| 1510 | const word = latest === undefined ? undefined : wordFor(latest); | |
| 1630 | const word = | |
| 1631 | latest === undefined | |
| 1632 | ? undefined | |
| 1633 | : await runWord( | |
| 1634 | { | |
| 1635 | db, | |
| 1636 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 1637 | runtime, | |
| 1638 | record, | |
| 1639 | }, | |
| 1640 | latest, | |
| 1641 | ); | |
| 1511 | 1642 | const who = |
| 1512 | 1643 | latest?.actorId == null |
| 1513 | 1644 | ? undefined |
| @@ -1547,7 +1678,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 1547 | 1678 | : state}</span |
| 1548 | 1679 | >`, |
| 1549 | 1680 | }); |
| 1550 | }); | |
| 1681 | })); | |
| 1551 | 1682 | |
| 1552 | 1683 | const apps = |
| 1553 | 1684 | appRows.length === 0 |
test/studio.test.ts +197 −2
| @@ -29,13 +29,15 @@ import { | ||
| 29 | 29 | claimRun, |
| 30 | 30 | createRun, |
| 31 | 31 | finishRun, |
| 32 | markServing, | |
| 32 | 33 | noteRunProgress, |
| 33 | 34 | } from "../src/core/runs.ts"; |
| 34 | 35 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 35 | 36 | import type { Decider } from "../src/crew/decide.ts"; |
| 36 | 37 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; |
| 37 | 38 | import { serve, type Servers } from "../src/http/server.ts"; |
| 38 | import { pendingLeans } from "../src/web/pages/studio.ts"; | |
| 39 | import { pendingLeans, pullWord, runWord, type WordCtx } from "../src/web/pages/studio.ts"; | |
| 40 | import { repoDir } from "../src/git/repos.ts"; | |
| 39 | 41 | |
| 40 | 42 | /** One transparent pixel, dropped where a look would sit. */ |
| 41 | 43 | const PNG = Buffer.from( |
| @@ -1468,6 +1470,182 @@ describe("the studio's slot count", () => { | ||
| 1468 | 1470 | }); |
| 1469 | 1471 | }); |
| 1470 | 1472 | |
| 1473 | describe("words come from facts, not from run.summary", () => { | |
| 1474 | let dataDir: string; | |
| 1475 | let app: App; | |
| 1476 | let servers: Servers; | |
| 1477 | let runtime: AppsRuntime; | |
| 1478 | let repo: Repo; | |
| 1479 | let record: AppRecord; | |
| 1480 | let alice: User; | |
| 1481 | let wordCtx: WordCtx; | |
| 1482 | let seededCommit: string; | |
| 1483 | ||
| 1484 | const put = ( | |
| 1485 | live?: { commitOid: string; state?: "failed" }, | |
| 1486 | ): void => { | |
| 1487 | const envs = (runtime as unknown as { envs: Map<string, unknown> }).envs; | |
| 1488 | envs.set(`${record.id}/production`, { | |
| 1489 | app: record, | |
| 1490 | env: "production", | |
| 1491 | chain: Promise.resolve(), | |
| 1492 | restarts: [], | |
| 1493 | ...(live?.state === "failed" | |
| 1494 | ? { lastError: "the deploy failed" } | |
| 1495 | : live | |
| 1496 | ? { | |
| 1497 | live: { | |
| 1498 | commitOid: live.commitOid, | |
| 1499 | handle: { port: 4321 }, | |
| 1500 | runId: "fake", | |
| 1501 | since: new Date().toISOString(), | |
| 1502 | }, | |
| 1503 | } | |
| 1504 | : {}), | |
| 1505 | }); | |
| 1506 | }; | |
| 1507 | ||
| 1508 | beforeAll(async () => { | |
| 1509 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-words-")); | |
| 1510 | app = createApp({ dataDir, port: 0 }); | |
| 1511 | servers = await serve(app); | |
| 1512 | runtime = new AppsRuntime(app, { | |
| 1513 | domain: "words.test", | |
| 1514 | scheme: "http", | |
| 1515 | publicPort: servers.port, | |
| 1516 | forgeUrl: servers.url, | |
| 1517 | concurrency: 1, | |
| 1518 | }); | |
| 1519 | const a = await createUser(app.db, { | |
| 1520 | username: "alice", | |
| 1521 | email: "[email protected]", | |
| 1522 | password: "a-good-passphrase", | |
| 1523 | adminIfFirst: true, | |
| 1524 | }); | |
| 1525 | if (!a.ok) throw new Error(a.error); | |
| 1526 | alice = a.value; | |
| 1527 | const made = await createRepo(app, alice, { | |
| 1528 | name: "wordsapp", | |
| 1529 | visibility: "private", | |
| 1530 | description: "", | |
| 1531 | }); | |
| 1532 | if (!made.ok) throw new Error(String(made.error)); | |
| 1533 | repo = made.value; | |
| 1534 | const seeded = await commitToBranch(app, alice, repo, { | |
| 1535 | message: "Start", | |
| 1536 | files: [{ path: "README.md", content: "start\n" }], | |
| 1537 | }); | |
| 1538 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 1539 | seededCommit = seeded.value.commitOid; | |
| 1540 | const enabled = await enableApp(app, alice, repo, { slug: "wordsapp" }); | |
| 1541 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1542 | record = getAppByRepo(app.db, repo.id)!; | |
| 1543 | wordCtx = { | |
| 1544 | db: app.db, | |
| 1545 | dir: repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 1546 | runtime, | |
| 1547 | record, | |
| 1548 | }; | |
| 1549 | }); | |
| 1550 | ||
| 1551 | afterAll(async () => { | |
| 1552 | servers.stop(); | |
| 1553 | app.close(); | |
| 1554 | await rm(dataDir, { recursive: true, force: true }); | |
| 1555 | }); | |
| 1556 | ||
| 1557 | test("a merged pull whose production deploy has not yet served does not say shipped, and says so once it has", async () => { | |
| 1558 | const branch = await commitToBranch(app, alice, repo, { | |
| 1559 | branch: "feature/word", | |
| 1560 | base: "main", | |
| 1561 | message: "Add a word", | |
| 1562 | files: [{ path: "WORD.md", content: "word\n" }], | |
| 1563 | }); | |
| 1564 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1565 | const opened = await openPull(app, alice, repo, { | |
| 1566 | title: "Add a word", | |
| 1567 | body: "", | |
| 1568 | baseBranch: "main", | |
| 1569 | headBranch: "feature/word", | |
| 1570 | }); | |
| 1571 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1572 | const merged = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 1573 | if (!merged.ok) throw new Error(String(merged.error)); | |
| 1574 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 1575 | expect(pull.mergedOid).not.toBeNull(); | |
| 1576 | ||
| 1577 | // Production is still on the commit before the merge: not shipped. | |
| 1578 | put({ commitOid: seededCommit }); | |
| 1579 | const notYet = await pullWord(wordCtx, pull); | |
| 1580 | expect(notYet.word).not.toBe("shipped"); | |
| 1581 | ||
| 1582 | // Production has caught up to the merge commit: shipped, live. | |
| 1583 | put({ commitOid: pull.mergedOid! }); | |
| 1584 | const now = await pullWord(wordCtx, pull); | |
| 1585 | expect(now.word).toBe("shipped"); | |
| 1586 | }); | |
| 1587 | ||
| 1588 | test('an agent run that opened no pull and pushed nothing is not shipped', async () => { | |
| 1589 | const told = await openIssue(app, alice, repo, { | |
| 1590 | title: "Write a plan", | |
| 1591 | body: "", | |
| 1592 | }); | |
| 1593 | if (!told.ok) throw new Error(String(told.error)); | |
| 1594 | const run = createRun(app.db, { | |
| 1595 | repoId: repo.id, | |
| 1596 | kind: "agent", | |
| 1597 | issueId: told.value.id, | |
| 1598 | branch: "scout/999-a-branch-nothing-used", | |
| 1599 | actorId: alice.id, | |
| 1600 | reason: "assigned", | |
| 1601 | }); | |
| 1602 | claimRun(app.db, run.id); | |
| 1603 | const finished = finishRun(app.db, run.id, { | |
| 1604 | state: "ok", | |
| 1605 | summary: "wrote docs/PLAN.md", | |
| 1606 | }); | |
| 1607 | const word = await runWord(wordCtx, finished!); | |
| 1608 | expect(word.word).not.toBe("shipped"); | |
| 1609 | expect(word.word).toBe("planned"); | |
| 1610 | expect(word.note).toBe("wrote docs/PLAN.md"); | |
| 1611 | }); | |
| 1612 | ||
| 1613 | test("a deploy serving with failed tests is not shipped", async () => { | |
| 1614 | const branch = await commitToBranch(app, alice, repo, { | |
| 1615 | branch: "feature/preview", | |
| 1616 | base: "main", | |
| 1617 | message: "Preview", | |
| 1618 | files: [{ path: "PREVIEW.md", content: "preview\n" }], | |
| 1619 | }); | |
| 1620 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1621 | const opened = await openPull(app, alice, repo, { | |
| 1622 | title: "Preview", | |
| 1623 | body: "", | |
| 1624 | baseBranch: "main", | |
| 1625 | headBranch: "feature/preview", | |
| 1626 | }); | |
| 1627 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1628 | const pull = opened.value; | |
| 1629 | const deploy = createRun(app.db, { | |
| 1630 | repoId: repo.id, | |
| 1631 | kind: "check", | |
| 1632 | issueId: pull.id, | |
| 1633 | env: `pull-${pull.number}`, | |
| 1634 | commitOid: pull.headOid, | |
| 1635 | reason: "pull", | |
| 1636 | }); | |
| 1637 | claimRun(app.db, deploy.id); | |
| 1638 | markServing(app.db, deploy.id); | |
| 1639 | finishRun(app.db, deploy.id, { | |
| 1640 | state: "failed", | |
| 1641 | summary: "test: 1 of 3 tests failed", | |
| 1642 | }); | |
| 1643 | const word = await pullWord(wordCtx, getPull(app.db, repo.id, pull.number)!); | |
| 1644 | expect(word.word).not.toBe("shipped"); | |
| 1645 | expect(word.note).toBe("serving, but the check failed"); | |
| 1646 | }); | |
| 1647 | }); | |
| 1648 | ||
| 1471 | 1649 | /** |
| 1472 | 1650 | * Working, on its own page: `/:owner/:name/runs/:id` in the studio names |
| 1473 | 1651 | * the tell, the cost, and offers the steer box — same routes and checks as |
| @@ -1601,17 +1779,34 @@ describe("working: a run's own page in the studio", () => { | ||
| 1601 | 1779 | test("a finished run renders in the studio's own word, with no Stop", async () => { |
| 1602 | 1780 | const tell = openIssue(app, alice, repo, { title: "Tidy the footer" }); |
| 1603 | 1781 | if (!tell.ok) throw new Error(String(tell.error)); |
| 1782 | const branch = await commitToBranch(app, alice, repo, { | |
| 1783 | branch: "scout/tidy-the-footer", | |
| 1784 | base: "main", | |
| 1785 | message: "Tidy the footer", | |
| 1786 | files: [{ path: "FOOTER.md", content: "tidy\n" }], | |
| 1787 | }); | |
| 1788 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1789 | const opened = await openPull(app, alice, repo, { | |
| 1790 | title: "Tidy the footer", | |
| 1791 | body: `Closes #${tell.value.number}`, | |
| 1792 | baseBranch: "main", | |
| 1793 | headBranch: "scout/tidy-the-footer", | |
| 1794 | }); | |
| 1795 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1796 | // The word comes from the pull this run actually produced (found by | |
| 1797 | // its branch), not from parsing the summary below. | |
| 1604 | 1798 | const run = createRun(app.db, { |
| 1605 | 1799 | repoId: repo.id, |
| 1606 | 1800 | kind: "agent", |
| 1607 | 1801 | issueId: tell.value.id, |
| 1802 | branch: "scout/tidy-the-footer", | |
| 1608 | 1803 | actorId: scoutId, |
| 1609 | 1804 | reason: "assigned", |
| 1610 | 1805 | }); |
| 1611 | 1806 | claimRun(app.db, run.id); |
| 1612 | 1807 | finishRun(app.db, run.id, { |
| 1613 | 1808 | state: "ok", |
| 1614 | summary: "opened pull #9", | |
| 1809 | summary: `opened pull #${opened.value.number}`, | |
| 1615 | 1810 | costUsd: 0.1, |
| 1616 | 1811 | turns: 4, |
| 1617 | 1812 | }); |
new above
offline