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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11604-the-owner-sees-the-work-growing-or-shrin → main 4 commits fork
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"; | ||
| 24 | 24 | import type { App } from "../app.ts"; |
| 25 | 25 | import { adoptResolution } from "../core/trusted_sources.ts"; |
| 26 | 26 | import { crewRole, isCrew } from "../core/agents.ts"; |
| 27 | import { pausedUntil } from "../core/planner_pause.ts"; | |
| 27 | 28 | import { moveBlockersToChildren } from "../core/dependencies.ts"; |
| 28 | 29 | import { |
| 29 | 30 | addComment as insertComment, |
| @@ -94,11 +95,18 @@ export function openIssue( | ||
| 94 | 95 | input: { title: string; body?: string; sealed?: boolean }, |
| 95 | 96 | ): Result< |
| 96 | 97 | Issue, |
| 97 | "not_found" | "unauthorized" | TitleError | "body_too_long" | MirrorError | |
| 98 | "not_found" | "unauthorized" | "planner_paused" | TitleError | "body_too_long" | MirrorError | |
| 98 | 99 | > { |
| 99 | 100 | const user = requireParticipant(app, actor, repo); |
| 100 | 101 | if (!user.ok) return user; |
| 101 | 102 | 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"); | |
| 102 | 110 | // Anyone who may open an issue may seal it: a reader reporting a hole |
| 103 | 111 | // seals it too, and from then on only writers and the crew see it |
| 104 | 112 | // (`core/sealed.ts`). |
| @@ -801,7 +809,7 @@ export async function openIssueThrough( | ||
| 801 | 809 | ): Promise< |
| 802 | 810 | Result< |
| 803 | 811 | Issue, |
| 804 | ThroughError<"not_found" | "unauthorized" | TitleError | "body_too_long"> | |
| 812 | ThroughError<"not_found" | "unauthorized" | "planner_paused" | TitleError | "body_too_long"> | |
| 805 | 813 | > |
| 806 | 814 | > { |
| 807 | 815 | const user = requireParticipant(app, actor, repo); |
src/actions/plan.ts +2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * nothing reaches the ready list without a reason to be built now. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { pausedUntil, PAUSE_REFUSAL } from "../core/planner_pause.ts"; | |
| 13 | 14 | import { sealedScope } from "../core/sealed.ts"; |
| 14 | 15 | import type { App } from "../app.ts"; |
| 15 | 16 | import { crewRole, isCrew } from "../core/agents.ts"; |
| @@ -231,6 +232,7 @@ function vet( | ||
| 231 | 232 | // A person's note from an app is owed an answer before the planner adds to the pile (#10563). |
| 232 | 233 | // Only the repo's planner is held; it lifts when a crew member comments, or the issue is closed or hidden. |
| 233 | 234 | 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); | |
| 234 | 236 | const waiting = unansweredFromApp(app.db, repo.id); |
| 235 | 237 | if (waiting !== undefined) |
| 236 | 238 | 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 = | ||
| 67 | 67 | | "bad_from" |
| 68 | 68 | | "not_found" |
| 69 | 69 | | "unauthorized" |
| 70 | /** The owner paused the planner (`core/planner_pause.ts`). */ | |
| 71 | | "planner_paused" | |
| 70 | 72 | /** Someone who may not direct the crew here (`crew/direct.ts`). */ |
| 71 | 73 | | "forbidden" |
| 72 | 74 | /** 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 | ||
| 150 | 150 | export { route as getApiV1ReposOwnerNameFollowingProposals } from "./getApiV1ReposOwnerNameFollowingProposals.ts"; |
| 151 | 151 | export { route as getApiV1ReposOwnerNameForks } from "./getApiV1ReposOwnerNameForks.ts"; |
| 152 | 152 | export { route as getApiV1ReposOwnerNameGatesIdLog } from "./getApiV1ReposOwnerNameGatesIdLog.ts"; |
| 153 | export { route as getApiV1ReposOwnerNameGrowth } from "./getApiV1ReposOwnerNameGrowth.ts"; | |
| 153 | 154 | export { route as getApiV1ReposOwnerNameHooks } from "./getApiV1ReposOwnerNameHooks.ts"; |
| 154 | 155 | export { route as getApiV1ReposOwnerNameHooksIdDeliveries } from "./getApiV1ReposOwnerNameHooksIdDeliveries.ts"; |
| 155 | 156 | export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameIssues.ts"; |
src/contract/tags.ts +1
| @@ -14,6 +14,7 @@ const ALIAS: Readonly<Record<string, string>> = { | ||
| 14 | 14 | "make-it-yours": "forks", |
| 15 | 15 | contribute: "forks", |
| 16 | 16 | queue: "gates", |
| 17 | growth: "issues", | |
| 17 | 18 | checks: "pulls", |
| 18 | 19 | conversation: "conversations", |
| 19 | 20 | "conversation-confirms": "conversations", |
src/core/event_audience.ts +3
| @@ -227,6 +227,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 227 | 227 | // Who is trusted to fold in, and the switch that stops it, are the owner's (`actions/trusted_sources.ts`). |
| 228 | 228 | kind.startsWith("trust.") || |
| 229 | 229 | kind.startsWith("foldin.") || |
| 230 | // The owner's planner switch is the admins' to hear (#11604). | |
| 231 | kind === "planner.paused" || | |
| 232 | kind === "planner.resumed" || | |
| 230 | 233 | // Events the retired FLOW ONLY switch wrote stay the admins' to hear. |
| 231 | 234 | kind === "flow_only.cleared" || |
| 232 | 235 | // 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 @@ | ||
| 8 | 8 | * time. A missed wake-up costs a tick, never a run. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { pausedUntil } from "../core/planner_pause.ts"; | |
| 11 | 12 | import { crewFilters, isStrangerPull } from "./stranger.ts"; |
| 12 | 13 | import { TEMP_FULL } from "../checks/disk_full.ts"; |
| 13 | 14 | import { freshPullFor } from "./fresh_pull.ts"; |
| @@ -464,6 +465,9 @@ export function queueAgentRun( | ||
| 464 | 465 | if (!accountActive(app.db, repo.ownerId) || isContributionFork(app.db, repo.id)) return undefined; |
| 465 | 466 | // Never a job outside the agent's seat: building is a builder's, reviewing a reviewer's. |
| 466 | 467 | 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; | |
| 467 | 471 | // An archived repository starts nothing new; a run carrying on (`again`) |
| 468 | 472 | // is one already going, and finishes. A first run |
| 469 | 473 | // (`attempt` 0, as a conflict or converge run is queued) is new. |
src/http/api.ts +18
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { growth, MAX_DAYS as MAX_GROWTH_DAYS } from "../core/growth.ts"; | |
| 12 | import { PAUSE_REFUSAL, pausedUntil } from "../core/planner_pause.ts"; | |
| 11 | 13 | import { teamHoldRefusal } from "../core/teams_words.ts"; |
| 12 | 14 | import { guardedRefusal } from "../crew/stranger.ts"; |
| 13 | 15 | import { MAX_PASS, passOf } from "../actions/box_pass.ts"; |
| @@ -668,6 +670,7 @@ export function originAware(error: string, status = 422): Response { | ||
| 668 | 670 | 422, |
| 669 | 671 | "a bug closed without a fix says why: send `note` (one sentence, at most 300 characters)", |
| 670 | 672 | ); |
| 673 | if (error === "planner_paused") return apiError("planner_paused", 409, PAUSE_REFUSAL); | |
| 671 | 674 | if (error === "mirrored") |
| 672 | 675 | return apiError( |
| 673 | 676 | "mirrored", |
| @@ -4033,6 +4036,21 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4033 | 4036 | return timePhase(ctx.request, "render", () => json(paged(items, limit, (g) => g.id), 200, { "cache-control": "no-store" })); |
| 4034 | 4037 | }); |
| 4035 | 4038 | |
| 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 | ||
| 4036 | 4054 | // A kept gate's log (`checks/gate_log.ts`): the last 200 lines, as text. |
| 4037 | 4055 | router.add("GET", "/api/v1/repos/:owner/:name/gates/:id/log", (ctx) => { |
| 4038 | 4056 | const repo = loadRepo(ctx); |
src/http/server.ts +3
| @@ -171,6 +171,7 @@ import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; | ||
| 171 | 171 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 172 | 172 | import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts"; |
| 173 | 173 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 174 | import { queuePage, queueSwitch } from "../web/pages/queue.ts"; | |
| 174 | 175 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 175 | 176 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 176 | 177 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; |
| @@ -1290,6 +1291,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1290 | 1291 | router.add("POST", "/:owner/:name/flows/update", flowsUpdate); |
| 1291 | 1292 | router.add("GET", "/:owner/:name/findings", findingsPage); |
| 1292 | 1293 | 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); | |
| 1293 | 1296 | router.add("GET", "/:owner/:name/bets", betsPage); |
| 1294 | 1297 | router.add("POST", "/:owner/:name/bets/propose", betPropose); |
| 1295 | 1298 | 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"; | ||
| 169 | 169 | import { variantsCommands } from "./variants.ts"; |
| 170 | 170 | import { autonomyCommands } from "./autonomy.ts"; |
| 171 | 171 | import { trafficCommands } from "./trafficview.ts"; |
| 172 | import { queueCommands } from "./queuegrowth.ts"; | |
| 172 | 173 | import { blockedCommands } from "./blocked.ts"; |
| 173 | 174 | import { statementCommands } from "./statement.ts"; |
| 174 | 175 | import { moneyCommands } from "./money.ts"; |
| @@ -1992,6 +1993,7 @@ export function workspaceScreen( | ||
| 1992 | 1993 | ...variantsCommands(f, ctx, repo), |
| 1993 | 1994 | ...autonomyCommands(f, ctx, repo), |
| 1994 | 1995 | ...trafficCommands(f, ctx, repo), |
| 1996 | ...queueCommands(f, ctx, repo), | |
| 1995 | 1997 | ...blockedCommands(f, ctx, repo), |
| 1996 | 1998 | ...statementCommands(f, ctx, repo), |
| 1997 | 1999 | ...moneyCommands(f, ctx, repo), |
src/web/pages/issues.ts +4 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * Mobile-first, no JavaScript required. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { PAUSE_REFUSAL } from "../../core/planner_pause.ts"; | |
| 8 | 9 | import { |
| 9 | 10 | addCommentThrough, |
| 10 | 11 | openIssueThrough, |
| @@ -262,7 +263,9 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> { | ||
| 262 | 263 | ? TITLE_TOO_LONG |
| 263 | 264 | : created.error === "body_too_long" |
| 264 | 265 | ? 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."); | |
| 266 | 269 | return composePage(ctx, repo, typed, message); |
| 267 | 270 | } |
| 268 | 271 | 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 | }); |