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

The owner sees the work growing or shrinking at a glance, and pauses the planner with one switch #11632

Merged opened this in answer to

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

Merged

scout/forge:scout/11604-the-owner-sees-the-work-growing-or-shrin → main 4 commits fork

  • Merged. scout/11604-the-owner-sees-the-work-growing-or-shrin was merged into main as 53fe9e6.

Review

Sign in to review this change.
22 files changed +1052 −3
docs/features/growth.md added +25
@@ -0,0 +1,25 @@
1 # Growth and the planner switch
2
3 Open `/:owner/:name/queue`, or "Queue" in the terminal's palette while a repository is open. It shows, for the
4 last 14 days, issues opened and closed, pulls opened, landed and closed unmerged, the open totals, who opened
5 the issues (owner, people, planner, builders, watchers) and a row for each day, with two plain sentences: how
6 fast the list grows or shrinks and how many days of work are queued at the current pace. The oldest open pull
7 and the oldest issue no run has started on sit above. Sealed issues are left out of every number and title for
8 anyone who may not see them (`core/sealed.ts`). No dollars are shown.
9
10 **Planner: on / paused.** The owner (a person who administers the repository) pauses it until they switch it on,
11 until the weekly reset (the account's own reset when one is read, else Monday), or until a time (UTC, within a
12 year). Paused, the planner opens no issues (`planner_paused`), adds no plan and starts no run; builders and
13 reviewers keep finishing what is queued. A pause lifts itself at its time. The page suggests a pause when the list
14 has grown three days running or queued work passes two weeks of pace; it never pauses by itself. In the terminal:
15 `o` until I switch it on, `w` weekly reset, `t` a time, `n` switch on. `planner.paused` and `planner.resumed` are
16 heard by the admins.
17
18 `GET /api/v1/repos/{owner}/{name}/growth?days=14` answers the same numbers (`days` 1 to 90), whether the planner
19 is paused, and what the caller may see.
20
21 Closed-per-day has no index of its own: it walks the repository's rows through the existing `repo_id` index
22 (one made the retrospective's plan sort). Opened-per-day uses `issue_repo_created`.
23
24 Not done: the MCP tool for the switch (the API and both doors are the way in for now), the panel on the
25 repository home, one action per aged item, and "until the chapter closes" (the forge has no chapters).
src/actions/issues.ts +10 −2
@@ -24,6 +24,7 @@ import { requireAbility } from "./roles.ts";
2424 import type { App } from "../app.ts";
2525 import { adoptResolution } from "../core/trusted_sources.ts";
2626 import { crewRole, isCrew } from "../core/agents.ts";
27 import { pausedUntil } from "../core/planner_pause.ts";
2728 import { moveBlockersToChildren } from "../core/dependencies.ts";
2829 import {
2930 addComment as insertComment,
@@ -94,11 +95,18 @@ export function openIssue(
9495 input: { title: string; body?: string; sealed?: boolean },
9596 ): Result<
9697 Issue,
97 "not_found" | "unauthorized" | TitleError | "body_too_long" | MirrorError
98 "not_found" | "unauthorized" | "planner_paused" | TitleError | "body_too_long" | MirrorError
9899 > {
99100 const user = requireParticipant(app, actor, repo);
100101 if (!user.ok) return user;
101102 if (isMirror(app, repo)) return err("mirrored");
103 // The owner's switch (#11604): a paused planner opens nothing, and says so; everyone else is unaffected.
104 if (
105 user.value.kind === "agent" &&
106 crewRole(app.db, repo.id, user.value.id) === "planner" &&
107 pausedUntil(app.db, repo.id, new Date()) !== null
108 )
109 return err("planner_paused");
102110 // Anyone who may open an issue may seal it: a reader reporting a hole
103111 // seals it too, and from then on only writers and the crew see it
104112 // (`core/sealed.ts`).
@@ -801,7 +809,7 @@ export async function openIssueThrough(
801809 ): Promise<
802810 Result<
803811 Issue,
804 ThroughError<"not_found" | "unauthorized" | TitleError | "body_too_long">
812 ThroughError<"not_found" | "unauthorized" | "planner_paused" | TitleError | "body_too_long">
805813 >
806814 > {
807815 const user = requireParticipant(app, actor, repo);
src/actions/plan.ts +2
@@ -10,6 +10,7 @@
1010 * nothing reaches the ready list without a reason to be built now.
1111 */
1212
13 import { pausedUntil, PAUSE_REFUSAL } from "../core/planner_pause.ts";
1314 import { sealedScope } from "../core/sealed.ts";
1415 import type { App } from "../app.ts";
1516 import { crewRole, isCrew } from "../core/agents.ts";
@@ -231,6 +232,7 @@ function vet(
231232 // A person's note from an app is owed an answer before the planner adds to the pile (#10563).
232233 // Only the repo's planner is held; it lifts when a crew member comments, or the issue is closed or hidden.
233234 if (actor.kind === "agent" && crewRole(app.db, repo.id, actor.id) === "planner") {
235 if (pausedUntil(app.db, repo.id, new Date()) !== null) return refuse("planner_paused", PAUSE_REFUSAL);
234236 const waiting = unansweredFromApp(app.db, repo.id);
235237 if (waiting !== undefined)
236238 return refuse(
src/actions/planner_switch.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * The planner's switch (#11604): the owner pauses the repository's planner until a time, the
3 * weekly reset or their own word, or switches it on. One action for the page and the API; the
4 * rule (what a pause stops) lives in `core/planner_pause.ts`.
5 */
6
7 import type { App } from "../app.ts";
8 import { canAdmin, type Repo } from "../core/repos.ts";
9 import type { User } from "../core/users.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import { FOREVER, nextWeek, pausePlanner, pausedUntil, resumePlanner } from "../core/planner_pause.ts";
12 import { weekCapacity } from "../flow/week.ts";
13
14 export type SwitchInput =
15 | { readonly mode: "on" }
16 | { readonly mode: "until"; readonly at: string }
17 | { readonly mode: "week" }
18 | { readonly mode: "owner" };
19
20 export type SwitchError = "not_found" | "forbidden" | "invalid";
21
22 /** Sets the switch; the value is when the pause lifts, or null when the planner is on. */
23 export function setPlannerSwitch(
24 app: App,
25 actor: User | undefined,
26 repo: Repo,
27 input: SwitchInput,
28 now = new Date(),
29 ): Result<string | null, SwitchError> {
30 if (actor === undefined) return err("not_found");
31 if (actor.kind === "agent" || !canAdmin(app.db, repo, actor)) return err("forbidden");
32 if (input.mode === "on") resumePlanner(app.db, repo.id);
33 else {
34 let until: string;
35 if (input.mode === "owner") until = FOREVER;
36 else if (input.mode === "week") {
37 const reset = weekCapacity(app.db, now)?.resetsAt ?? null;
38 until = reset !== null && reset > now.toISOString() ? reset : nextWeek(now);
39 } else {
40 const t = Date.parse(input.at);
41 if (!Number.isFinite(t) || t <= now.getTime() || t > now.getTime() + 366 * 86_400_000) return err("invalid");
42 until = new Date(t).toISOString();
43 }
44 pausePlanner(app.db, repo.id, until, actor.id);
45 }
46 const until = pausedUntil(app.db, repo.id, now);
47 app.bus.append({
48 kind: until === null ? "planner.resumed" : "planner.paused",
49 repoId: repo.id,
50 actorId: actor.id,
51 payload: { until },
52 });
53 return ok(until);
54 }
55
56 /** A `datetime-local` value (minutes or seconds, no zone) is UTC, as the page says; anything else goes through as typed. */
57 export function utcAt(at: string): string {
58 const t = at.trim();
59 return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/.test(t) ? `${t.length === 16 ? `${t}:00` : t}Z` : t;
60 }
61
62 /** The switch's input from a form or the terminal's words; undefined when it is none of them. */
63 export function switchInput(mode: string, at: string): SwitchInput | undefined {
64 if (mode === "on" || mode === "week" || mode === "owner") return { mode };
65 return mode === "until" ? { mode, at: utcAt(at) } : undefined;
66 }
src/actions/tell.ts +2
@@ -67,6 +67,8 @@ export type TellError =
6767 | "bad_from"
6868 | "not_found"
6969 | "unauthorized"
70 /** The owner paused the planner (`core/planner_pause.ts`). */
71 | "planner_paused"
7072 /** Someone who may not direct the crew here (`crew/direct.ts`). */
7173 | "forbidden"
7274 /** Someone whose email is not verified yet (`verify.ts`). */
src/contract/openapi/routes/getApiV1ReposOwnerNameGrowth.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route, limitTo } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/growth`, summary: "How the work is growing or shrinking: per day for `days` days (1 to 90, default 14) issues opened (split by owner, people, planner, builders, watchers) and closed, pulls opened, landed and closed unmerged; the open totals; net issues per day; days of work queued at the current pace; this week's spend on work the owner asked for versus work the planner proposed; three plain sentences; a suggestion to pause when the list has grown three days running or work passes two weeks; and whether the planner is paused, and until when", auth: false, role: "anonymous", query: { days: limitTo(90) } };
src/contract/openapi/routes/index.ts +1
@@ -150,6 +150,7 @@ export { route as getApiV1ReposOwnerNameFollowing } from "./getApiV1ReposOwnerNa
150150 export { route as getApiV1ReposOwnerNameFollowingProposals } from "./getApiV1ReposOwnerNameFollowingProposals.ts";
151151 export { route as getApiV1ReposOwnerNameForks } from "./getApiV1ReposOwnerNameForks.ts";
152152 export { route as getApiV1ReposOwnerNameGatesIdLog } from "./getApiV1ReposOwnerNameGatesIdLog.ts";
153 export { route as getApiV1ReposOwnerNameGrowth } from "./getApiV1ReposOwnerNameGrowth.ts";
153154 export { route as getApiV1ReposOwnerNameHooks } from "./getApiV1ReposOwnerNameHooks.ts";
154155 export { route as getApiV1ReposOwnerNameHooksIdDeliveries } from "./getApiV1ReposOwnerNameHooksIdDeliveries.ts";
155156 export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameIssues.ts";
src/contract/tags.ts +1
@@ -14,6 +14,7 @@ const ALIAS: Readonly<Record<string, string>> = {
1414 "make-it-yours": "forks",
1515 contribute: "forks",
1616 queue: "gates",
17 growth: "issues",
1718 checks: "pulls",
1819 conversation: "conversations",
1920 "conversation-confirms": "conversations",
src/core/event_audience.ts +3
@@ -227,6 +227,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
227227 // Who is trusted to fold in, and the switch that stops it, are the owner's (`actions/trusted_sources.ts`).
228228 kind.startsWith("trust.") ||
229229 kind.startsWith("foldin.") ||
230 // The owner's planner switch is the admins' to hear (#11604).
231 kind === "planner.paused" ||
232 kind === "planner.resumed" ||
230233 // Events the retired FLOW ONLY switch wrote stay the admins' to hear.
231234 kind === "flow_only.cleared" ||
232235 // A report from outside waits for a person who decides; its arrival is
src/core/growth.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * How the work is growing or shrinking (#11604): per day for the last `days` days, issues opened (by
3 * who) and closed, pulls opened, landed and closed unmerged, the open totals now, and the sentences
4 * computed from them. Opened-per-day is a range walk of `issue_repo_created`, which the migration
5 * `planner-pause` adds. Closed-per-day has no index of its own (one made `windowShipped`'s plan sort,
6 * `test/week-sprint.test.ts`): it walks the repository's rows through the existing `repo_id` index, one
7 * repository's issues and never the table. Every read leaves out sealed issues the viewer may not see
8 * (`core/sealed.ts`). Plain data out.
9 */
10
11 import type { Database } from "bun:sqlite";
12 import type { sealedScope } from "./sealed.ts";
13
14 /** What `sealedScope` answered for the viewer. */
15 export type Scope = ReturnType<typeof sealedScope>;
16
17 /** `AND …` leaving out sealed rows the viewer may not see; `a` and `b` are the two parameter numbers it uses. */
18 const visible = (a: number, b: number): string => `AND (i.sealed = 0 OR ?${a} = 1 OR i.author_id = ?${b})`;
19 const visibleArgs = (scope: Scope): [number, string] => [scope.sealed === true ? 1 : 0, scope.sealedBy ?? ""];
20
21 export type Who = "owner" | "people" | "planner" | "builders" | "watchers";
22 export const WHO: readonly Who[] = ["owner", "people", "planner", "builders", "watchers"];
23
24 export interface Day {
25 readonly day: string;
26 readonly issuesOpened: number;
27 readonly issuesClosed: number;
28 readonly openedBy: Readonly<Record<Who, number>>;
29 readonly pullsOpened: number;
30 readonly pullsLanded: number;
31 readonly pullsClosed: number;
32 }
33
34 export interface Growth {
35 readonly days: readonly Day[];
36 readonly open: { readonly issues: number; readonly pulls: number };
37 /** Net issues per day over the window: opened minus closed, averaged. */
38 readonly perDay: number;
39 /** Days of work at the current pace of closing; null when nothing is closing. */
40 readonly daysQueued: number | null;
41 /** How many days in a row, ending today, the list grew. */
42 readonly growingRun: number;
43 readonly sentences: readonly string[];
44 /** A suggestion to pause, never an automatic pause. */
45 readonly suggest: string | null;
46 }
47
48 export const MAX_DAYS = 90;
49 const DAY_MS = 86_400_000;
50
51 /** The window as ISO days, oldest first, ending with `now`'s UTC day. */
52 export function windowDays(now: Date, days: number): string[] {
53 const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
54 return Array.from({ length: days }, (_, i) => new Date(end - (days - 1 - i) * DAY_MS).toISOString().slice(0, 10));
55 }
56
57 /** Whom an issue's author is, over `i.author_id` and `rc.role`; `?3` is the owner's id. */
58 const WHO_SQL = `CASE
59 WHEN i.author_id = ?3 THEN 'owner'
60 WHEN rc.role = 'planner' THEN 'planner'
61 WHEN rc.role = 'builder' THEN 'builders'
62 WHEN EXISTS (SELECT 1 FROM agent a WHERE a.user_id = i.author_id) THEN 'watchers'
63 ELSE 'people' END`;
64
65 const n = (x: number): string => String(Math.round(x));
66 const plural = (x: number, one: string, many: string): string => `${x} ${x === 1 ? one : many}`;
67
68 type Tally = {
69 day: string;
70 issuesOpened: number;
71 issuesClosed: number;
72 openedBy: Record<Who, number>;
73 pullsOpened: number;
74 pullsLanded: number;
75 pullsClosed: number;
76 };
77
78 /** The panel's numbers for one repository. */
79 export function growth(db: Database, repo: { id: string; ownerId: string }, days: number, now: Date, scope: Scope = {}): Growth {
80 const labels = windowDays(now, days);
81 const [all, mine] = visibleArgs(scope);
82 const from = `${labels[0]}T00:00:00.000Z`;
83 const rows = new Map<string, Tally>();
84 for (const day of labels)
85 rows.set(day, {
86 day, issuesOpened: 0, issuesClosed: 0, pullsOpened: 0, pullsLanded: 0, pullsClosed: 0,
87 openedBy: { owner: 0, people: 0, planner: 0, builders: 0, watchers: 0 },
88 });
89
90 const opened = db
91 .query<{ day: string; k: string; who: Who; c: number }, [string, string, string, number, string]>(
92 `SELECT substr(i.created_at,1,10) AS day, i.kind AS k, ${WHO_SQL} AS who, COUNT(*) AS c
93 FROM issue i
94 LEFT JOIN repo_crew rc ON rc.repo_id = i.repo_id AND rc.user_id = i.author_id
95 WHERE i.repo_id = ?1 AND i.created_at >= ?2 ${visible(4, 5)}
96 GROUP BY day, k, who`,
97 )
98 .all(repo.id, from, repo.ownerId, all, mine);
99 for (const r of opened) {
100 const d = rows.get(r.day);
101 if (d === undefined) continue;
102 if (r.k === "pull") d.pullsOpened += r.c;
103 else {
104 d.issuesOpened += r.c;
105 d.openedBy[r.who] += r.c;
106 }
107 }
108 const closedRows = db
109 .query<{ day: string; k: string; landed: number; c: number }, [string, string, number, string]>(
110 `SELECT substr(i.closed_at,1,10) AS day, i.kind AS k,
111 (p.merged_at IS NOT NULL) AS landed, COUNT(*) AS c
112 FROM issue i LEFT JOIN pull p ON p.issue_id = i.id
113 WHERE i.repo_id = ?1 AND i.closed_at IS NOT NULL AND i.closed_at >= ?2 ${visible(3, 4)}
114 GROUP BY day, k, landed`,
115 )
116 .all(repo.id, from, all, mine);
117 for (const r of closedRows) {
118 const d = rows.get(r.day);
119 if (d === undefined) continue;
120 if (r.k === "issue") d.issuesClosed += r.c;
121 else if (r.landed === 1) d.pullsLanded += r.c;
122 else d.pullsClosed += r.c;
123 }
124 const open = db
125 .query<{ kind: string; c: number }, [string, number, string]>(
126 `SELECT i.kind, COUNT(*) AS c FROM issue i WHERE i.repo_id = ?1 AND i.state = 'open' ${visible(2, 3)} GROUP BY i.kind`,
127 )
128 .all(repo.id, all, mine);
129 const openIssues = open.find((o) => o.kind === "issue")?.c ?? 0;
130 const openPulls = open.find((o) => o.kind === "pull")?.c ?? 0;
131
132 const list = labels.map((l) => rows.get(l)!);
133 const totalOpened = list.reduce((s, d) => s + d.issuesOpened, 0);
134 const totalClosed = list.reduce((s, d) => s + d.issuesClosed, 0);
135 const perDay = (totalOpened - totalClosed) / days;
136 const pace = totalClosed / days;
137 const daysQueued = pace > 0 ? openIssues / pace : null;
138 let growingRun = 0;
139 for (let i = list.length - 1; i >= 0 && list[i]!.issuesOpened > list[i]!.issuesClosed; i--) growingRun++;
140
141 const dir = perDay > 0 ? "growing" : perDay < 0 ? "shrinking" : "steady";
142 const sentences = [
143 dir === "steady" || Math.round(Math.abs(perDay)) === 0
144 ? "The list of issues is holding steady."
145 : `The list of issues is ${dir} by ${n(Math.abs(perDay))} a day.`,
146 daysQueued === null
147 ? `${plural(openIssues, "issue is", "issues are")} queued and none closed in ${days} days, so there is no pace to count by.`
148 : `${plural(openIssues, "issue is", "issues are")} queued: ${n(daysQueued)} days of work at the current pace.`,
149 ];
150 const suggest =
151 growingRun >= 3
152 ? `The list has grown ${growingRun} days running: you may want to pause the planner for a while.`
153 : daysQueued !== null && daysQueued > 14
154 ? `${n(daysQueued)} days of work are queued, past two weeks: you may want to pause the planner for a while.`
155 : null;
156 return { days: list, open: { issues: openIssues, pulls: openPulls }, perDay, daysQueued, growingRun, sentences, suggest };
157 }
158
159 export interface Aged {
160 readonly number: number;
161 readonly title: string;
162 readonly openedAt: string;
163 }
164
165 /** The oldest open pull and the oldest open issue no run has started on: what age looks like, honestly. */
166 export function oldest(db: Database, repoId: string, scope: Scope = {}): { pull: Aged | null; issue: Aged | null } {
167 const one = (kind: "issue" | "pull", unstarted: boolean): Aged | null => {
168 const r = db
169 .query<{ number: number; title: string; created_at: string }, [string, string, number, string]>(
170 `SELECT i.number, i.title, i.created_at FROM issue i
171 WHERE i.repo_id = ?1 AND i.state = 'open' AND i.kind = ?2 ${visible(3, 4)}
172 ${unstarted ? "AND NOT EXISTS (SELECT 1 FROM run r WHERE r.issue_id = i.id)" : ""}
173 ORDER BY i.created_at LIMIT 1`,
174 )
175 .get(repoId, kind, ...visibleArgs(scope));
176 return r === null ? null : { number: r.number, title: r.title, openedAt: r.created_at };
177 };
178 return { pull: one("pull", false), issue: one("issue", true) };
179 }
src/core/migration-pins/20261112090000-planner-pause.pin added +1
@@ -0,0 +1,1 @@
1 fb8305e41acc36931f8cd46537f8243de5e94b89a99d989b1aec980b2d49d88a
src/core/migrations/20261112090000-planner-pause.ts added +12
@@ -0,0 +1,12 @@
1 // `planner_pause` (#11604): one row per repository whose planner the owner paused, with the time
2 // it lifts itself (`9999-…` when it waits for the owner). An index for the growth panel's per-day reads.
3 // New table and indexes only. Additive.
4 export default `
5 CREATE TABLE planner_pause (
6 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
7 until TEXT NOT NULL,
8 by_id TEXT REFERENCES user(id) ON DELETE SET NULL,
9 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
10 );
11 CREATE INDEX issue_repo_created ON issue(repo_id, created_at);
12 `;
src/core/planner_pause.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * The planner's switch (#11604): paused, the repository's planner opens and splits nothing; builders
3 * and reviewers carry on. A pause lifts itself at its time; "until I switch it on" is a time so far
4 * ahead that only the owner ends it. Plain data in and out: the db, a repository id, a clock.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export const FOREVER = "9999-12-31T00:00:00.000Z";
10
11 /** When the pause lifts, or null when the planner is on (a pause already past is the same as none). */
12 export function pausedUntil(db: Database, repoId: string, now: Date): string | null {
13 const row = db
14 .query<{ until: string }, [string]>("SELECT until FROM planner_pause WHERE repo_id = ?1")
15 .get(repoId);
16 return row !== null && row.until > now.toISOString() ? row.until : null;
17 }
18
19 export function pausePlanner(db: Database, repoId: string, until: string, byId: string): void {
20 db.query(
21 `INSERT INTO planner_pause (repo_id, until, by_id) VALUES (?1, ?2, ?3)
22 ON CONFLICT(repo_id) DO UPDATE SET until = ?2, by_id = ?3,
23 at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
24 ).run(repoId, until, byId);
25 }
26
27 export function resumePlanner(db: Database, repoId: string): void {
28 db.query("DELETE FROM planner_pause WHERE repo_id = ?1").run(repoId);
29 }
30
31 /** The next Monday 00:00 UTC after `now`: the week's turn when no account reports its own reset. */
32 export function nextWeek(now: Date): string {
33 const back = (now.getUTCDay() + 6) % 7;
34 return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - back + 7)).toISOString();
35 }
36
37 export const PAUSE_REFUSAL = "the planner is paused by the owner: it opens and splits nothing until the pause lifts";
38
39 /** What a pause stops, in the words the switch shows. */
40 export const PAUSE_STOPS =
41 "Paused, the planner opens no issues and splits none. Builders and reviewers keep finishing what is queued.";
42
43 /** The state in one sentence, for every door. */
44 export function pauseSentence(until: string | null): string {
45 if (until === null) return "The planner is on.";
46 if (until.startsWith("9999")) return "The planner is paused until you switch it on.";
47 return `The planner is paused until ${until.slice(0, 16).replace("T", " ")} UTC.`;
48 }
src/crew/dispatch.ts +4
@@ -8,6 +8,7 @@
88 * time. A missed wake-up costs a tick, never a run.
99 */
1010
11 import { pausedUntil } from "../core/planner_pause.ts";
1112 import { crewFilters, isStrangerPull } from "./stranger.ts";
1213 import { TEMP_FULL } from "../checks/disk_full.ts";
1314 import { freshPullFor } from "./fresh_pull.ts";
@@ -464,6 +465,9 @@ export function queueAgentRun(
464465 if (!accountActive(app.db, repo.ownerId) || isContributionFork(app.db, repo.id)) return undefined;
465466 // Never a job outside the agent's seat: building is a builder's, reviewing a reviewer's.
466467 if (outsideSeat(app, repo, issue, agent, reason)) return undefined;
468 // The owner's switch (#11604): a paused planner starts nothing; builders and reviewers carry on.
469 if (crewRole(app.db, repo.id, agent.user.id) === "planner" && pausedUntil(app.db, repo.id, new Date()) !== null)
470 return undefined;
467471 // An archived repository starts nothing new; a run carrying on (`again`)
468472 // is one already going, and finishes. A first run
469473 // (`attempt` 0, as a conflict or converge run is queued) is new.
src/http/api.ts +18
@@ -8,6 +8,8 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { growth, MAX_DAYS as MAX_GROWTH_DAYS } from "../core/growth.ts";
12 import { PAUSE_REFUSAL, pausedUntil } from "../core/planner_pause.ts";
1113 import { teamHoldRefusal } from "../core/teams_words.ts";
1214 import { guardedRefusal } from "../crew/stranger.ts";
1315 import { MAX_PASS, passOf } from "../actions/box_pass.ts";
@@ -668,6 +670,7 @@ export function originAware(error: string, status = 422): Response {
668670 422,
669671 "a bug closed without a fix says why: send `note` (one sentence, at most 300 characters)",
670672 );
673 if (error === "planner_paused") return apiError("planner_paused", 409, PAUSE_REFUSAL);
671674 if (error === "mirrored")
672675 return apiError(
673676 "mirrored",
@@ -4033,6 +4036,21 @@ export function apiRoutes(router: Router<Handler>): void {
40334036 return timePhase(ctx.request, "render", () => json(paged(items, limit, (g) => g.id), 200, { "cache-control": "no-store" }));
40344037 });
40354038
4039 // How the work is growing or shrinking (#11604, `core/growth.ts`): per day for `days` days.
4040 router.add("GET", "/api/v1/repos/:owner/:name/growth", (ctx) => {
4041 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4042 const repo = loadRepo(ctx);
4043 if (!repo) return notFound();
4044 const raw = ctx.url.searchParams.get("days");
4045 const days = raw === null ? 14 : Number(raw);
4046 if (!Number.isInteger(days) || days < 1 || days > MAX_GROWTH_DAYS)
4047 return json({ error: "invalid", message: `\`days\` is a whole number from 1 to ${MAX_GROWTH_DAYS}` }, 422);
4048 const now = new Date();
4049 const g = timePhase(ctx.request, "growth", () => growth(ctx.app.db, repo, days, now, sealedScope(ctx.app.db, repo, ctx.auth.user)));
4050 const paused = pausedUntil(ctx.app.db, repo.id, now);
4051 return json({ ...g, planner: { paused: paused !== null, until: paused } }, 200, { "cache-control": "no-store" });
4052 });
4053
40364054 // A kept gate's log (`checks/gate_log.ts`): the last 200 lines, as text.
40374055 router.add("GET", "/api/v1/repos/:owner/:name/gates/:id/log", (ctx) => {
40384056 const repo = loadRepo(ctx);
src/http/server.ts +3
@@ -171,6 +171,7 @@ import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
171171 import { apiPage, appApiPage } from "../web/pages/api.ts";
172172 import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts";
173173 import { findingAccept, findingsPage } from "../web/pages/findings.ts";
174 import { queuePage, queueSwitch } from "../web/pages/queue.ts";
174175 import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts";
175176 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
176177 import { docsFile, docsFolder } from "../web/pages/docs_link.ts";
@@ -1290,6 +1291,8 @@ export function buildRouter(): Router<Handler> {
12901291 router.add("POST", "/:owner/:name/flows/update", flowsUpdate);
12911292 router.add("GET", "/:owner/:name/findings", findingsPage);
12921293 router.add("POST", "/:owner/:name/findings/:id/accept", findingAccept);
1294 router.add("GET", "/:owner/:name/queue", queuePage);
1295 router.add("POST", "/:owner/:name/queue/planner", queueSwitch);
12931296 router.add("GET", "/:owner/:name/bets", betsPage);
12941297 router.add("POST", "/:owner/:name/bets/propose", betPropose);
12951298 router.add("GET", "/:owner/:name/bets/:id", betPage);
src/tui/screens/queuegrowth.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * The queue in the terminal (#11604): the web page's panel (`web/pages/queue.ts`) from the same
3 * reads (`core/growth.ts`, sealed issues left out for this viewer) and the planner's switch, which
4 * only a human admin of the repository changes: `o` pauses until they switch it on, `w` until the
5 * weekly reset, `t` until a time typed in UTC, `n` switches it on. Each goes through
6 * `setPlannerSwitch`, the page's one door, so a web viewer sees it at once.
7 */
8
9 import { setPlannerSwitch, switchInput, type SwitchInput } from "../../actions/planner_switch.ts";
10 import { growth, oldest, WHO } from "../../core/growth.ts";
11 import { PAUSE_STOPS, pauseSentence, pausedUntil } from "../../core/planner_pause.ts";
12 import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts";
13 import { sealedScope } from "../../core/sealed.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Command } from "../app/palette.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { promptScreen } from "./common.ts";
21
22 const DAYS = 14;
23
24 /** Whether this session may flip the switch: a person who administers the repository, never an agent. */
25 const mayFlip = (f: Session, repo: Repo): boolean =>
26 f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user);
27
28 /** The screen's lines, pure over the session and the clock. */
29 export function queueLines(f: Session, repo: Repo, now = new Date()): string[] {
30 const scope = sealedScope(f.db, repo, f.user);
31 const g = growth(f.db, repo, DAYS, now, scope);
32 const age = oldest(f.db, repo.id, scope);
33 const until = pausedUntil(f.db, repo.id, now);
34 const sum = (pick: (d: (typeof g.days)[number]) => number) => g.days.reduce((s, d) => s + pick(d), 0);
35 const opened = sum((d) => d.issuesOpened);
36 const closed = sum((d) => d.issuesClosed);
37 return [
38 `${repo.ownerName}/${repo.name}`,
39 "",
40 pauseSentence(until) + (until === null ? "" : ` ${PAUSE_STOPS}`),
41 "",
42 ...g.sentences,
43 ...(g.suggest === null ? [] : [g.suggest]),
44 "",
45 `Issues opened ${opened} · closed ${closed} · net ${opened - closed > 0 ? "+" : ""}${opened - closed} · open now ${g.open.issues}`,
46 `Pulls opened ${sum((d) => d.pullsOpened)} · landed ${sum((d) => d.pullsLanded)} · closed unmerged ${sum((d) => d.pullsClosed)} · open now ${g.open.pulls}`,
47 `Opened by ${WHO.map((w) => `${w} ${sum((d) => d.openedBy[w])}`).join(" · ")}`,
48 ...(age.pull === null ? [] : [`Oldest open pull: #${age.pull.number} ${age.pull.title}`]),
49 ...(age.issue === null ? [] : [`Oldest issue not started: #${age.issue.number} ${age.issue.title}`]),
50 "",
51 ...[...g.days].reverse().map((d) => `${d.day} ${d.issuesOpened} opened · ${d.issuesClosed} closed`),
52 ];
53 }
54
55 export function queueScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
56 const current = (): Repo => getRepoById(f.db, repo.id) ?? repo;
57 const flip = (input: SwitchInput): string | undefined => {
58 const done = setPlannerSwitch(f.app, f.user, current(), input);
59 if (done.ok) ctx.toast(pauseSentence(done.value), "success");
60 else ctx.toast(done.error === "invalid" ? "Pick a time in the future, within a year." : "Only the owner switches the planner.", "danger");
61 ctx.invalidate();
62 return undefined;
63 };
64 const screen: Screen = {
65 title: "Queue",
66 get keys() {
67 return mayFlip(f, current())
68 ? [binding(["o"], "pause until on"), binding(["w"], "pause to weekly reset"), binding(["t"], "pause until a time"), binding(["n"], "switch on")]
69 : [];
70 },
71 render(area, s) {
72 const c = ctx.theme.c;
73 queueLines(f, current()).forEach((line, i) => {
74 if (i < area.h) s.text(area.x, area.y + i, truncate(line, area.w), style(c.fg, c.bg), area.x + area.w);
75 });
76 },
77 key(e) {
78 if (!mayFlip(f, current())) return false;
79 if (e.key === "o") return flip({ mode: "owner" }), true;
80 if (e.key === "w") return flip({ mode: "week" }), true;
81 if (e.key === "n") return flip({ mode: "on" }), true;
82 if (e.key === "t") {
83 ctx.push(
84 promptScreen(ctx, screen, {
85 title: "Pause the planner until",
86 label: "A time in UTC",
87 placeholder: "2026-11-20T09:00",
88 onSubmit: (raw) => {
89 const input = switchInput("until", raw);
90 if (input !== undefined) flip(input);
91 return undefined;
92 },
93 }),
94 );
95 return true;
96 }
97 return false;
98 },
99 };
100 return screen;
101 }
102
103 /** The palette's "Queue" while a repository is open: anyone who can read it sees the growth. */
104 export function queueCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
105 if (repo === undefined) return [];
106 return [
107 {
108 id: "queue-growth",
109 title: `${repo.ownerName}/${repo.name} — Queue`,
110 hint: "is the work growing or shrinking, and the planner's switch",
111 run: () => ctx.push(queueScreen(f, ctx, repo)),
112 },
113 ];
114 }
src/tui/screens/workspace.ts +2
@@ -169,6 +169,7 @@ import { appEnvCommands } from "./appenv.ts";
169169 import { variantsCommands } from "./variants.ts";
170170 import { autonomyCommands } from "./autonomy.ts";
171171 import { trafficCommands } from "./trafficview.ts";
172 import { queueCommands } from "./queuegrowth.ts";
172173 import { blockedCommands } from "./blocked.ts";
173174 import { statementCommands } from "./statement.ts";
174175 import { moneyCommands } from "./money.ts";
@@ -1992,6 +1993,7 @@ export function workspaceScreen(
19921993 ...variantsCommands(f, ctx, repo),
19931994 ...autonomyCommands(f, ctx, repo),
19941995 ...trafficCommands(f, ctx, repo),
1996 ...queueCommands(f, ctx, repo),
19951997 ...blockedCommands(f, ctx, repo),
19961998 ...statementCommands(f, ctx, repo),
19971999 ...moneyCommands(f, ctx, repo),
src/web/pages/issues.ts +4 −1
@@ -5,6 +5,7 @@
55 * Mobile-first, no JavaScript required.
66 */
77
8 import { PAUSE_REFUSAL } from "../../core/planner_pause.ts";
89 import {
910 addCommentThrough,
1011 openIssueThrough,
@@ -262,7 +263,9 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
262263 ? TITLE_TOO_LONG
263264 : created.error === "body_too_long"
264265 ? BODY_TOO_LONG
265 : "You cannot open an issue here.");
266 : created.error === "planner_paused"
267 ? PAUSE_REFUSAL
268 : "You cannot open an issue here.");
266269 return composePage(ctx, repo, typed, message);
267270 }
268271 return flashTo(ctx, `${repoBase(repo)}/issues/${created.value.number}`, {
src/web/pages/queue.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The queue (#11604): `/:owner/:name/queue` shows whether the work is growing or shrinking — facts
3 * as rows, the last 14 days a row each, two plain sentences — and the planner's one switch.
4 * Server-rendered, no script; the switch is a same-origin POST. What a viewer may not see (sealed
5 * issues) is left out of every number and title (`core/sealed.ts`).
6 */
7
8 import { setPlannerSwitch, switchInput } from "../../actions/planner_switch.ts";
9 import { growth, oldest, WHO, type Growth } from "../../core/growth.ts";
10 import { PAUSE_STOPS, pauseSentence, pausedUntil } from "../../core/planner_pause.ts";
11 import { canAdmin, type Repo } from "../../core/repos.ts";
12 import { sealedScope } from "../../core/sealed.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
15 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
16 import { field, readForm } from "../form.ts";
17 import { html, type Html } from "../html.ts";
18 import { flashTo, page } from "../layout.ts";
19 import { badge, btn, row } from "../ui.ts";
20
21 export const DAYS = 14;
22
23 const sum = (xs: readonly number[]) => xs.reduce((s, x) => s + x, 0);
24
25 /** The totals as rows, the sentences, the suggestion when there is one, and a row for each day. */
26 export function growthRows(g: Growth): Html {
27 const d = g.days;
28 const opened = sum(d.map((x) => x.issuesOpened));
29 const closed = sum(d.map((x) => x.issuesClosed));
30 const net = opened - closed;
31 const fact = (title: string, value: string) => row({ title, trail: badge(value) });
32 const by = WHO.map((w) => ({ w, n: sum(d.map((x) => x.openedBy[w])) })).filter((x) => x.n > 0);
33 return html`<p>${g.sentences.join(" ")}</p>
34 ${g.suggest === null ? null : html`<p role="note">${g.suggest}</p>`}
35 <ul class="list setting-list" data-growth>
36 ${fact("Issues opened", String(opened))}
37 ${fact("Issues closed", String(closed))}
38 ${fact("Net", `${net > 0 ? "+" : ""}${net}`)}
39 ${fact("Issues open now", String(g.open.issues))}
40 ${fact("Pulls opened, landed, closed unmerged", `${sum(d.map((x) => x.pullsOpened))}, ${sum(d.map((x) => x.pullsLanded))}, ${sum(d.map((x) => x.pullsClosed))}`)}
41 ${fact("Pulls open now", String(g.open.pulls))}
42 ${by.map((x) => fact(`Opened by ${x.w}`, String(x.n)))}
43 </ul>
44 <h2>Day by day</h2>
45 <ul class="list setting-list">
46 ${[...d].reverse().map((x) => row({ title: x.day, trail: badge(`${x.issuesOpened} opened · ${x.issuesClosed} closed`) }))}
47 </ul>`;
48 }
49
50 function switchRow(repo: Repo, until: string | null, owner: boolean): Html {
51 const post = `${repoBase(repo)}/queue/planner`;
52 const state = html`<p data-planner-switch>${pauseSentence(until)} ${until === null ? null : PAUSE_STOPS}</p>`;
53 if (!owner) return state;
54 return until !== null
55 ? html`${state}<form method="post" action="${post}"><input type="hidden" name="mode" value="on">${btn("Switch the planner on", { variant: "primary" })}</form>`
56 : html`${state}<form method="post" action="${post}" class="bar">
57 <label>Pause <select class="input" name="mode">
58 <option value="owner">until I switch it on</option>
59 <option value="week">until the weekly reset</option>
60 <option value="until">until the time here (UTC)</option>
61 </select></label>
62 <input class="input" type="datetime-local" name="at" aria-label="Time, UTC">
63 ${btn("Pause the planner", { variant: "primary" })}</form>`;
64 }
65
66 const ago = (iso: string, now: Date) => `${Math.max(0, Math.floor((now.getTime() - Date.parse(iso)) / 86_400_000))} days old`;
67
68 // GET /:owner/:name/queue
69 export function queuePage(ctx: Ctx): Response {
70 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
71 const repo = loadRepo(ctx);
72 if (!repo) return notFoundPage(ctx);
73 const rendering = performance.now();
74 const now = new Date();
75 const user = ctx.auth.user;
76 const owner = user !== undefined && user.kind !== "agent" && canAdmin(ctx.app.db, repo, user);
77 const scope = sealedScope(ctx.app.db, repo, user);
78 const g = growth(ctx.app.db, repo, DAYS, now, scope);
79 const age = oldest(ctx.app.db, repo.id, scope);
80 const until = pausedUntil(ctx.app.db, repo.id, now);
81 const base = repoBase(repo);
82 const aged = [
83 age.pull === null ? null : row({ href: `${base}/pulls/${age.pull.number}`, nav: true, title: html`Oldest open pull: #${age.pull.number} ${age.pull.title}`, trail: badge(ago(age.pull.openedAt, now)) }),
84 age.issue === null ? null : row({ href: `${base}/issues/${age.issue.number}`, nav: true, title: html`Oldest issue not started: #${age.issue.number} ${age.issue.title}`, trail: badge(ago(age.issue.openedAt, now)) }),
85 ].filter((r): r is Html => r !== null);
86 const body = html`${repoHeader(ctx, repo, "issues")}
87 ${pageHead("Queue", { sub: "Is the work growing or shrinking?" })}
88 ${switchRow(repo, until, owner)}
89 ${aged.length === 0 ? null : html`<ul class="list setting-list">${aged}</ul>`}
90 ${growthRows(g)}`;
91 const response = page({ ctx, title: `Queue · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
92 notePhase(ctx.request, "render", performance.now() - rendering);
93 return response;
94 }
95
96 // POST /:owner/:name/queue/planner
97 export async function queueSwitch(ctx: Ctx): Promise<Response> {
98 const repo = loadRepo(ctx);
99 if (!repo) return notFoundPage(ctx);
100 const form = await readForm(ctx, "rw");
101 if (form instanceof Response) return form;
102 const back = `${repoBase(repo)}/queue`;
103 const input = switchInput(field(form, "mode"), field(form, "at"));
104 if (input === undefined) return flashTo(ctx, back, { tone: "danger", message: "Choose on, a time, the weekly reset or until you switch it on." });
105 const done = setPlannerSwitch(ctx.app, ctx.auth.user, repo, input);
106 if (!done.ok)
107 return flashTo(ctx, back, { tone: "danger", message: done.error === "invalid" ? "Pick a time in the future, within a year." : "Only the owner switches the planner." });
108 return flashTo(ctx, back, { message: pauseSentence(done.value) });
109 }
test/growth.test.ts added +326
@@ -0,0 +1,326 @@
1 /**
2 * The growth panel and the planner's switch (#11604): the counts match the rows for a fixture with
3 * owner, person, planner, builder and watcher authors; pausing stops the planner's issues, plans and
4 * runs while a builder's queued run still starts; a timed pause lifts itself; the API and the page answer.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { newApp } from "../src/actions/apps.ts";
11 import { openIssue } from "../src/actions/issues.ts";
12 import { openPlan } from "../src/actions/plan.ts";
13 import { setPlannerSwitch, switchInput } from "../src/actions/planner_switch.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, getAgent } from "../src/core/agents.ts";
16 import { growth, oldest } from "../src/core/growth.ts";
17 import { nextWeek, pauseSentence, pausedUntil } from "../src/core/planner_pause.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
19 import { eventAudience } from "../src/core/event_audience.ts";
20 import { weekCapacity } from "../src/flow/week.ts";
21 import { noteLimit } from "../src/core/standing.ts";
22 import { addSubscription } from "../src/core/subscriptions.ts";
23 import { createRun } from "../src/core/runs.ts";
24 import { recordUsage } from "../src/core/usage.ts";
25 import { sealedScope } from "../src/core/sealed.ts";
26 import { createToken } from "../src/core/tokens.ts";
27 import { createUser, type User } from "../src/core/users.ts";
28 import { queueAgentRun } from "../src/crew/dispatch.ts";
29 import { serve, type Servers } from "../src/http/server.ts";
30 import { quietLogs } from "./helpers/globals.ts";
31
32 quietLogs();
33
34 const NOW = new Date("2026-11-12T12:00:00.000Z");
35 const DAY = 86_400_000;
36 const at = (daysAgo: number) => new Date(NOW.getTime() - daysAgo * DAY).toISOString();
37
38 describe("growth and the planner switch", () => {
39 let dataDir = "";
40 let app: App;
41 let servers: Servers;
42 let owner: User;
43 let person: User;
44 let planner: User;
45 let builder: User;
46 let watcher: User;
47 let repo: Repo;
48 let seq = 0;
49
50 const insert = (author: User, kind: "issue" | "pull", created: string, closed: string | null, merged = false) => {
51 const n = ++seq;
52 const id = `fx${n}`;
53 app.db
54 .query(
55 `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at, closed_at)
56 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
57 )
58 .run(id, repo.id, 1000 + n, kind, `fixture ${n}`, author.id, closed === null ? "open" : "closed", created, closed);
59 if (kind === "pull")
60 app.db
61 .query(
62 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
63 )
64 .run(id, `b${n}`, merged ? closed : null);
65 return id;
66 };
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-growth-"));
70 app = createApp({ dataDir, port: 0, host: "forge.test" });
71 servers = await serve(app);
72 const mk = async (username: string, agent: boolean, roles: string[] = []) => {
73 const made = await createUser(app.db, {
74 username,
75 email: `${username}@forge.test`,
76 ...(agent
77 ? { agent: true, origin: "agent" as const }
78 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: username === "alice" }),
79 });
80 if (!made.ok) throw new Error(String(made.error));
81 if (agent) createAgentRow(app.db, { userId: made.value.id, roles: roles as never });
82 return made.value;
83 };
84 owner = await mk("alice", false);
85 person = await mk("pat", false);
86 planner = await mk("plan", true, ["planner"]);
87 builder = await mk("build", true, ["builder"]);
88 watcher = await mk("watch", true, ["maintainer"]);
89 const made = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" });
90 if (!made.ok) throw new Error(String(made.error));
91 repo = getRepo(app.db, "alice", "shop")!;
92 addCrew(app.db, repo.id, planner.id, owner.id, "planner");
93 addCrew(app.db, repo.id, builder.id, owner.id, "builder");
94 });
95
96 afterAll(async () => {
97 servers.stop();
98 app.close();
99 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
100 });
101
102 test("the counts match the rows, by day and by author", () => {
103 app.db.query("DELETE FROM issue WHERE repo_id = ?1").run(repo.id);
104 const base = 0;
105 // today: 2 planner + 1 owner opened, 1 closed; yesterday: 1 person + 1 builder + 1 watcher opened, 2 closed
106 insert(planner, "issue", at(0), at(0));
107 insert(planner, "issue", at(0), null);
108 insert(owner, "issue", at(0), null);
109 insert(person, "issue", at(1), null);
110 insert(builder, "issue", at(1), at(1));
111 insert(watcher, "issue", at(1), at(1));
112 // an old one is outside the window; pulls: 2 opened a day ago, one landed today, one closed unmerged
113 insert(planner, "issue", at(40), null);
114 insert(builder, "pull", at(1), at(0), true);
115 insert(builder, "pull", at(1), at(0), false);
116 insert(builder, "pull", at(3), null);
117
118 const g = growth(app.db, repo, 14, NOW);
119 expect(g.days).toHaveLength(14);
120 const today = g.days[13]!;
121 const yesterday = g.days[12]!;
122 expect(today.day).toBe("2026-11-12");
123 expect(today.issuesOpened).toBe(3);
124 expect(today.openedBy).toEqual({ owner: 1, people: 0, planner: 2, builders: 0, watchers: 0 });
125 expect(today.issuesClosed).toBe(1);
126 expect(yesterday.openedBy).toEqual({ owner: 0, people: 1, planner: 0, builders: 1, watchers: 1 });
127 expect(yesterday.issuesClosed).toBe(2);
128 expect(yesterday.pullsOpened).toBe(2);
129 expect(today.pullsLanded).toBe(1);
130 expect(today.pullsClosed).toBe(1);
131 expect(g.open.issues).toBe(base + 4);
132 expect(g.open.pulls).toBe(1);
133 // net over the window: 6 opened, 3 closed
134 expect(g.perDay).toBeCloseTo(3 / 14);
135 expect(g.sentences).toHaveLength(2);
136 expect(JSON.stringify(g)).not.toContain("spend");
137 expect(g.sentences[1]).toContain("days of work");
138 expect(oldest(app.db, repo.id).pull?.openedAt).toBe(at(3));
139 });
140
141 test("a suggestion appears when the list has grown three days running, and nothing pauses by itself", () => {
142 for (const d of [0, 1, 2]) {
143 insert(planner, "issue", at(d), null);
144 insert(planner, "issue", at(d), null);
145 }
146 const g = growth(app.db, repo, 14, NOW);
147 expect(g.growingRun).toBe(3);
148 expect(g.suggest).toContain("pause the planner");
149 expect(pausedUntil(app.db, repo.id, NOW)).toBeNull();
150 });
151
152 test("pausing stops the planner's issues, plans and runs; a builder's queued run still starts", () => {
153 const direction = insert(owner, "issue", at(0), null);
154 app.db.query("UPDATE repo SET direction_issue_id = ?2, autonomy = 'auto' WHERE id = ?1").run(repo.id, direction);
155 repo = getRepo(app.db, "alice", "shop")!;
156 const task = openIssue(app, owner, repo, { title: "A task" });
157 if (!task.ok) throw new Error(task.error);
158 const plan = (key: string) => [{ key, title: `Plan ${key}`, body: "x", blockedBy: [] }];
159 const directionIssue = { ...task.value, id: direction };
160 const plannerAgent = getAgent(app.db, planner.id)!;
161 const builderAgent = getAgent(app.db, builder.id)!;
162
163 // On: the planner opens and plans, and its run is queued.
164 expect(openIssue(app, planner, repo, { title: "By the planner" }).ok).toBe(true);
165 expect(openPlan(app, planner, repo, plan("a")).ok).toBe(true);
166 expect(queueAgentRun(app, repo, directionIssue, plannerAgent, "assigned")).toBeDefined();
167 app.db.query("UPDATE run SET state = 'cancelled' WHERE repo_id = ?1").run(repo.id);
168
169 // Only the owner switches it.
170 expect(setPlannerSwitch(app, person, repo, { mode: "owner" })).toEqual({ ok: false, error: "forbidden" });
171 expect(setPlannerSwitch(app, planner, repo, { mode: "owner" })).toEqual({ ok: false, error: "forbidden" });
172 const paused = setPlannerSwitch(app, owner, repo, { mode: "owner" }, NOW);
173 expect(paused.ok).toBe(true);
174
175 const refusedIssue = openIssue(app, planner, repo, { title: "Again" });
176 expect(refusedIssue).toEqual({ ok: false, error: "planner_paused" });
177 const refusedPlan = openPlan(app, planner, repo, plan("b"));
178 expect(refusedPlan.ok ? "" : refusedPlan.error.error).toBe("planner_paused");
179 expect(queueAgentRun(app, repo, directionIssue, plannerAgent, "assigned")).toBeUndefined();
180 // Others carry on: a person's issue, a builder's plan and a builder's queued run.
181 expect(openIssue(app, owner, repo, { title: "Owner still asks" }).ok).toBe(true);
182 expect(openPlan(app, builder, repo, plan("c")).ok).toBe(true);
183 expect(queueAgentRun(app, repo, task.value, builderAgent, "assigned")).toBeDefined();
184
185 // Switched on, the planner works again.
186 expect(setPlannerSwitch(app, owner, repo, { mode: "on" }).ok).toBe(true);
187 expect(openIssue(app, planner, repo, { title: "Back" }).ok).toBe(true);
188 });
189
190 test("a timed pause lifts itself, and the weekly reset is the next Monday", () => {
191 const t = new Date(Date.now() + 3_600_000).toISOString();
192 expect(setPlannerSwitch(app, owner, repo, { mode: "until", at: t })).toEqual({ ok: true, value: t });
193 expect(pausedUntil(app.db, repo.id, new Date(Date.now() + 1000))).toBe(t);
194 expect(pausedUntil(app.db, repo.id, new Date(Date.now() + 7_200_000))).toBeNull();
195 expect(setPlannerSwitch(app, owner, repo, { mode: "until", at: "2020-01-01T00:00:00Z" })).toEqual({ ok: false, error: "invalid" });
196 expect(setPlannerSwitch(app, owner, repo, { mode: "until", at: "nonsense" })).toEqual({ ok: false, error: "invalid" });
197 expect(nextWeek(NOW)).toBe("2026-11-16T00:00:00.000Z");
198 expect(setPlannerSwitch(app, owner, repo, { mode: "week" }, NOW).ok).toBe(true);
199 setPlannerSwitch(app, owner, repo, { mode: "on" });
200 });
201
202 test("the API answers the numbers, the page shows them and the switch needs the owner and the same origin", async () => {
203 const res = await fetch(`${servers.url}/api/v1/repos/alice/shop/growth?days=14`);
204 expect(res.status).toBe(200);
205 const body = (await res.json()) as { days: unknown[]; open: { issues: number }; planner: { paused: boolean } };
206 expect(body.days).toHaveLength(14);
207 expect(body.planner.paused).toBe(false);
208 expect(body.open.issues).toBe(growth(app.db, repo, 14, new Date()).open.issues);
209 expect((await fetch(`${servers.url}/api/v1/repos/alice/shop/growth?days=0`)).status).toBe(422);
210 expect((await fetch(`${servers.url}/api/v1/repos/alice/shop/growth?days=999`)).status).toBe(422);
211
212 const html = await (await fetch(`${servers.url}/alice/shop/queue`)).text();
213 expect(html).toContain("data-growth");
214 expect(html).toContain("The planner is");
215 expect(html).not.toContain("Pause the planner"); // a stranger sees the state, not the switch
216
217 const token = createToken(app.db, owner.id, "t", "rw").token;
218 const form = (origin: string | null) =>
219 fetch(`${servers.url}/alice/shop/queue/planner`, {
220 method: "POST",
221 redirect: "manual",
222 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", ...(origin === null ? {} : { origin }) },
223 body: "mode=owner",
224 });
225 expect((await form("https://evil.example")).status).toBe(403);
226 expect(pausedUntil(app.db, repo.id, new Date())).toBeNull();
227 expect((await form(servers.url)).status).toBeLessThan(400);
228 expect(pausedUntil(app.db, repo.id, new Date())).not.toBeNull();
229 const paused = await (await fetch(`${servers.url}/alice/shop/queue`)).text();
230 expect(paused).toContain("paused");
231 });
232
233 test("the per-day reads walk an index, never a scan of issue", () => {
234 const db = app.db;
235 const original = db.query.bind(db);
236 const sqls: string[] = [];
237 (db as unknown as { query: unknown }).query = (sql: string) => (sqls.push(sql), original(sql));
238 try {
239 growth(db, repo, 14, NOW);
240 } finally {
241 (db as unknown as { query: unknown }).query = original;
242 }
243 const windowed = sqls.filter((q) => /^\s*SELECT/i.test(q) && /created_at >= \?2|closed_at >= \?2/.test(q) && !/FROM run r/.test(q));
244 expect(windowed).toHaveLength(2);
245 for (const q of windowed) {
246 const n = Math.max(...[...q.matchAll(/\?(\d+)/g)].map((m) => Number(m[1])));
247 const plan = original(`EXPLAIN QUERY PLAN ${q}`).all(...Array.from({ length: n }, () => "x")).map((r) => (r as { detail: string }).detail);
248 expect(plan.filter((d) => /SCAN (i|issue)\b/.test(d) && !/USING/.test(d))).toEqual([]);
249 }
250 // Opened-per-day walks the new index; closed-per-day walks the repository's own rows by an existing one.
251 const plans = windowed.map((q) => {
252 const n = Math.max(...[...q.matchAll(/\?(\d+)/g)].map((m) => Number(m[1])));
253 return original(`EXPLAIN QUERY PLAN ${q}`).all(...Array.from({ length: n }, () => "x")).map((r) => (r as { detail: string }).detail).join("\n");
254 });
255 expect(plans.some((p) => p.includes("issue_repo_created"))).toBe(true);
256 for (const p of plans) expect(p).toMatch(/SEARCH i(ssue)? USING (COVERING )?INDEX/);
257 });
258
259 test("a sealed issue is in no number and no title for a guest, a reader or a stranger; a writer sees it", async () => {
260 const before = growth(app.db, repo, 14, NOW, {});
261 const sealedId = insert(person, "issue", at(45), null);
262 app.db.query("UPDATE issue SET sealed = 1, title = 'secret hole in login' WHERE id = ?1").run(sealedId);
263 const sealedToday = insert(person, "issue", at(0), null);
264 app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(sealedToday);
265 const guest = sealedScope(app.db, repo, undefined);
266 const stranger = sealedScope(app.db, repo, { ...person, id: "someone-else" } as User);
267 for (const scope of [guest, stranger]) {
268 const g = growth(app.db, repo, 14, NOW, scope);
269 expect(g.open).toEqual(before.open);
270 expect(g.days[13]!.issuesOpened).toBe(before.days[13]!.issuesOpened);
271 expect(oldest(app.db, repo.id, scope).issue?.title).not.toBe("secret hole in login");
272 }
273 // The filer follows their own report; a writer sees all.
274 expect(growth(app.db, repo, 14, NOW, sealedScope(app.db, repo, person)).open.issues).toBe(before.open.issues + 2);
275 expect(growth(app.db, repo, 14, NOW, sealedScope(app.db, repo, owner)).open.issues).toBe(before.open.issues + 2);
276 expect(oldest(app.db, repo.id, sealedScope(app.db, repo, owner)).issue?.title).toBe("secret hole in login");
277
278 const page = await (await fetch(`${servers.url}/alice/shop/queue`)).text();
279 expect(page).not.toContain("secret hole in login");
280 const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/shop/growth`)).json()) as { open: { issues: number } };
281 expect(api.open.issues).toBe(before.open.issues);
282 app.db.query("DELETE FROM issue WHERE id IN (?1, ?2)").run(sealedId, sealedToday);
283 });
284
285 test("no dollars are on the page or in the API", async () => {
286 const body = await (await fetch(`${servers.url}/api/v1/repos/alice/shop/growth`)).text();
287 expect(body).not.toContain("spend");
288 const page = await (await fetch(`${servers.url}/alice/shop/queue`)).text();
289 expect(page).not.toContain("went to work");
290 expect(page).not.toMatch(/\$\d/);
291 });
292
293 test("the weekly reset pauses until the account's own reset, not just the next Monday", () => {
294 const added = addSubscription(app.db, dataDir, { name: "growth week", token: "sk-ant-oat01-growth-test-1" });
295 if (!added.ok) throw new Error(added.error);
296 const ran = (at: string, usd: number) =>
297 recordUsage(app.db, {
298 runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null,
299 subscriptionId: added.value.id, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at,
300 });
301 ran("2026-10-30T10:00:00.000Z", 20);
302 noteLimit(app.db, added.value.id, { window: "week", resetsAt: "2026-11-03T00:00:00.000Z", observedAt: "2026-11-04T00:00:00.000Z", said: "weekly limit" });
303 ran("2026-11-05T10:00:00.000Z", 5);
304 const NOW = new Date("2026-11-06T00:00:00Z");
305 const reset = weekCapacity(app.db, NOW)?.resetsAt;
306 expect(reset).toBeTruthy();
307 expect(reset).not.toBe(nextWeek(NOW));
308 expect(setPlannerSwitch(app, owner, repo, { mode: "week" }, NOW)).toEqual({ ok: true, value: reset! });
309 setPlannerSwitch(app, owner, repo, { mode: "on" });
310 });
311
312 test("the planner's pause and resume are for the admins to hear", () => {
313 expect(eventAudience("planner.paused")).toBe("admins");
314 expect(eventAudience("planner.resumed")).toBe("admins");
315 });
316
317 test("a time with or without seconds is UTC, and a pause says it in one sentence", () => {
318 expect(switchInput("until", "2026-11-20T09:00")).toEqual({ mode: "until", at: "2026-11-20T09:00:00Z" });
319 expect(switchInput("until", "2026-11-20T09:00:30")).toEqual({ mode: "until", at: "2026-11-20T09:00:30Z" });
320 expect(switchInput("until", "2026-11-20T09:00:00Z")).toEqual({ mode: "until", at: "2026-11-20T09:00:00Z" });
321 expect(switchInput("nope", "")).toBeUndefined();
322 expect(pauseSentence(null)).toBe("The planner is on.");
323 expect(pauseSentence("9999-12-31T00:00:00.000Z")).toContain("until you switch it on");
324 expect(pauseSentence("2026-11-20T09:00:00.000Z")).toBe("The planner is paused until 2026-11-20 09:00 UTC.");
325 });
326 });
test/tui-queue.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The queue in the terminal (#11604): the owner sees the panel and flips the planner's switch with
3 * keys, through the page's own action; a reader sees the panel and no keys; a sealed issue's title
4 * is in no line for someone who may not see it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { pausedUntil } from "../src/core/planner_pause.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { queueLines } from "../src/tui/screens/queuegrowth.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp, settlePalette } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 describe("the queue in the terminal", () => {
25 let dataDir: string;
26 let forge: Forge;
27 let ada: User;
28 let dave: User;
29 let repo: Repo;
30
31 function boot(user: User | undefined) {
32 const terminal = headlessTerminal(120, 40);
33 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
34 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
35 app.start();
36 const key = (k: string) => {
37 const ctrl = k.startsWith("ctrl+");
38 app.dispatch({ type: "key", key: ctrl ? k.slice(5) : k, ctrl, alt: false, shift: false });
39 };
40 const press = async (...keys: string[]) => {
41 for (const k of keys) key(k);
42 await settleApp(app);
43 };
44 const type = async (text: string) => {
45 for (const ch of text) key(ch);
46 await settleApp(app);
47 };
48 const ask = async (text: string) => {
49 await press("ctrl+k");
50 await type(text);
51 await settlePalette(app);
52 await press("enter");
53 };
54 const open = async () => {
55 await settleApp(app);
56 await ask("Open a repository");
57 await type("ada/shop");
58 await settlePalette(app);
59 await press("enter");
60 await ask("Queue");
61 };
62 return { text: () => app.screen.toText(), press, open, stop: () => app.stop() };
63 }
64
65 beforeAll(async () => {
66 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-queue-"));
67 forge = createApp({ dataDir, port: 0 });
68 const make = async (username: string, adminIfFirst: boolean): Promise<User> => {
69 const made = await createUser(forge.db, {
70 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
71 });
72 if (!made.ok) throw new Error(made.error);
73 return made.value;
74 };
75 ada = await make("ada", true);
76 dave = await make("dave", false);
77 const made = await createRepo(forge, ada, { name: "shop", visibility: "public", description: "" });
78 if (!made.ok) throw new Error(JSON.stringify(made.error));
79 repo = made.value;
80 }, 60_000);
81
82 afterAll(async () => {
83 forge?.close();
84 if (dataDir) await rm(dataDir, { recursive: true, force: true });
85 });
86
87 test("the owner reads the panel and pauses and resumes the planner with keys", async () => {
88 const h = boot(ada);
89 await h.open();
90 expect(h.text()).toContain("The planner is on.");
91 expect(h.text()).toContain("Issues opened");
92 await h.press("o");
93 expect(pausedUntil(forge.db, repo.id, new Date())).not.toBeNull();
94 expect(h.text()).toContain("The planner is paused until you switch it on.");
95 await h.press("n");
96 expect(pausedUntil(forge.db, repo.id, new Date())).toBeNull();
97 h.stop();
98 });
99
100 test("a reader sees the panel and no key changes the switch", async () => {
101 const h = boot(dave);
102 await h.open();
103 expect(h.text()).toContain("The planner is on.");
104 await h.press("o");
105 expect(pausedUntil(forge.db, repo.id, new Date())).toBeNull();
106 h.stop();
107 });
108
109 test("a sealed issue's title is in no line for a guest, and is for the writer", () => {
110 forge.db
111 .query(
112 `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, sealed) VALUES ('sx1', ?1, 900, 'issue', 'secret hole in login', ?2, 'open', 1)`,
113 )
114 .run(repo.id, ada.id);
115 const session = (user: User | undefined) => ({ db: forge.db, user }) as never;
116 expect(queueLines(session(undefined), repo).join("\n")).not.toContain("secret hole in login");
117 expect(queueLines(session(ada), repo).join("\n")).toContain("secret hole in login");
118 });
119 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs