Public: anyone can read this; the crew can change it.

One path a person can follow: check, review, land, live #7793

Merged opened this in answer to

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

Merged

torque/forge:torque/7703-one-path-a-person-can-follow-check-revie → main 7 commits fork

  • Merged. torque/7703-one-path-a-person-can-follow-check-revie was merged into main as ab19f88.

Review

Sign in to review this change.
17 files changed +555 −10
README.md +2
@@ -399,6 +399,8 @@ work here.
399399
400400 ## What works today
401401
402 Every pull says which of four steps it is at — check, review, land, live — and why it waits there: a line under its title on both tabs (linked to `/pipeline`, a public page that explains the four steps), `step: { step, why, done }` in the MCP `pull` tool, and `step: …` in the terminal pull screen's header. See [`docs/features/pipeline.md`](docs/features/pipeline.md).
403
402404 A public app of a public repository tells an agent that lands on it what it can
403405 do: its HTML pages, and the "not running" page, carry two `Link` headers —
404406 `rel="help"` to `/__forge/report` and `rel="alternate"` to the forge's
docs/MCP.md +1 −1
@@ -136,7 +136,7 @@ for a tunnel and a forge that sleeps.
136136 | `prompts` | (admin scope) `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
137137 | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes |
138138 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
139 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
139 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
140140 | `pulls` | `{ repo?, state?, limit?, cursor? }`: without `repo`, every open pull you may read, oldest first, as `{ pulls, next_cursor }` (limit 1-200, default 50): `repo`, `number`, `title`, `check`, `verdict` (approved, changes_requested or none) and `gate` (what holds it, or null; the `why` module's cause); with `repo`, a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
141141 | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | |
142142 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
docs/design/areas/lists.md +2
@@ -16,3 +16,5 @@ trails a palette row.
1616 minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows).
1717
1818 The proofs under a forge on `/forges` (#7959) are `row()` `children`, one per public address; the row's trail badge says "reachable" or "not reachable", so the state is a word and not only a colour. No new class.
19
20 `/pipeline` (#7703) is a `.list` of `row()`s, one per step, each folding (`fold`) to the parts inside it; no new class. The pull page's path line marks a finished path with a `badge("done")`, so the state is a word.
docs/design/audit.md +1
@@ -60,6 +60,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6060 - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen.
6161 - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen.
6262 - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here.
63 - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen.
6364 - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary.
6465 - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document.
6566 - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen.
docs/features/pipeline.md added +45
@@ -0,0 +1,45 @@
1 # One path: check, review, land, live (#7703)
2
3 Every change follows four steps, and a person only needs those four words:
4
5 1. **Check**: types, lint and the tests the change reaches run on the pull's own code.
6 2. **Review**: a reviewer approves the change or asks for changes.
7 3. **Land**: an approved, green pull merges into the default branch.
8 4. **Live**: a release builds the default branch and production serves it.
9
10 `/pipeline` says this to anyone. Each step's finer parts (the check queue, the
11 proof, the merge queue's gate, the release build and boot check) sit one
12 click down in a `<details>`. The live counts per stage stay on `/boards`,
13 whose Pipeline panel links back to `/pipeline`.
14
15 ## Where a pull is
16
17 `src/flow/path.ts` decides it: `pathStep` is plain data in, plain data out,
18 and `stepOfPull` reads one pull's facts. The rule is "the earliest step not
19 yet passed":
20
21 - **check** while a check on the head is queued, running, red or cancelled
22 (if no check is recorded, there is nothing to wait for);
23 - **review** while it is green (or has no check) and nobody has approved this head;
24 - **land** once it is approved and green;
25 - **live** once merged into an app's default branch, `done` when a production
26 deploy has served since the merge. A merge where nothing deploys ends at
27 **land**, `done`.
28
29 For an open pull the sentence comes from `nextActor` (`src/flow/next_actor.ts`),
30 so the step line and the "Next:" line never disagree. On the check step the
31 check is the reason, even before anyone has reviewed.
32
33 Under every pull's title (both tabs) the line reads, for example,
34 `Check · **Review** · Land · Live — No review yet.`, linked to `/pipeline`.
35 A finished path (live, or landed where nothing deploys) carries a "done" badge.
36 The MCP `pull` tool answers `step: { step, why, done }`, and the terminal's pull
37 screen says `step: review` beside `next:` in its header line.
38 `changeHead` computes "who acts next" once and hands it to `stepOfPull`.
39
40 ## Not here yet
41
42 The rest of #7703 is filed as its own issues: remove the proof (#7779), run
43 the full suite as a scheduled report (#7780), re-run a shard that crashed
44 outside any test (#7781), reuse a verdict for the same tree (#7782), carry the
45 step to the feed and the lists (#7783; the pull page, MCP and terminal have it), and measure each step (#7784).
forge.json +1 −1
@@ -3,7 +3,7 @@
33 "api.anthropic.com"
44 ],
55 "app": {
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"],
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/pipeline", "/resilience", "/settings"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/core/reserved.ts +1
@@ -65,6 +65,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6565 "board",
6666 "boards",
6767 "contribute",
68 "pipeline",
6869 "events",
6970 "explore",
7071 "family",
src/flow/path.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * The one path a change follows, in four words a person can hold (#7703):
3 * check, review, land, live. Every finer step (the check queue, the proof,
4 * the merge queue, the release build, the boot check) sits one click down on
5 * /boards; this module only says which of the four a pull is at and, in one
6 * plain sentence, why it waits there.
7 *
8 * `pathStep` is plain data in, plain data out; `stepOfPull` reads the facts
9 * for one pull, and the pull page, the change view and the MCP `pull` all
10 * answer from it. The sentence for an open pull is `nextActor`'s, so the
11 * step and "who acts next" never disagree.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import { getAppByRepo } from "../core/apps.ts";
16 import type { Pull } from "../core/pulls.ts";
17 import type { Repo } from "../core/repos.ts";
18 import { reviewSummary } from "../core/reviews.ts";
19 import { checkSummary, type RunState } from "../core/runs.ts";
20 import { nextOfPull, type Next } from "./next_actor.ts";
21
22 export const STEPS = ["check", "review", "land", "live"] as const;
23 export type Step = (typeof STEPS)[number];
24
25 /** What each step is for, in one line: the /pipeline page and the strip's titles read this. */
26 export const STEP_MEANS: Record<Step, string> = {
27 check: "Types, lint and the tests the change reaches run on the pull's own code.",
28 review: "A reviewer reads the change and approves it or asks for changes.",
29 land: "An approved, green pull merges into the default branch.",
30 live: "A release builds the default branch and production starts serving it.",
31 };
32
33 export interface PathStep {
34 readonly step: Step;
35 /** One plain sentence: why it waits here, or that it is done. */
36 readonly why: string;
37 /** True once the last step is reached: live in production, or landed where nothing deploys. */
38 readonly done: boolean;
39 }
40
41 export interface PathInput {
42 readonly state: "open" | "merged" | "closed";
43 /** The open pull's check on its head; ignored once merged. */
44 readonly check: RunState | "none";
45 readonly approvals: number;
46 /** Who acts next on an open pull (`nextActor`); its reason is the sentence. */
47 readonly next: Next | null;
48 /** Merged pulls only: whether the base is an app's default branch, and whether production deployed since. */
49 readonly deploys?: boolean;
50 readonly liveSince?: boolean;
51 }
52
53 const sentence = (s: string) => `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
54
55 /** Which step the pull is at; null for a pull closed without landing. */
56 export function pathStep(i: PathInput): PathStep | null {
57 if (i.state === "closed") return null;
58 if (i.state === "merged") {
59 if (i.deploys !== true)
60 return { step: "land", why: "Landed; nothing deploys from this branch, so this is the end of its path.", done: true };
61 return i.liveSince === true
62 ? { step: "live", why: "Landed and live in production.", done: true }
63 : { step: "live", why: "Landed; it goes live with the next production release.", done: false };
64 }
65 // The earliest of the four not yet passed: a check on this head that is not
66 // green, then an approval. No check recorded is no check to wait for.
67 const checking = i.check !== "ok" && i.check !== "none";
68 const step: Step = checking ? "check" : i.approvals > 0 ? "land" : "review";
69 // nextActor names review before a running check; on the check step the check is the reason.
70 const why =
71 step === "check" && (i.next === null || i.next.who === "reviewer")
72 ? CHECK_WORDS[i.check as Exclude<RunState, "ok">]
73 : (i.next?.why ?? "waiting");
74 return { step, why: sentence(why), done: false };
75 }
76
77 const CHECK_WORDS: Record<Exclude<RunState, "ok">, string> = {
78 queued: "its check is queued",
79 running: "its check is running",
80 failed: "its check is red",
81 cancelled: "its check was cancelled",
82 };
83
84 /** Whether production for `repo` has deployed since `at`. */
85 function deployedSince(db: Database, repoId: string, at: string): boolean {
86 return (
87 db
88 .query<{ n: number }, [string, string]>(
89 `SELECT 1 AS n FROM run
90 WHERE repo_id = ?1 AND kind = 'deploy' AND env = 'production'
91 AND created_at >= ?2 AND serving_at IS NOT NULL LIMIT 1`,
92 )
93 .get(repoId, at) !== null
94 );
95 }
96
97 /**
98 * The step of `pull` in `repo`. `live` is the open pull's head and conflicts
99 * as the caller already read them, and `next` is the `nextOfPull` it already
100 * computed for the same head, so one render reads the facts once.
101 */
102 export function stepOfPull(
103 db: Database,
104 repo: Repo,
105 pull: Pull,
106 live: { readonly headOid: string; readonly conflicts: number },
107 next: Next | null = pull.state === "open" && pull.mergedAt === null ? nextOfPull(db, pull, live) : null,
108 ): PathStep | null {
109 if (pull.mergedAt !== null) {
110 const deploys = pull.baseBranch === repo.defaultBranch && getAppByRepo(db, repo.id) !== undefined;
111 return pathStep({
112 state: "merged",
113 check: "ok",
114 approvals: 0,
115 next: null,
116 deploys,
117 liveSince: deploys && deployedSince(db, repo.id, pull.mergedAt),
118 });
119 }
120 if (pull.state !== "open") return null;
121 return pathStep({
122 state: "open",
123 check: checkSummary(db, pull.id, live.headOid).state,
124 approvals: reviewSummary(db, pull.id, live.headOid).approvals,
125 next,
126 });
127 }
src/http/mcp/tools/pull.ts +6 −1
@@ -6,6 +6,7 @@ import { previewEnv } from "../../../apps/data.ts";
66 import { checkStale } from "../../../checks/stale.ts";
77 import { checkSummary } from "../../../core/runs.ts";
88 import { nextOfPull } from "../../../flow/next_actor.ts";
9 import { stepOfPull } from "../../../flow/path.ts";
910 import { appView } from "../../../actions/apps.ts";
1011 import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts";
1112 import { isCrew } from "../../../core/agents.ts";
@@ -14,7 +15,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
1415
1516 export const tool: Tool = {
1617 name: "pull",
17 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
18 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
1819 description:
1920 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.",
2021 inputSchema: {
@@ -70,6 +71,10 @@ export const tool: Tool = {
7071 headOid: pull.headOid,
7172 conflicts: state?.conflicts?.length ?? 0,
7273 }),
74 step: stepOfPull(ctx.app.db, repo, pull, {
75 headOid: pull.headOid,
76 conflicts: state?.conflicts?.length ?? 0,
77 }),
7378 ...(state ?? {}),
7479 });
7580 // What the card needs and the text does not say: where the pull lives
src/http/server.ts +3
@@ -21,6 +21,7 @@ import { systemPage, systemStream } from "../web/pages/system.ts";
2121 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
2222 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
2323 import { contributePage } from "../web/pages/contribute.ts";
24 import { pipelinePage } from "../web/pages/pipeline.ts";
2425 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2526 import { usagePage } from "../web/pages/usage.ts";
2627 import { spendPage } from "../web/pages/spend-tab.ts";
@@ -857,6 +858,8 @@ export function buildRouter(): Router<Handler> {
857858 router.add("GET", "/system", systemPage);
858859 router.add("GET", "/system/stream", systemStream);
859860 router.add("GET", "/contribute", contributePage);
861 // The one path a change follows, in four words (#7703); literal, above /:owner.
862 router.add("GET", "/pipeline", pipelinePage);
860863 router.add("GET", "/settings/telemetry", telemetryPage);
861864 router.add("POST", "/settings/telemetry", telemetrySet);
862865 // Each THREATS.md protection against the last gate; literal, above /:owner.
src/tui/screens/pull.ts +17 −1
@@ -63,6 +63,7 @@ import { checkWords } from "../../checks/summary.ts";
6363 import { oneLine } from "../../checks/words.ts";
6464 import { resolveOid } from "../../git/merge.ts";
6565 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
66 import { stepOfPull, type PathStep } from "../../flow/path.ts";
6667 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
6768 import { voteVariant } from "../../actions/variants.ts";
6869 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -225,6 +226,8 @@ export function pullScreen(
225226 let threads: Thread[] = [];
226227 /** Who acts next, for a local open pull; the page says the same line. */
227228 let next: Next | null = null;
229 /** Which of check, review, land, live the pull is at; the page's step line. */
230 let step: PathStep | null = null;
228231 /** Where each team the issue names stands; empty when it names none. */
229232 let teams: TeamVerdict[] = [];
230233 /** Settled threads the reader has opened back up, for this visit only. */
@@ -324,6 +327,19 @@ export function pullScreen(
324327 headOid: row.headOid || (facts?.headOid ?? ""),
325328 conflicts: facts?.conflicts.length ?? 0,
326329 });
330 step =
331 row === undefined || settings === undefined
332 ? null
333 : stepOfPull(
334 f.db,
335 settings,
336 row,
337 {
338 headOid: row.headOid || (facts?.headOid ?? ""),
339 conflicts: facts?.conflicts.length ?? 0,
340 },
341 next,
342 );
327343 checkProgress = runningProgress(f.app.config.dataDir, check);
328344 const spec =
329345 repo === undefined || !isApp
@@ -1002,7 +1018,7 @@ export function pullScreen(
10021018 x1,
10031019 head!.y + 1,
10041020 truncate(
1005 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
1021 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${step.step}${step.done ? " (done)" : ""}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
10061022 Math.max(0, edge - x1),
10071023 ),
10081024 style(c.mutedFg, c.bg),
src/web/pages/flow-panel.ts +1 −1
@@ -211,7 +211,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
211211 </tbody>
212212 </table>
213213 </div>`,
214 { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false },
214 { head: html`<h2>Pipeline</h2><a class="muted small" href="/pipeline">The four steps</a><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false },
215215 )}
216216 ${verifiedCard(readVerified(app, reader.kind === "owner"), now)}
217217 ${hotFilesCard(app, reader)}
src/web/pages/pipeline.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * /pipeline, open to anyone: the one path a change follows, in four words
3 * (#7703). A newcomer reads it top to bottom and can say how a change gets
4 * from a pull to production; each step's finer machinery is one click down
5 * (the row folds), and the live counts per stage are on /boards. Every pull
6 * page links here from its path line (`changeHead`), and the MCP `pull`
7 * answers the same four words as `step`.
8 */
9 import type { Ctx } from "../../http/context.ts";
10 import { STEP_MEANS, STEPS, type Step } from "../../flow/path.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { html } from "../html.ts";
13 import { page } from "../layout.ts";
14 import { card, row } from "../ui.ts";
15
16 /** What a pull waits on at each step, what only that step catches, and the parts inside it. */
17 export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside: readonly string[] }> = {
18 check: {
19 waits: "a free check slot, then the check itself (minutes).",
20 catches: "a change that does not type-check, breaks the lint, or fails a test its own files reach.",
21 inside: [
22 "The gate script and the type check, on the pull's head.",
23 "The tests the change reaches (the fast tier), sharded across the check's machine.",
24 "A red main pauses pulls' checks for a while, so a pull is not blamed for main's red.",
25 ],
26 },
27 review: {
28 waits: "a reviewer: an agent for agent pulls, or a person who can write the repository.",
29 catches: "what no test asks: the wrong change, an unsafe one, or one nobody asked for.",
30 inside: [
31 "A verdict counts for the head it was given on; a new push asks for a fresh look.",
32 "Changes asked send the pull back to its builder, who pushes and is checked again.",
33 ],
34 },
35 land: {
36 waits: "the merge queue, which merges approved, green pulls into the default branch in order.",
37 catches: "a pull that no longer merges onto the base as it is now.",
38 inside: [
39 "Before it joins the queue, a pull is proved once on the base's tip; that proof is being removed (#7779).",
40 "The merge queue's gate: lint and types on the pull merged with the base's tip.",
41 "A conflicting pull goes back to its builder to rebase before it can land.",
42 ],
43 },
44 live: {
45 waits: "the next release of the default branch.",
46 catches: "a tree that builds and checks but does not boot or answer as a website.",
47 inside: [
48 "The release build, and a boot of the built image on an empty data directory.",
49 "The new version takes over the port only once it answers healthy.",
50 "After the swap the forge loads its own public pages; a failure is paged.",
51 ],
52 },
53 };
54
55 const title = (s: Step) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
56
57 // GET /pipeline
58 export function pipelinePage(ctx: Ctx): Response {
59 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
60 const rendering = performance.now();
61 const body = html`<div class="page-head">
62 <h1>How a change lands</h1>
63 <p class="muted">
64 Every change follows the same four steps:
65 <strong>check, review, land, live</strong>. A pull's page says which
66 one it is at and, in one sentence, why it waits there.
67 </p>
68 </div>
69 <ol class="list" id="path">
70 ${STEPS.map((s, i) => {
71 const d = STEP_DETAIL[s];
72 return row({
73 id: s,
74 data: { step: s },
75 title: `${i + 1}. ${title(s)}`,
76 meta: html`${STEP_MEANS[s]}`,
77 fold: {
78 id: `inside-${s}`,
79 open: false,
80 body: html`<div class="card-body">
81 <p class="small">It waits for ${d.waits} Only this step catches ${d.catches}</p>
82 <ul class="small">${d.inside.map((line) => html`<li>${line}</li>`)}</ul>
83 </div>`,
84 },
85 });
86 })}
87 </ol>
88 ${card(
89 html`<div class="card-body">
90 <p class="small">
91 The whole test suite does not stand in the path: a pull's check runs the tests its change reaches,
92 and the rest of the path runs lint and types. An admin sees how many changes sit at each step right now on
93 <a href="/boards">Boards</a>.
94 </p>
95 <p class="muted small">For agents: the MCP tool <span class="mono">pull</span> answers <span class="mono">step: { step, why, done }</span> with these four words.</p>
96 </div>`,
97 { head: html`<h2>Where to look next</h2>` },
98 )}`;
99 const response = page({ ctx, title: "How a change lands", body });
100 notePhase(ctx.request, "render", performance.now() - rendering);
101 return response;
102 }
src/web/pages/pull.ts +1 −2
@@ -107,7 +107,6 @@ import { previewEnv } from "../../apps/data.ts";
107107 import { specFindings } from "./pull-spec.ts";
108108 import { pullPreviewFindings } from "../../core/preview_findings.ts";
109109 import { crewActivityCard } from "./runs.ts";
110 import { nextOfPull } from "../../flow/next_actor.ts";
111110 import { holdCard } from "../studio/hold.ts";
112111 import {
113112 changeHead,
@@ -978,7 +977,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
978977 compact: true,
979978 ownTitle: true,
980979 })}
981 ${changeHead(ctx, repo, pull, nextOfPull(ctx.app.db, pull, { headOid: status.headOid, conflicts: status.conflicts.length }))}
980 ${changeHead(ctx, repo, pull, { headOid: status.headOid, conflicts: status.conflicts.length })}
982981 ${changeTabs(repo, pull, "files")}
983982 <p class="files-where muted small" id="files">
984983 <span class="mono">${headLabel} → ${pull.baseBranch}</span>
src/web/studio/change.ts +35 −3
@@ -52,6 +52,7 @@ import {
5252 btn,
5353 card,
5454 confirmAction,
55 badge,
5556 icon,
5657 originTag,
5758 seg,
@@ -83,7 +84,8 @@ import { previewVision } from "../../crew/vision.ts";
8384 import { visionPreviewCard } from "./direction.ts";
8485 import { holdCard } from "./hold.ts";
8586 import { pullMayRun } from "../../core/pull_trust.ts";
86 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
87 import { nextOfPull } from "../../flow/next_actor.ts";
88 import { STEP_MEANS, STEPS, stepOfPull, type PathStep } from "../../flow/path.ts";
8789 import { changeHref } from "./nouns.ts";
8890 import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
8991
@@ -187,7 +189,17 @@ function reviewEntry(
187189 /** A change's head, the same on both of its tabs: the title, its state,
188190 * who opened it and in answer to whom, where it came from, and what the
189191 * crew spent on it. */
190 export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = null): Html {
192 export function changeHead(
193 ctx: Ctx,
194 repo: Repo,
195 pull: Pull,
196 live: { readonly headOid: string; readonly conflicts: number } | null = null,
197 ): Html {
198 const next = live === null ? null : nextOfPull(ctx.app.db, pull, live);
199 const step =
200 live === null && pull.mergedAt === null
201 ? null
202 : stepOfPull(ctx.app.db, repo, pull, live ?? { headOid: pull.headOid, conflicts: 0 }, next);
191203 const who = toldBy(ctx.app.db, pull);
192204 const marks = marksByName(ctx.app.db, [pull.authorName, who ?? ""]);
193205 return html`<div class="page-head">
@@ -225,6 +237,7 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null =
225237 >`,
226238 )}
227239 </div>
240 ${when(step, pathLine)}
228241 ${when(
229242 next,
230243 (n) => html`<p class="next-actor muted small">Next: ${n.who}, ${n.why}.</p>`,
@@ -232,6 +245,25 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null =
232245 </div>`;
233246 }
234247
248 /**
249 * Where the pull is on the one path (#7703): the four words, the current one
250 * marked, and why it waits there. The words link to /pipeline, which says
251 * what each one means and holds the finer steps.
252 */
253 export function pathLine(p: PathStep): Html {
254 const at = STEPS.indexOf(p.step);
255 const words = STEPS.map((s, i) => {
256 const label = `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
257 if (i === at)
258 return html`<strong aria-current="step" title="${STEP_MEANS[s]}">${label}</strong>${p.done ? html` ${badge("done", { tone: "success" })}` : null}`;
259 return html`<span title="${STEP_MEANS[s]}">${label}</span>`;
260 });
261 return html`<p class="muted small" data-path="${p.step}">
262 <a href="/pipeline">${words.map((w, i) => html`${i > 0 ? " · " : ""}${w}`)}</a>
263 — ${p.why}
264 </p>`;
265 }
266
235267 /** The change's two steps: the change itself, and its Files one tab
236268 * deeper — never a mode, the same address family either way. */
237269 export function changeTabs(
@@ -597,7 +629,7 @@ async function changeConversation(
597629 compact: true,
598630 ownTitle: true,
599631 })}
600 ${changeHead(ctx, repo, pull, status === undefined ? null : nextOfPull(db, pull, { headOid, conflicts: status.conflicts.length }))}
632 ${changeHead(ctx, repo, pull, status === undefined ? null : { headOid, conflicts: status.conflicts.length })}
601633 ${changeTabs(repo, pull, "change")}
602634 ${when(siblings.length > 0, () =>
603635 html`<p class="muted small variant-link">
test/pipeline-path.test.ts added +208
@@ -0,0 +1,208 @@
1 /** The one path (#7703): four words, each pull at one of them with a sentence, /pipeline explaining them, and the MCP answering the same. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { mergePull, openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createApp as createAppRecord } from "../src/core/apps.ts";
13 import { getPull } from "../src/core/pulls.ts";
14 import { getRepoById } from "../src/core/repos.ts";
15 import { pathStep, stepOfPull, STEPS } from "../src/flow/path.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 describe("pathStep", () => {
22 const reviewer = { who: "reviewer", why: "no review yet" } as const;
23 test("the four words, in order", () => {
24 expect([...STEPS]).toEqual(["check", "review", "land", "live"]);
25 });
26 test("a running check is the check step, and says so even though nobody has reviewed", () => {
27 expect(pathStep({ state: "open", check: "running", approvals: 0, next: reviewer })).toEqual({
28 step: "check",
29 why: "Its check is running.",
30 done: false,
31 });
32 });
33 test("a red check keeps the builder's reason", () => {
34 const s = pathStep({ state: "open", check: "failed", approvals: 0, next: { who: "builder", why: "its check is red" } });
35 expect(s?.step).toBe("check");
36 expect(s?.why).toBe("Its check is red.");
37 });
38 test("green and unreviewed is review; no check recorded is not a check to wait for", () => {
39 expect(pathStep({ state: "open", check: "ok", approvals: 0, next: reviewer })?.step).toBe("review");
40 expect(pathStep({ state: "open", check: "none", approvals: 0, next: reviewer })).toEqual({
41 step: "review",
42 why: "No review yet.",
43 done: false,
44 });
45 });
46 test("approved and green is land, with the queue's reason", () => {
47 const s = pathStep({ state: "open", check: "ok", approvals: 1, next: { who: "queue", why: "approved and green" } });
48 expect(s).toEqual({ step: "land", why: "Approved and green.", done: false });
49 });
50 test("merged: live once production deployed, waiting for a release before, done where nothing deploys", () => {
51 const merged = { state: "merged", check: "ok", approvals: 0, next: null } as const;
52 expect(pathStep({ ...merged, deploys: true, liveSince: true })).toEqual({ step: "live", why: "Landed and live in production.", done: true });
53 expect(pathStep({ ...merged, deploys: true, liveSince: false })?.done).toBe(false);
54 expect(pathStep({ ...merged, deploys: false })).toMatchObject({ step: "land", done: true });
55 });
56 test("a pull closed without landing has no step", () => {
57 expect(pathStep({ state: "closed", check: "ok", approvals: 0, next: null })).toBeNull();
58 });
59 });
60
61 let dataDir: string;
62 let app: App;
63 let servers: Servers;
64 let alice: User;
65 let token = "";
66 const numbers: Record<string, number> = {};
67 const cells = process.env["FORGE_CELLS"];
68
69 const read = async (path: string): Promise<string> =>
70 (await fetch(`${servers.url}${path}`, {
71 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
72 })).text();
73
74 beforeAll(async () => {
75 process.env["FORGE_CELLS"] = "off";
76 dataDir = await mkdtemp(join(tmpdir(), "forge-path-"));
77 app = createApp({ dataDir, port: 0 });
78 servers = await serve(app);
79 const made = await createUser(app.db, {
80 origin: "seed",
81 trusted: true,
82 username: "alice",
83 email: "[email protected]",
84 password: "a-good-passphrase",
85 adminIfFirst: true,
86 });
87 if (!made.ok) throw new Error(String(made.error));
88 alice = made.value;
89 token = createToken(app.db, alice.id, "rw", "rw").token;
90 for (const name of ["open", "merged"]) {
91 const repo = await createRepo(app, alice, { name, visibility: "public" });
92 if (!repo.ok) throw new Error(String(repo.error));
93 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
94 const done = await commitToBranch(app, alice, repo.value, {
95 ...(branch === undefined ? {} : { branch }),
96 message: "change",
97 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
98 });
99 if (!done.ok) throw new Error(JSON.stringify(done.error));
100 }
101 const pull = await openPull(app, alice, repo.value, { title: name, body: "", baseBranch: "main", headBranch: "topic" });
102 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
103 numbers[name] = pull.value.number;
104 if (name === "merged") {
105 const done = await mergePull(app, alice, repo.value, pull.value, "merge");
106 if (!done.ok) throw new Error(JSON.stringify(done.error));
107 }
108 }
109 });
110
111 afterAll(async () => {
112 await servers.stop();
113 app.close();
114 if (cells === undefined) delete process.env["FORGE_CELLS"];
115 else process.env["FORGE_CELLS"] = cells;
116 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
117 });
118
119 test("/pipeline names the four steps in order, each with its finer parts one click down", async () => {
120 const res = await fetch(`${servers.url}/pipeline`);
121 expect(res.status).toBe(200);
122 const body = await res.text();
123 const at = STEPS.map((s) => body.indexOf(`data-step="${s}"`));
124 expect(at.every((i) => i > 0)).toBe(true);
125 expect([...at].sort((a, b) => a - b)).toEqual(at);
126 expect(body.match(/<details id="inside-/g)?.length).toBe(4);
127 expect(body).toContain('href="/boards"');
128 });
129
130 test("an open pull says it is at review and why, on both tabs, linked to /pipeline", async () => {
131 for (const tab of ["", "/files"]) {
132 const body = await read(`/alice/open/pulls/${numbers["open"]}${tab}`);
133 expect(body).toContain('data-path="review"');
134 expect(body).toMatch(/<strong aria-current="step"[^>]*>Review<\/strong>/);
135 expect(body).toContain("— No review yet.");
136 expect(body).toContain('<a href="/pipeline">');
137 }
138 });
139
140 test("a merged pull in a repository with no app says its path ends at land", async () => {
141 const body = await read(`/alice/merged/pulls/${numbers["merged"]}`);
142 expect(body).toContain('data-path="land"');
143 expect(body).toContain("nothing deploys from this branch");
144 });
145
146 test("the MCP pull answers the same step", async () => {
147 const res = await fetch(`${servers.url}/mcp`, {
148 method: "POST",
149 headers: {
150 "content-type": "application/json",
151 accept: "application/json, text/event-stream",
152 authorization: `Bearer ${token}`,
153 },
154 body: JSON.stringify({
155 jsonrpc: "2.0",
156 id: 1,
157 method: "tools/call",
158 params: { name: "pull", arguments: { repo: "alice/open", number: numbers["open"] } },
159 }),
160 });
161 const out: any = await res.json();
162 expect(out.result.isError).not.toBe(true);
163 const pull = JSON.parse(out.result.content[0].text) as { step: { step: string; why: string; done: boolean } };
164 expect(pull.step).toEqual({ step: "review", why: "No review yet.", done: false });
165 });
166
167 test("a merged pull into an app is landed until production has served a deploy since the merge, then live", async () => {
168 const made = await createRepo(app, alice, { name: "shipped", visibility: "public" });
169 if (!made.ok) throw new Error(String(made.error));
170 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
171 const done = await commitToBranch(app, alice, made.value, {
172 ...(branch === undefined ? {} : { branch }),
173 message: "change",
174 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
175 });
176 if (!done.ok) throw new Error(JSON.stringify(done.error));
177 }
178 const opened = await openPull(app, alice, made.value, { title: "ship", body: "", baseBranch: "main", headBranch: "topic" });
179 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
180 const merged = await mergePull(app, alice, made.value, opened.value, "merge");
181 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
182 const record = createAppRecord(app.db, { repoId: made.value.id, slug: "shipped", access: "public" });
183 if (!record.ok) throw new Error(record.error);
184
185 const pull = getPull(app.db, made.value.id, opened.value.number)!;
186 const repo = getRepoById(app.db, made.value.id)!;
187 const live = { headOid: pull.headOid, conflicts: 0 };
188 const deploy = (id: string, at: string, serving: string | null, env = "production") =>
189 app.db
190 .query("INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at) VALUES (?, ?, 'deploy', ?, 'ok', ?, ?)")
191 .run(id, made.value.id, env, at, serving);
192
193 // No deploy yet: landed, waiting for a release.
194 expect(stepOfPull(app.db, repo, pull, live)).toEqual({
195 step: "live",
196 why: "Landed; it goes live with the next production release.",
197 done: false,
198 });
199 // A production deploy that predates the merge, one still not serving, and a preview do not count.
200 deploy("d-before", "2000-01-01T00:00:00.000Z", "2000-01-01T00:01:00.000Z");
201 deploy("d-booting", "2999-01-01T00:00:00.000Z", null);
202 deploy("d-preview", "2999-01-01T00:00:00.000Z", "2999-01-01T00:01:00.000Z", "pull-1");
203 expect(stepOfPull(app.db, repo, pull, live)?.done).toBe(false);
204 // One serving after the merge makes it live.
205 deploy("d-after", "2999-01-01T00:00:00.000Z", "2999-01-01T00:01:00.000Z");
206 expect(stepOfPull(app.db, repo, pull, live)).toEqual({ step: "live", why: "Landed and live in production.", done: true });
207 expect(await read(`/alice/shipped/pulls/${opened.value.number}`)).toContain('data-path="live"');
208 });
test/pull-next-tui.test.ts +2
@@ -70,6 +70,8 @@ test("the terminal's pull screen says who acts next, as the page does", async ()
7070 await settleApp(tui);
7171 await Bun.sleep(20);
7272 }
73 // The page's step line, too: this pull is at review.
74 if (!tui.screen.toText().includes("step: review")) throw new Error(tui.screen.toText());
7375 } finally {
7476 tui.stop();
7577 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs