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

Merged opened this

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

Merged

scout/forge:scout/54-shipped-means-production-is-serving-it-t → main 2 commits fork

  • Merged. scout/54-shipped-means-production-is-serving-it-t was merged into main as ba40b4d.

Review

Sign in to review this change.
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.
1919 | **shipped** | a merge to the default branch, production redeployed |
2020 | **asks you** | a pull the crew stopped on, or a green one for a look |
2121
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
2238 These are also a kanban board: tell is the backlog, working is in progress,
2339 look is review, shipped is done, asks you is blocked. The crew's slot count
2440 (**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.
106122
107123 `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself —
108124 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`,
110126 `src/web/pages/studio.ts`) with its deploy one click away, the preview's
111127 state with the link that opens it (`previewCard`, the same card the aside
112128 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(
176176 return join(db, issue);
177177 }
178178
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
179202 /**
180203 * Open pulls, in any repository, whose head is this branch of this one:
181204 * what a push to a fork moves.
src/web/pages/runs.ts +2 −2
@@ -333,7 +333,7 @@ export function runsList(ctx: Ctx): Response {
333333 // ---- one run ----
334334
335335 // GET /:owner/:name/runs/:id
336 export function runPage(ctx: Ctx): Response {
336 export async function runPage(ctx: Ctx): Promise<Response> {
337337 const repo = loadRepo(ctx);
338338 if (!repo) return notFoundPage(ctx);
339339 const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
@@ -346,7 +346,7 @@ export function runPage(ctx: Ctx): Response {
346346 const studio =
347347 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
348348 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);
350350 const base = repoBase(repo);
351351 const actor =
352352 run.actorId === null ? undefined : getUserById(ctx.app.db, run.actorId);
src/web/pages/studio.ts +220 −89
@@ -7,10 +7,15 @@
77 * hand.
88 *
99 * 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.
1214 */
1315
16 import type { Database } from "bun:sqlite";
17 import { mergeBase } from "../../git/merge.ts";
18 import { repoDir } from "../../git/repos.ts";
1419 import {
1520 addComment,
1621 assignIssue,
@@ -40,6 +45,7 @@ import {
4045 joinMany,
4146 listPulls,
4247 openPullsClosing,
48 pullForBranch,
4349 type Pull,
4450 } from "../../core/pulls.ts";
4551 import {
@@ -578,7 +584,120 @@ function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
578584
579585 type Word = "working" | "look" | "shipped" | "stopped" | "waiting" | "planned";
580586
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 }> {
582701 if (run.state === "queued")
583702 return run.notBefore === null
584703 ? { word: "waiting", note: "queued" }
@@ -586,12 +705,10 @@ function wordFor(run: Run): { word: Word; note: string } {
586705 if (run.state === "running") return { word: "working", note: "now" };
587706 if (run.state === "failed" || run.state === "cancelled")
588707 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);
595712 }
596713
597714 const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
@@ -623,22 +740,6 @@ function wordBadge(word: Word, id?: string): Html {
623740 >`;
624741 }
625742
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
642743 // ---- previews: every open pull's preview, one click away ----
643744
644745 /** The three words the studio uses for a preview, whatever its check says. */
@@ -807,6 +908,12 @@ export async function studioApp(
807908 ): Promise<Response> {
808909 const db = ctx.app.db;
809910 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 };
810917 const crew = listCrewRoles(db, repo.id);
811918 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
812919 const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 });
@@ -822,10 +929,6 @@ export async function studioApp(
822929 }
823930 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
824931 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
825 const pullChecks = latestRunsForIssues(
826 db,
827 openPulls.map((p) => p.id),
828 );
829932 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
830933 const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
831934 (r) => r.createdAt >= week,
@@ -900,54 +1003,46 @@ export async function studioApp(
9001003 )
9011004 .slice(0, 6);
9021005 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 )),
9511046 ...waiting.map((i) =>
9521047 row({
9531048 href: `${base}/issues/${i.number}`,
@@ -1137,13 +1232,13 @@ const RUN_TRANSCRIPT_CAP = 400;
11371232 * forms `runPage` offers, posting to the very same routes with the very
11381233 * same checks; `?mode=dev` reads the transcript there, unchanged.
11391234 */
1140 export function studioRun(
1235 export async function studioRun(
11411236 ctx: Ctx,
11421237 repo: Repo,
11431238 run: Run,
11441239 lines: readonly string[],
11451240 truncated: boolean,
1146 ): Response {
1241 ): Promise<Response> {
11471242 const db = ctx.app.db;
11481243 const base = repoBase(repo);
11491244 const actor =
@@ -1152,7 +1247,13 @@ export function studioRun(
11521247 run.issueId === null ? undefined : getIssueById(db, run.issueId);
11531248 const who = actor?.username ?? "an agent";
11541249 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);
11561257 const unfinished = !isTerminal(run.state);
11571258 const writer = canWrite(db, repo, ctx.auth.user);
11581259 const duration = durationText(run);
@@ -1365,8 +1466,27 @@ export async function studioPull(
13651466 const db = ctx.app.db;
13661467 const base = repoBase(repo);
13671468 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 }
13701490 const decision = decisionsFor(ctx, [repo]).find(
13711491 (d) => d.pull?.id === pull.id,
13721492 );
@@ -1403,11 +1523,11 @@ export async function studioPull(
14031523 html`<p class="studio-pull-check">
14041524 ${wordBadge(word)} <span class="muted small">${note}</span>
14051525 ${when(
1406 check.run,
1407 (run) =>
1526 deploy,
1527 (d) =>
14081528 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
14111531 >`,
14121532 )}
14131533 </p>`,
@@ -1501,13 +1621,24 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
15011621 for (const d of decisions)
15021622 asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1);
15031623
1504 const appRows = appRepos.map((repo) => {
1624 const appRows = await Promise.all(appRepos.map(async (repo) => {
15051625 const record = records.get(repo.id)!;
15061626 const envs = runtime === undefined ? [] : runtime.status(record);
15071627 const production = envs.find((e) => e.env === "production");
15081628 const state = production?.state ?? "stopped";
15091629 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 );
15111642 const who =
15121643 latest?.actorId == null
15131644 ? undefined
@@ -1547,7 +1678,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
15471678 : state}</span
15481679 >`,
15491680 });
1550 });
1681 }));
15511682
15521683 const apps =
15531684 appRows.length === 0
test/studio.test.ts +197 −2
@@ -29,13 +29,15 @@ import {
2929 claimRun,
3030 createRun,
3131 finishRun,
32 markServing,
3233 noteRunProgress,
3334 } from "../src/core/runs.ts";
3435 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3536 import type { Decider } from "../src/crew/decide.ts";
3637 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
3738 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";
3941
4042 /** One transparent pixel, dropped where a look would sit. */
4143 const PNG = Buffer.from(
@@ -1468,6 +1470,182 @@ describe("the studio's slot count", () => {
14681470 });
14691471 });
14701472
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
14711649 /**
14721650 * Working, on its own page: `/:owner/:name/runs/:id` in the studio names
14731651 * 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", () => {
16011779 test("a finished run renders in the studio's own word, with no Stop", async () => {
16021780 const tell = openIssue(app, alice, repo, { title: "Tidy the footer" });
16031781 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.
16041798 const run = createRun(app.db, {
16051799 repoId: repo.id,
16061800 kind: "agent",
16071801 issueId: tell.value.id,
1802 branch: "scout/tidy-the-footer",
16081803 actorId: scoutId,
16091804 reason: "assigned",
16101805 });
16111806 claimRun(app.db, run.id);
16121807 finishRun(app.db, run.id, {
16131808 state: "ok",
1614 summary: "opened pull #9",
1809 summary: `opened pull #${opened.value.number}`,
16151810 costUsd: 0.1,
16161811 turns: 4,
16171812 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs