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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6472-a-pure-read-sorts-the-open-crew-pulls-in → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 678 | 678 | - [](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. |
| 679 | 679 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 680 | 680 | - [](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`). | |
| 681 | 682 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 682 | 683 | - [](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. |
| 683 | 684 | - [](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 | ||
| 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"; | ||
| 17 | 17 | import { HOLDS } from "../../crew/held.ts"; |
| 18 | 18 | import type { HeldRow } from "../../flow/held.ts"; |
| 19 | 19 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 20 | import { pileSays, readPile } from "../../flow/pile.ts"; | |
| 20 | 21 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 21 | 22 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; |
| 22 | 23 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| @@ -64,6 +65,8 @@ export function flowLines( | ||
| 64 | 65 | "", |
| 65 | 66 | ...wrap(throughputSays(flow), inner), |
| 66 | 67 | "", |
| 68 | ...wrap(pileSays(readPile(db, agent !== undefined)), inner), | |
| 69 | "", | |
| 67 | 70 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, |
| 68 | 71 | ...flow.stages.map(stageOut).map( |
| 69 | 72 | (s) => |
src/web/pages/flow-panel.ts +3
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import type { Reader } from "../../actions/telemetry.ts"; |
| 11 | 11 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; |
| 12 | 12 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 13 | import { pileSays, readPile } from "../../flow/pile.ts"; | |
| 13 | 14 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| 14 | 15 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; |
| 15 | 16 | import { |
| @@ -99,12 +100,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 99 | 100 | const onBuilder = requestedSays(requested.builder, "builder", now); |
| 100 | 101 | const onReviewer = requestedSays(requested.reviewer, "reviewer", now); |
| 101 | 102 | const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent")); |
| 103 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); | |
| 102 | 104 | const listed = asked(url); |
| 103 | 105 | const shown = listed === null ? undefined : out.get(listed.key); |
| 104 | 106 | return html`${card( |
| 105 | 107 | html`<p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 106 | 108 | <strong>${verdict(stages, now)}</strong> |
| 107 | 109 | </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 ( |
||
| 108 | 111 | ${lane === null ? "" : html`<p id="rebase-lane">Rebases: ${lane}.</p>`} |
| 109 | 112 | ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`} |
| 110 | 113 | ${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 | }); |
new above
offline
Export with no caller: wire this into /flow and the tty flow screen, or the work is not reachable.