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

A pure read sorts the open crew pulls into one count per state: red, conflicted, changes asked, approved and waiting, awaiting review #6483

Merged opened this

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

Merged

scout/forge:scout/6472-a-pure-read-sorts-the-open-crew-pulls-in → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6472-a-pure-read-sorts-the-open-crew-pulls-in was merged into main as af3686a.

Review

Sign in to review this change.
5 files changed +212 −0
README.md +1
@@ -678,6 +678,7 @@ This page is the front door. Everything else is a file of its own under
678678 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
679679 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
680680 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
681 - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`).
681682 - [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew.
682683 - [`docs/FLEET.md`](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo.
683684 - [`docs/HOME-EVAL.md`](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context.
src/flow/pile.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * The open crew pulls sorted into piles (#6472): 85 open pulls is one number,
3 * and the owner needs to know which pile is the clog. Open, unmerged pulls
4 * opened by a crew agent of the repository (an imported pull is no agent's),
5 * each counted once, in the first pile it fits: red check, conflicted,
6 * changes asked, approved and waiting, awaiting review.
7 *
8 * Red is `checks_pile.ts`'s: the latest check on the current head failed.
9 * Conflicted is `conflicted.ts`'s `conflictedPulls`, both the runs and the
10 * approved pulls waiting for room. A verdict is the latest approval or
11 * request for changes; a bare comment is no verdict. `publicOnly` is
12 * `readVitals`'s: public repositories alone.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { conflictedPulls } from "./conflicted.ts";
17
18 export interface Pile {
19 readonly red: number;
20 readonly conflicted: number;
21 readonly changesAsked: number;
22 readonly approved: number;
23 readonly awaitingReview: number;
24 readonly total: number;
25 }
26
27 interface Row {
28 readonly repo: string;
29 readonly number: number;
30 readonly red: number;
31 readonly verdict: string | null;
32 }
33

Export with no caller: wire this into /flow and the tty flow screen, or the work is not reachable.

34 export function readPile(db: Database, publicOnly = false): Pile {
35 const rows = db
36 .query<Row, [number]>(
37 `SELECT o.username || '/' || r.name AS repo, i.number AS number,
38 COALESCE((SELECT c.state FROM run c WHERE c.kind = 'check' AND c.issue_id = i.id
39 AND c.commit_oid = p.head_oid ORDER BY c.id DESC LIMIT 1) = 'failed', 0) AS red,
40 (SELECT v.state FROM review v WHERE v.issue_id = i.id AND v.state IN ('approved','changes_requested')
41 ORDER BY v.created_at DESC, v.id DESC LIMIT 1) AS verdict
42 FROM issue i JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id
43 JOIN user o ON o.id = r.owner_id JOIN user a ON a.id = i.author_id
44 WHERE i.state = 'open' AND p.merged_at IS NULL AND (?1 = 0 OR r.visibility = 'public')
45 AND a.is_agent = 1 AND COALESCE(i.origin_author, '') = ''
46 AND EXISTS (SELECT 1 FROM repo_crew c WHERE c.repo_id = r.id AND c.user_id = a.id)`,
47 )
48 .all(publicOnly ? 1 : 0);
49 const conflicted = new Set(conflictedPulls(db, publicOnly).map((p) => `${p.repo}#${p.number}`));
50 const pile = { red: 0, conflicted: 0, changesAsked: 0, approved: 0, awaitingReview: 0 };
51 for (const row of rows) {
52 if (row.red) pile.red++;
53 else if (conflicted.has(`${row.repo}#${row.number}`)) pile.conflicted++;
54 else if (row.verdict === "changes_requested") pile.changesAsked++;
55 else if (row.verdict === "approved") pile.approved++;
56 else pile.awaitingReview++;
57 }
58 return { ...pile, total: rows.length };
59 }
60
61 /** One sentence for /boards and the terminal; the order is the order a pull is sorted in, so the first pile named is the one it is counted in. */
62 export function pileSays(pile: Pile): string {
63 if (pile.total === 0) return "No open crew pulls.";
64 return `${pile.total} open crew ${pile.total === 1 ? "pull" : "pulls"}, each counted once in the first pile it fits: ${pile.red} red, ${pile.conflicted} conflicted, ${pile.changesAsked} changes asked, ${pile.approved} approved and waiting, ${pile.awaitingReview} awaiting review.`;
65 }
src/tui/screens/flow.ts +3
@@ -17,6 +17,7 @@ import type { Database } from "bun:sqlite";
1717 import { HOLDS } from "../../crew/held.ts";
1818 import type { HeldRow } from "../../flow/held.ts";
1919 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts";
20 import { pileSays, readPile } from "../../flow/pile.ts";
2021 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
2122 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
2223 import { readQueues, redBatchWords } from "../../flow/queue.ts";
@@ -64,6 +65,8 @@ export function flowLines(
6465 "",
6566 ...wrap(throughputSays(flow), inner),
6667 "",
68 ...wrap(pileSays(readPile(db, agent !== undefined)), inner),
69 "",
6770 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
6871 ...flow.stages.map(stageOut).map(
6972 (s) =>
src/web/pages/flow-panel.ts +3
@@ -10,6 +10,7 @@
1010 import type { Reader } from "../../actions/telemetry.ts";
1111 import { queuesFor, systemFor } from "../../system/telemetry.ts";
1212 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts";
13 import { pileSays, readPile } from "../../flow/pile.ts";
1314 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
1415 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
1516 import {
@@ -99,12 +100,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
99100 const onBuilder = requestedSays(requested.builder, "builder", now);
100101 const onReviewer = requestedSays(requested.reviewer, "reviewer", now);
101102 const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent"));
103 const pile = pileSays(readPile(app.db, reader.kind === "agent"));
102104 const listed = asked(url);
103105 const shown = listed === null ? undefined : out.get(listed.key);
104106 return html`${card(
105107 html`<p id="bottleneck" data-stage="${worst?.key ?? ""}">
106108 <strong>${verdict(stages, now)}</strong>
107109 </p>
110 <p id="pile">${pile}</p>

A long sentence of five counts is hard to scan on a 360px phone. Consider a short list of links (<a> per pile) rather than one paragraph.

108111 ${lane === null ? "" : html`<p id="rebase-lane">Rebases: ${lane}.</p>`}
109112 ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`}
110113 ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`}
test/flow-pile.test.ts added +140
@@ -0,0 +1,140 @@
1 /** The pile (#6472): open crew pulls counted once each, in the first pile they fit. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { addCrew, createAgentRow } from "../src/core/agents.ts";
8 import { createRepo } from "../src/core/repos.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { id } from "../src/kernel/id.ts";
11 import { flowLines } from "../src/tui/screens/flow.ts";
12 import { flowPanel } from "../src/web/pages/flow-panel.ts";
13 import { pileSays, readPile } from "../src/flow/pile.ts";
14
15 let root: string;
16 let app: App;
17 let ownerId: string;
18 let atlasId: string;
19 let strangerId: string;
20 let pub: string;
21 let priv: string;
22 let n = 0;
23 let seq = 0;
24
25 beforeAll(async () => {
26 root = mkdtempSync(join(tmpdir(), "forge-pile-"));
27 app = createApp({ dataDir: root, port: 0 });
28 const made = await createUser(app.db, { username: "trevato", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
29 if (!made.ok) throw new Error(made.error);
30 ownerId = made.value.id;
31 const other = await createUser(app.db, { username: "stranger", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
32 if (!other.ok) throw new Error(other.error);
33 strangerId = other.value.id;
34 const atlas = await createUser(app.db, { origin: "seed", username: "atlas", email: "[email protected]", agent: true });
35 if (!atlas.ok) throw new Error("agent");
36 atlasId = atlas.value.id;
37 createAgentRow(app.db, { userId: atlasId, brief: "plan", createdBy: ownerId });
38 for (const [name, visibility] of [["open", "public"], ["secret", "private"]] as const) {
39 const repo = await createRepo(app.db, app.reposRoot, { ownerId, name, visibility, description: "r" });
40 if (!repo.ok) throw new Error(repo.error);
41 addCrew(app.db, repo.value.id, atlasId, ownerId);
42 if (name === "open") pub = repo.value.id;
43 else priv = repo.value.id;
44 }
45 });
46 afterAll(() => {
47 app.close();
48 rmSync(root, { recursive: true, force: true });
49 });
50
51 function pull(repoId: string, opts: { author?: string; merged?: boolean; state?: string } = {}): string {
52 const issue = id();
53 const number = ++n;
54 app.db
55 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'pull', 't', '', ?4, ?5)`)
56 .run(issue, repoId, number, opts.author ?? atlasId, opts.state ?? "open");
57 app.db
58 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, 'main', 'b', '', 'h', ?2, ?3)`)
59 .run(issue, repoId, opts.merged ? "2026-10-26T00:00:00.000Z" : null);
60 return issue;
61 }
62 function run(repoId: string, issue: string, kind: string, state: string, reason = ""): void {
63 app.db
64 .query(`INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, reason) VALUES (?1, ?2, ?3, ?4, 'h', ?5, ?6)`)
65 .run(`${String(++seq).padStart(4, "0")}${id()}`, repoId, kind, issue, state, reason);
66 }
67 function review(issue: string, state: string): void {
68 app.db
69 .query(`INSERT INTO review (id, issue_id, author_id, state) VALUES (?1, ?2, ?3, ?4)`)
70 .run(`${String(++seq).padStart(4, "0")}${id()}`, issue, ownerId, state);
71 }
72 const clear = (): void => {
73 for (const t of ["review", "run", "pull", "issue"]) app.db.query(`DELETE FROM ${t}`).run();
74 };
75
76 describe("flow pile", () => {
77 test("nothing yet reads zero", () => {
78 clear();
79 expect(readPile(app.db)).toEqual({ red: 0, conflicted: 0, changesAsked: 0, approved: 0, awaitingReview: 0, total: 0 });
80 });
81
82 test("one pull in each pile, a merged pull left out, and the piles add up to total", () => {
83 clear();
84 run(pub, pull(pub), "check", "failed");
85 run(pub, pull(pub), "agent", "queued", "conflict");
86 review(pull(pub), "changes_requested");
87 review(pull(pub), "approved");
88 pull(pub);
89 const merged = pull(pub, { merged: true, state: "closed" });
90 run(pub, merged, "check", "failed");
91 const pile = readPile(app.db);
92 expect(pile).toEqual({ red: 1, conflicted: 1, changesAsked: 1, approved: 1, awaitingReview: 1, total: 5 });
93 expect(pile.red + pile.conflicted + pile.changesAsked + pile.approved + pile.awaitingReview).toBe(pile.total);
94 });
95
96 test("a pull is counted once, in the first pile it fits", () => {
97 clear();
98 const a = pull(pub);
99 run(pub, a, "check", "failed");
outdated

Add a page/tty-level test once wired; the unit tests alone cannot prove the door exists.

100 run(pub, a, "agent", "failed", "conflict");
101 review(a, "approved");
102 const b = pull(pub);
103 run(pub, b, "agent", "running", "conflict");
104 review(b, "changes_requested");
105 const c = pull(pub);
106 review(c, "approved");
107 review(c, "changes_requested");
108 const d = pull(pub);
109 review(d, "changes_requested");
110 review(d, "approved");
111 review(d, "commented");
112 expect(readPile(app.db)).toEqual({ red: 1, conflicted: 1, changesAsked: 1, approved: 1, awaitingReview: 0, total: 4 });
113 });
114
115 test("only crew agents' pulls count, and publicOnly leaves private repositories out", () => {
116 clear();
117 pull(pub, { author: strangerId });
118 pull(pub, { author: ownerId });
119 pull(pub);
120 pull(priv);
121 expect(readPile(app.db).total).toBe(2);
122 expect(readPile(app.db, true)).toEqual({ red: 0, conflicted: 0, changesAsked: 0, approved: 0, awaitingReview: 1, total: 1 });
123 });
124
125 test("the /boards panel and the terminal flow screen say the same counts, in the sort order; an agent reads public only", () => {
126 clear();
127 run(pub, pull(pub), "check", "failed");
128 review(pull(pub), "approved");
129 pull(priv);
130 const says = pileSays(readPile(app.db));
131 expect(says).toContain("3 open crew pulls");
132 expect(says).toContain("1 red, 0 conflicted, 0 changes asked, 1 approved and waiting, 1 awaiting review");
133 const page = String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards"));
134 expect(page).toContain(`<p id="pile">${says}</p>`);
135 expect(flowLines(app.db).join(" ")).toContain("1 red, 0 conflicted, 0 changes asked, 1 approved and waiting, 1 awaiting review");
136 const agent = String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards"), "/boards"));
137 expect(agent).toContain("2 open crew pulls");
138 expect(flowLines(app.db, new Date(), undefined, { queues: [] }).join(" ")).toContain("2 open crew pulls");
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs