Public: anyone can read this; the crew can change it.
The crew page says in one line what the slot governor last did and why #11536
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11459-the-crew-page-says-in-one-line-what-the → main 7 commits fork
17 files
changed +184
−17
docs/features/governor-line.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # The governor's line | |
| 2 | ||
| 3 | The crew card (studio and repository crew page) and MCP `crew` (`governor.says`) say in one line what the slot governor last did and why: "Builders 7 → 3 at 20:41: 9 opened vs 2 landed", or "Builders back to 7 at 21:30" (UTC). Nothing is said until the slot count has changed once: the first reading is only a baseline. | |
| 4 | ||
| 5 | The tick (`announceGovernor`, src/crew/governed.ts) feeds each reading through `governorStep` (src/crew/governor_log.ts) and keeps the result in the `governor_last` table of the app's sqlite file, so a restart keeps it. | |
| 6 | ||
| 7 | ## Where to read it | |
| 8 | ||
| 9 | - Page: the crew card's one Governor row (repository page and crew page) shows the line while the governor holds the crew below its slots; otherwise the current reason, and after a release "Builders back to 7 at 21:30" followed by the reason. It is one row, so the reason is not said twice. | |
| 10 | - MCP: `crew` returns `governor.says` (empty until a change) beside `governor.why`. | |
| 11 | - Terminal: the dashboard's repository row shows the same line (`governorLine`, src/crew/governed.ts) while the crew is held. | |
| 12 | ||
| 13 | Deleting a repository deletes its `governor_last` row. |
scripts/unreached-allow.json −1
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | [ |
| 2 | "src/crew/governor_log.ts", | |
| 3 | 2 | "src/flow/clog_words.ts", |
| 4 | 3 | "src/flow/conflict_pairs.ts", |
| 5 | 4 | "src/flow/shared_red.ts", |
src/core/migration-pins/20261111160000-governor-last.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | efa01f230b5afaca9c7b611dadfd91e585170785ed5855c0ed2619ee3cd48005 |
src/core/migrations/20261111160000-governor-last.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | // The governor's last change of slot count (#11459), one row per repository, so the crew page can | |
| 2 | // say what it did and why after a restart. A baseline is stored with from = to. Additive. | |
| 3 | export default ` | |
| 4 | CREATE TABLE governor_last ( | |
| 5 | repo_id TEXT PRIMARY KEY, | |
| 6 | from_slots INTEGER NOT NULL, | |
| 7 | to_slots INTEGER NOT NULL, | |
| 8 | why TEXT NOT NULL DEFAULT '', | |
| 9 | at INTEGER NOT NULL | |
| 10 | ); | |
| 11 | `; |
src/core/repos.ts +1
| @@ -1052,6 +1052,7 @@ export async function deleteRepo( | ||
| 1052 | 1052 | if (!removed.ok && present) return err("delete_failed"); |
| 1053 | 1053 | // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`). |
| 1054 | 1054 | db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id); |
| 1055 | db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repo.id); | |
| 1055 | 1056 | db.query("DELETE FROM repo WHERE id = ?1").run(repo.id); |
| 1056 | 1057 | forgetRepoCursors(db, repo.id); |
| 1057 | 1058 | forgetListings(db); |
src/crew/governed.ts +15 −1
| @@ -10,6 +10,7 @@ import type { App } from "../app.ts"; | ||
| 10 | 10 | import { canSeeOwnerFacts, type Repo } from "../core/repos.ts"; |
| 11 | 11 | import type { User } from "../core/users.ts"; |
| 12 | 12 | import { readRepoFlow } from "../flow/repo_flow.ts"; |
| 13 | import { governorSays, readLast, rememberGovernor } from "./governor_log.ts"; | |
| 13 | 14 | import { governedSlots, type Governed } from "./governor.ts"; |
| 14 | 15 | |
| 15 | 16 | /** The governor's reading of this repository's own flow, against the operator's slots (#7644). */ |
| @@ -22,6 +23,8 @@ export interface GovernorSeen extends Governed { | ||
| 22 | 23 | readonly ceiling: number; |
| 23 | 24 | /** True while the governor holds the crew below the operator's slots. */ |
| 24 | 25 | readonly held: boolean; |
| 26 | /** What the governor last did, in one line (#11459); empty until it has changed the slots. */ | |
| 27 | readonly says: string; | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | /** |
| @@ -32,7 +35,17 @@ export interface GovernorSeen extends Governed { | ||
| 32 | 35 | export function governorSeen(app: App, actor: User | undefined, repo: Repo, now: Date): GovernorSeen | undefined { |
| 33 | 36 | if (repo.autonomy === "off" || !canSeeOwnerFacts(app.db, repo, actor)) return undefined; |
| 34 | 37 | const g = governorFor(app, repo, now); |
| 35 | return { ...g, ceiling: repo.autonomySlots, held: g.slots < repo.autonomySlots }; | |
| 38 | return { ...g, ceiling: repo.autonomySlots, held: g.slots < repo.autonomySlots, says: governorSays(readLast(app.db, repo.id)) }; | |
| 39 | } | |
| 40 | ||
| 41 | /** | |
| 42 | * The governor in one line for every door (#11459): what it last did while the | |
| 43 | * crew is held (that line carries the reason), else the current reason, with | |
| 44 | * "back to N" followed by the reason it now gives. | |
| 45 | */ | |
| 46 | export function governorLine(g: GovernorSeen): string { | |
| 47 | if (g.says === "") return g.why; | |
| 48 | return g.held ? g.says : `${g.says}. ${g.why}`; | |
| 36 | 49 | } |
| 37 | 50 | |
| 38 | 51 | /** The governed number each repository was last seen at; unseen means the operator's slots. */ |
| @@ -45,6 +58,7 @@ const seen = new Map<string, number>(); | ||
| 45 | 58 | */ |
| 46 | 59 | export function announceGovernor(app: App, repo: Repo, now: Date): string | undefined { |
| 47 | 60 | const g = governorFor(app, repo, now); |
| 61 | rememberGovernor(app.db, repo.id, g, now.getTime()); | |
| 48 | 62 | const before = seen.get(repo.id) ?? repo.autonomySlots; |
| 49 | 63 | seen.set(repo.id, g.slots); |
| 50 | 64 | if (g.slots !== before) |
src/crew/governor_log.ts +21
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * reading is recorded with from === to (a baseline, not a change). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 7 | 8 | import type { Governed } from "./governor.ts"; |
| 8 | 9 | |
| 9 | 10 | export interface GovernorLast { |
| @@ -35,3 +36,23 @@ export function governorSays(last: GovernorLast | null): string { | ||
| 35 | 36 | if (last.to > last.from) return `Builders back to ${last.to} at ${clock(last.at)}`; |
| 36 | 37 | return `Builders ${last.from} → ${last.to} at ${clock(last.at)}: ${last.why}`; |
| 37 | 38 | } |
| 39 | ||
| 40 | interface Row { from_slots: number; to_slots: number; why: string; at: number } | |
| 41 | ||
| 42 | /** The remembered last change for a repository (kept in the app's sqlite file), or null before any reading. */ | |
| 43 | export function readLast(db: Database, repoId: string): GovernorLast | null { | |
| 44 | const r = db.query("SELECT from_slots, to_slots, why, at FROM governor_last WHERE repo_id = ?1").get(repoId) as Row | null; | |
| 45 | return r ? { from: r.from_slots, to: r.to_slots, why: r.why, at: r.at } : null; | |
| 46 | } | |
| 47 | ||
| 48 | /** Feeds one reading through `governorStep` and keeps the result; the line that says it. */ | |
| 49 | export function rememberGovernor(db: Database, repoId: string, governed: Governed, now: number): GovernorLast { | |
| 50 | const prev = readLast(db, repoId); | |
| 51 | const { last } = governorStep(prev, governed, now); | |
| 52 | if (last !== prev) | |
| 53 | db.query( | |
| 54 | "INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, ?2, ?3, ?4, ?5) " + | |
| 55 | "ON CONFLICT(repo_id) DO UPDATE SET from_slots = ?2, to_slots = ?3, why = ?4, at = ?5", | |
| 56 | ).run(repoId, last.from, last.to, last.why, last.at); | |
| 57 | return last; | |
| 58 | } |
src/http/mcp/tools/crew.ts +1 −1
| @@ -15,7 +15,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 15 | 15 | |
| 16 | 16 | export const tool: Tool = { |
| 17 | 17 | name: "crew", |
| 18 | doc: "`{ kind: \"week\" }` (forge admin): `{ days, members[{ username, merged, returned, spentUsd, idle }] }`, the last 7 days per agent; otherwise `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]`, how much each may do at once now and today's spend (0 is paused, still on the crew), and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `seat` (planner, builder or reviewer) with `slots` (0 to 8, the planner 0 or 1) sets how much that seat may do at once, 0 pausing it, on the trail at once; `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`", | |
| 18 | doc: "`{ kind: \"week\" }` (forge admin): `{ days, members[{ username, merged, returned, spentUsd, idle }] }`, the last 7 days per agent; otherwise `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]`, how much each may do at once now and today's spend (0 is paused, still on the crew), and `governor { slots, ceiling, held, why, says }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `seat` (planner, builder or reviewer) with `slots` (0 to 8, the planner 0 or 1) sets how much that seat may do at once, 0 pausing it, on the trail at once; `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`", | |
| 19 | 19 | description: "List a repository's crew, or add or remove an agent. Plans first; admin token.", |
| 20 | 20 | inputSchema: { |
| 21 | 21 | type: "object", |
src/tui/screens/dashboard.ts +2 −2
| @@ -18,7 +18,7 @@ import { DEFAULT_WEIGHTS, frontWeights, type FrontWeights } from "../../core/fro | ||
| 18 | 18 | import { exploreCount, exploreFiltered, exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts"; |
| 19 | 19 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 20 | 20 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 21 | import { governorSeen } from "../../crew/governed.ts"; | |
| 21 | import { governorLine, governorSeen } from "../../crew/governed.ts"; | |
| 22 | 22 | import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| 23 | 23 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; |
| 24 | 24 | import { forgeHealth } from "../../kernel/guard.ts"; |
| @@ -390,7 +390,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 390 | 390 | repo.autonomy === "autonomous" ? plannerHold(f.app, repo, now) : null; |
| 391 | 391 | if (hold !== null) return [[repo.id, holdWords(hold)] as const]; |
| 392 | 392 | const governor = governorSeen(f.app, f.user, repo, now); |
| 393 | return governor?.held === true ? [[repo.id, governor.why] as const] : []; | |
| 393 | return governor?.held === true ? [[repo.id, governorLine(governor)] as const] : []; | |
| 394 | 394 | }), |
| 395 | 395 | ); |
| 396 | 396 | alarms = |
src/web/studio/app.ts +2 −4
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | } from "../../core/runs.ts"; |
| 31 | 31 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 32 | 32 | import { governorSeen } from "../../crew/governed.ts"; |
| 33 | import { governorRows } from "./governor_rows.ts"; | |
| 33 | 34 | import { repoDir } from "../../git/repos.ts"; |
| 34 | 35 | import type { Ctx } from "../../http/context.ts"; |
| 35 | 36 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| @@ -434,10 +435,7 @@ export async function studioApp( | ||
| 434 | 435 | </dd> |
| 435 | 436 | <dt>Slots</dt> |
| 436 | 437 | <dd>${autonomy.active} of ${autonomy.slots} in use</dd> |
| 437 | ${governor === undefined | |
| 438 | ? null | |
| 439 | : html`<dt>Governor</dt> | |
| 440 | <dd>${governor.why}</dd>`} | |
| 438 | ${governorRows(governor)} | |
| 441 | 439 | ${autonomy.paused |
| 442 | 440 | ? html`<dt>Paused</dt> |
| 443 | 441 | <dd>${pausedWords(autonomy.paused)}</dd>` |
src/web/studio/crew.ts +2 −4
| @@ -32,6 +32,7 @@ import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | ||
| 32 | 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; |
| 33 | 33 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 34 | 34 | import { governorSeen } from "../../crew/governed.ts"; |
| 35 | import { governorRows } from "./governor_rows.ts"; | |
| 35 | 36 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| 36 | 37 | import type { Ctx } from "../../http/context.ts"; |
| 37 | 38 | import { repoBase } from "../chrome.ts"; |
| @@ -354,10 +355,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 354 | 355 | </dd> |
| 355 | 356 | <dt>Slots</dt> |
| 356 | 357 | <dd>${status.active} of ${status.slots} in use</dd> |
| 357 | ${governor === undefined | |
| 358 | ? null | |
| 359 | : html`<dt>Governor</dt> | |
| 360 | <dd>${governor.why}</dd>`} | |
| 358 | ${governorRows(governor)} | |
| 361 | 359 | ${status.paused |
| 362 | 360 | ? html`<dt>Paused</dt> |
| 363 | 361 | <dd> |
src/web/studio/governor_rows.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | /** The crew card's Governor row (#11459): one row, shared by the repository page and the crew page. */ | |
| 2 | ||
| 3 | import type { GovernorSeen } from "../../crew/governed.ts"; | |
| 4 | import { governorLine } from "../../crew/governed.ts"; | |
| 5 | import { html, type Html } from "../html.ts"; | |
| 6 | ||
| 7 | /** The `<dt>/<dd>` pair for the governor, or null while the crew is off or the reader may not see it. */ | |
| 8 | export function governorRows(governor: GovernorSeen | undefined): Html | null { | |
| 9 | return governor === undefined ? null : html`<dt>Governor</dt><dd>${governorLine(governor)}</dd>`; | |
| 10 | } |
test/crew-governed-unit.test.ts +27 −2
| @@ -20,7 +20,7 @@ import { addSubscription, setGrants } from "../src/core/subscriptions.ts"; | ||
| 20 | 20 | import { recordUsage } from "../src/core/usage.ts"; |
| 21 | 21 | import { createUser } from "../src/core/users.ts"; |
| 22 | 22 | import { repoPace } from "../src/crew/autonomy.ts"; |
| 23 | import { announceGovernor, governorFor, governorSeen } from "../src/crew/governed.ts"; | |
| 23 | import { announceGovernor, governorFor, governorLine, governorSeen } from "../src/crew/governed.ts"; | |
| 24 | 24 | import { governedSlots } from "../src/crew/governor.ts"; |
| 25 | 25 | import { readRepoFlow } from "../src/flow/repo_flow.ts"; |
| 26 | 26 | import { id } from "../src/kernel/id.ts"; |
| @@ -274,6 +274,31 @@ test("governorSeen gates the governor alike for every door: off crew or a reader | ||
| 274 | 274 | expect(governorSeen(app, user, repo, NOW)).toBeUndefined(); // autonomy off |
| 275 | 275 | const on: Repo = { ...repo, autonomy: "assisted" }; |
| 276 | 276 | const seen = governorSeen(app, user, on, NOW); |
| 277 | expect(seen).toEqual({ ...governorFor(app, on, NOW), ceiling: 4, held: true }); | |
| 277 | expect(seen).toEqual({ ...governorFor(app, on, NOW), ceiling: 4, held: true, says: "" }); | |
| 278 | 278 | expect(governorSeen(app, undefined, on, NOW)).toBeUndefined(); |
| 279 | 279 | }); |
| 280 | ||
| 281 | test("announceGovernor remembers a change: governorSeen says it after, and nothing before (#11459)", async () => { | |
| 282 | const { app, repo: r0, pull } = await forge(4); | |
| 283 | const repo = { ...r0, autonomy: "assisted" as const }; | |
| 284 | const actor = app.db.query<any, []>("SELECT * FROM user LIMIT 1").get(); | |
| 285 | const user = (await import("../src/core/users.ts")).getUserById(app.db, actor.id)!; | |
| 286 | expect(announceGovernor(app, repo, NOW)).toBeUndefined(); // baseline | |
| 287 | expect(governorSeen(app, user, repo, NOW)!.says).toBe(""); | |
| 288 | flood(pull); | |
| 289 | announceGovernor(app, repo, later(1)); | |
| 290 | const seenNow = governorSeen(app, user, repo, later(1))!; | |
| 291 | expect(seenNow.says).toStartWith("Builders 4 → "); | |
| 292 | expect(seenNow.says).toContain(seenNow.why); | |
| 293 | expect(governorLine(seenNow)).toBe(seenNow.says); | |
| 294 | }); | |
| 295 | ||
| 296 | test("deleting a repository forgets its governor line (#11459)", async () => { | |
| 297 | const { app, repo, pull } = await forge(4); | |
| 298 | announceGovernor(app, repo, NOW); | |
| 299 | flood(pull); | |
| 300 | announceGovernor(app, repo, later(1)); | |
| 301 | expect(app.db.query("SELECT 1 FROM governor_last WHERE repo_id = ?1").get(repo.id)).not.toBeNull(); | |
| 302 | await (await import("../src/core/repos.ts")).deleteRepo(app.db, app.reposRoot, repo); | |
| 303 | expect(app.db.query("SELECT 1 FROM governor_last WHERE repo_id = ?1").get(repo.id)).toBeNull(); | |
| 304 | }); |
test/governor-log.test.ts +35
| @@ -34,3 +34,38 @@ describe("governorStep", () => { | ||
| 34 | 34 | expect(governorSays(null)).toBe(""); |
| 35 | 35 | }); |
| 36 | 36 | }); |
| 37 | ||
| 38 | describe("the remembered last change", () => { | |
| 39 | const { Database } = require("bun:sqlite"); | |
| 40 | const { default: sql } = require("../src/core/migrations/20261111160000-governor-last.ts"); | |
| 41 | const { readLast, rememberGovernor } = require("../src/crew/governor_log.ts"); | |
| 42 | const fresh = () => { const db = new Database(":memory:"); db.exec(sql); return db; }; | |
| 43 | test("a baseline is stored but says nothing; a change is remembered and worded", () => { | |
| 44 | const db = fresh(); | |
| 45 | expect(governorSays(rememberGovernor(db, "r", { slots: 6, why: "w" }, at(14, 0)))).toBe(""); | |
| 46 | expect(readLast(db, "r")).toEqual({ from: 6, to: 6, why: "w", at: at(14, 0) }); | |
| 47 | rememberGovernor(db, "r", { slots: 4, why: "9 opened vs 2 landed" }, at(14, 2)); | |
| 48 | expect(governorSays(readLast(db, "r"))).toBe("Builders 6 → 4 at 14:02: 9 opened vs 2 landed"); | |
| 49 | }); | |
| 50 | test("a restart keeps it: the same file opened twice remembers the change", () => { | |
| 51 | const { mkdtempSync, rmSync } = require("node:fs"); | |
| 52 | const { tmpdir } = require("node:os"); | |
| 53 | const { join } = require("node:path"); | |
| 54 | const dir = mkdtempSync(join(tmpdir(), "forge-gov-")); | |
| 55 | const file = join(dir, "x.db"); | |
| 56 | try { | |
| 57 | const one = new Database(file); | |
| 58 | one.exec(sql); | |
| 59 | rememberGovernor(one, "r", { slots: 6, why: "w" }, at(14, 0)); | |
| 60 | rememberGovernor(one, "r", { slots: 4, why: "x" }, at(14, 2)); | |
| 61 | one.close(); | |
| 62 | const two = new Database(file); | |
| 63 | rememberGovernor(two, "r", { slots: 4, why: "y" }, at(15, 0)); | |
| 64 | expect(readLast(two, "r")?.at).toBe(at(14, 2)); | |
| 65 | expect(governorSays(readLast(two, "r"))).toBe("Builders 6 → 4 at 14:02: x"); | |
| 66 | two.close(); | |
| 67 | } finally { | |
| 68 | rmSync(dir, { recursive: true, force: true }); | |
| 69 | } | |
| 70 | }); | |
| 71 | }); |
test/governor-rows.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { governorLine, type GovernorSeen } from "../src/crew/governed.ts"; | |
| 3 | import { governorRows } from "../src/web/studio/governor_rows.ts"; | |
| 4 | ||
| 5 | const seen = (over: Partial<GovernorSeen>): GovernorSeen => ({ slots: 3, ceiling: 7, held: true, says: "", why: "Builders 3: busy", ...over }); | |
| 6 | ||
| 7 | test("the card's Governor row shows the last change once, and the reason alone when there is none (#11459)", () => { | |
| 8 | const said = String(governorRows(seen({ says: "Builders 7 → 3 at 20:41: 9 opened vs 2 landed" }))); | |
| 9 | expect(said).toContain("<dt>Governor</dt>"); | |
| 10 | expect(said).toContain("Builders 7 → 3 at 20:41: 9 opened vs 2 landed"); | |
| 11 | expect(said).not.toContain("Builders 3: busy"); | |
| 12 | expect(said).not.toContain("Last"); | |
| 13 | expect(String(governorRows(seen({})))).toContain("Builders 3: busy"); | |
| 14 | expect(governorRows(undefined)).toBeNull(); | |
| 15 | }); | |
| 16 | ||
| 17 | test("after a release the line says back-to and the current reason", () => { | |
| 18 | expect(governorLine(seen({ held: false, says: "Builders back to 7 at 21:30", why: "Builders 7: quiet" }))).toBe("Builders back to 7 at 21:30. Builders 7: quiet"); | |
| 19 | }); |
test/mcp-crew.test.ts +13 −1
| @@ -132,13 +132,25 @@ test("the listing carries the governor's slots and reason for a writer while the | ||
| 132 | 132 | app.db.query("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4 WHERE name = 'game'").run(); |
| 133 | 133 | try { |
| 134 | 134 | const on = (await crew(admin, {})).value.governor; |
| 135 | expect(on).toEqual({ slots: 4, ceiling: 4, held: false, why: "Builders 4: a quiet hour, 0 of 0 open pulls moving" }); | |
| 135 | expect(on).toEqual({ slots: 4, ceiling: 4, held: false, says: "", why: "Builders 4: a quiet hour, 0 of 0 open pulls moving" }); | |
| 136 | 136 | expect((await crew(bob, {})).value.governor).toBeNull(); // bob cannot write to alice/game |
| 137 | 137 | } finally { |
| 138 | 138 | app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run(); |
| 139 | 139 | } |
| 140 | 140 | }); |
| 141 | 141 | |
| 142 | test("the listing's governor carries what it last did once the slots changed (#11459)", async () => { | |
| 143 | app.db.query("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4 WHERE name = 'game'").run(); | |
| 144 | const repoId = (app.db.query("SELECT id FROM repo WHERE name = 'game'").get() as any).id; | |
| 145 | app.db.query("INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, 4, 2, '9 opened vs 2 landed', ?2)").run(repoId, Date.UTC(2026, 9, 26, 20, 41)); | |
| 146 | try { | |
| 147 | expect((await crew(admin, {})).value.governor.says).toBe("Builders 4 → 2 at 20:41: 9 opened vs 2 landed"); | |
| 148 | } finally { | |
| 149 | app.db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repoId); | |
| 150 | app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run(); | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 142 | 154 | test("the crew answer carries the three seats, empty ones named", async () => { |
| 143 | 155 | const r: any = await mcpTool(servers.url, admin, "crew", { repo: "alice/game" }); |
| 144 | 156 | const team = r.value.seats; |
test/tui-dashboard-governor.test.ts +11 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 7 | 7 | import { createRepo, getRepo } from "../src/core/repos.ts"; |
| 8 | 8 | import type { User } from "../src/core/users.ts"; |
| 9 | import { governorSeen } from "../src/crew/governed.ts"; | |
| 9 | import { announceGovernor, governorSeen } from "../src/crew/governed.ts"; | |
| 10 | 10 | import { id } from "../src/kernel/id.ts"; |
| 11 | 11 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 12 | 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -65,9 +65,19 @@ test("a flooded repository's row says the governor's reason; one at its ceiling | ||
| 65 | 65 | } |
| 66 | 66 | const g = governorSeen(forge, alice, repo, new Date())!; |
| 67 | 67 | expect(g.held).toBe(true); |
| 68 | expect(g.says).toBe(""); | |
| 68 | 69 | expect(g.why).toStartWith("Builders 4 → 1:"); |
| 69 | 70 | const screen = await firstScreen(alice); |
| 70 | 71 | expect(screen).toContain(g.why); |
| 71 | 72 | expect(screen).toContain("the calm one"); |
| 72 | 73 | expect(screen).not.toContain("the flooded one"); |
| 73 | 74 | }); |
| 75 | ||
| 76 | test("once the governor has changed the slots the row says the line (#11459)", async () => { | |
| 77 | const repo = getRepo(forge.db, "alice", "flooded")!; | |
| 78 | forge.db.run("INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, 4, 4, 'w', 0)", [repo.id]); | |
| 79 | announceGovernor(forge, repo, new Date()); | |
| 80 | const g = governorSeen(forge, alice, repo, new Date())!; | |
| 81 | expect(g.says).toStartWith("Builders 4 → 1 at "); | |
| 82 | expect(await firstScreen(alice)).toContain(g.says.slice(0, 60)); | |
| 83 | }); |