Public: anyone can read this; the crew can change it.
/settings/crew shows issues the crew parked twice and the last restore drill, each card only when it has something #3909
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3879-settings-crew-shows-issues-the-crew-park → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +334
−0
README.md +6
| @@ -27,6 +27,12 @@ claim state and the same apps-running note, never a claim link. | ||
| 27 | 27 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 28 | 28 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 29 | 29 | terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. |
| 30 | Settings → Crew also shows, each only when it has something, a "Handed to the | |
| 31 | planner" card (issues the crew parked twice: repo, issue link, when last | |
| 32 | parked, why) and a "Restore drill" card (the last drill's pass or fail and | |
| 33 | when). The terminal's palette has the same as "Crew hand-overs", a read-only | |
| 34 | list of the same rows (repo#number, title, reasons; it does not open the | |
| 35 | issue — use the link on the page). Both are a forge admin's only. | |
| 30 | 36 | Under the agents, each team that has a pull has one pooled row (its members' |
| 31 | 37 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 32 | 38 | both): `data-team` rows on the page, "team <name>" rows in that terminal screen. |
src/crew/handed.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * What the crew page and the terminal's crew screen say about the crew's | |
| 3 | * hand-overs: the issues it parked twice and gave to the planner | |
| 4 | * (`parkedTwice`), and the last restore drill. Each is read where its own | |
| 5 | * page reads it, and each is absent when there is nothing to say — an | |
| 6 | * empty card is a zero drawn. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { PARK_LIKE } from "../core/done_on_main.ts"; | |
| 11 | import { getReposByIds } from "../core/repos.ts"; | |
| 12 | import { lastDrill } from "../snapshot/drills.ts"; | |
| 13 | import { PARK_STRIKES, parkedTwice } from "./parks.ts"; | |
| 14 | ||
| 15 | /** A park reason is cut to this many characters on a card or a terminal row. */ | |
| 16 | export const REASON_MAX = 140; | |
| 17 | ||
| 18 | export interface HandedRow { | |
| 19 | readonly repo: string; | |
| 20 | readonly number: number; | |
| 21 | readonly title: string; | |
| 22 | readonly at: string; | |
| 23 | /** Each park reason, on one line. */ | |
| 24 | readonly reasons: readonly string[]; | |
| 25 | } | |
| 26 | ||
| 27 | export interface DrillLine { | |
| 28 | readonly at: string; | |
| 29 | readonly verdict: "green" | "red"; | |
| 30 | } | |
| 31 | ||
| 32 | export interface CrewHandover { | |
| 33 | readonly parked: readonly HandedRow[]; | |
| 34 | readonly drill: DrillLine | undefined; | |
| 35 | } | |
| 36 | ||
| 37 | /** One line, at most `REASON_MAX` characters. */ | |
| 38 | export function oneLine(reason: string): string { | |
| 39 | const flat = reason.replace(/\s+/g, " ").trim(); | |
| 40 | return flat.length <= REASON_MAX ? flat : `${flat.slice(0, REASON_MAX - 1)}…`; | |
| 41 | } | |
| 42 | ||
| 43 | /** The issues parked twice in any repository, newest park first. */ | |
| 44 | export function handedToPlanner(db: Database): HandedRow[] { | |
| 45 | const ids = db | |
| 46 | .query<{ id: string }, []>( | |
| 47 | `SELECT DISTINCT o.repo_id AS id FROM issue o | |
| 48 | WHERE o.kind = 'issue' AND o.state = 'open' | |
| 49 | AND (SELECT COUNT(*) FROM comment p | |
| 50 | WHERE p.issue_id = o.id AND p.body LIKE '${PARK_LIKE}') >= ${PARK_STRIKES}`, | |
| 51 | ) | |
| 52 | .all() | |
| 53 | .map((r) => r.id); | |
| 54 | if (ids.length === 0) return []; | |
| 55 | const repos = getReposByIds(db, ids); | |
| 56 | return parkedTwice(db, [...repos.values()]) | |
| 57 | .flatMap((p) => { | |
| 58 | const repo = repos.get(p.repoId); | |
| 59 | if (repo === undefined) return []; | |
| 60 | return [ | |
| 61 | { | |
| 62 | repo: `${repo.ownerName}/${repo.name}`, | |
| 63 | number: p.number, | |
| 64 | title: p.title, | |
| 65 | at: p.at, | |
| 66 | reasons: p.reasons.map(oneLine), | |
| 67 | }, | |
| 68 | ]; | |
| 69 | }) | |
| 70 | .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); | |
| 71 | } | |
| 72 | ||
| 73 | export function crewHandover(db: Database): CrewHandover { | |
| 74 | const d = lastDrill(db); | |
| 75 | return { | |
| 76 | parked: handedToPlanner(db), | |
| 77 | drill: d === undefined ? undefined : { at: d.at, verdict: d.verdict }, | |
| 78 | }; | |
| 79 | } |
src/tui/forge.ts +9
| @@ -52,6 +52,7 @@ import { flagsScreen } from "./screens/flags.ts"; | ||
| 52 | 52 | import { scorecardScreen } from "./screens/scorecard.ts"; |
| 53 | 53 | import { cloudScreen } from "./screens/cloud.ts"; |
| 54 | 54 | import { crewSplitsScreen } from "./screens/crewsplits.ts"; |
| 55 | import { crewHandoverScreen } from "./screens/crewhandover.ts"; | |
| 55 | 56 | import { usageScreen } from "./screens/usage.ts"; |
| 56 | 57 | import { canScreen } from "./screens/can.ts"; |
| 57 | 58 | import { peopleScreen } from "./screens/people.ts"; |
| @@ -397,6 +398,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 397 | 398 | hint: "agent runs per model and per prompt version", |
| 398 | 399 | run: () => ctx.push(crewSplitsScreen(session, ctx)), |
| 399 | 400 | }); |
| 401 | // Settings → Crew's hand-overs: issues parked twice, the last restore drill. | |
| 402 | if (session.user?.isAdmin === true) | |
| 403 | commands.push({ | |
| 404 | id: "crew-handover", | |
| 405 | title: "Crew hand-overs", | |
| 406 | hint: "issues parked twice, and the last restore drill", | |
| 407 | run: () => ctx.push(crewHandoverScreen(session, ctx)), | |
| 408 | }); | |
| 400 | 409 | // Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677). |
| 401 | 410 | if (session.user?.isAdmin === true) |
| 402 | 411 | commands.push({ |
src/tui/screens/crewhandover.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * The crew's hand-overs in the terminal, a forge admin's: the issues it | |
| 3 | * parked twice and the last restore drill, as Settings → Crew draws them | |
| 4 | * (`crewHandover`) — each section only when it has something to say. | |
| 5 | */ | |
| 6 | ||
| 7 | import { crewHandover } from "../../crew/handed.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { truncate } from "../term/width.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { fill, len, split } from "../ui/layout.ts"; | |
| 14 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 15 | ||
| 16 | export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | |
| 17 | const state = new ListState(); | |
| 18 | const allowed = f.user?.isAdmin === true; | |
| 19 | // Read once when the screen opens, not per frame. | |
| 20 | const { parked, drill } = allowed ? crewHandover(f.db) : { parked: [], drill: undefined }; | |
| 21 | const rows: ListRow[] = [ | |
| 22 | ...parked.map((r) => ({ | |
| 23 | title: `Handed to the planner: ${r.repo}#${r.number} ${r.title}`, | |
| 24 | secondary: `parked ${r.at.slice(0, 16).replace("T", " ")} UTC`, | |
| 25 | meta: r.reasons.join(" · "), | |
| 26 | })), | |
| 27 | ...(drill === undefined | |
| 28 | ? [] | |
| 29 | : [ | |
| 30 | { | |
| 31 | title: `Restore drill: ${drill.verdict === "green" ? "passed" : "failed"}`, | |
| 32 | secondary: drill.at.slice(0, 16).replace("T", " ") + " UTC", | |
| 33 | meta: "", | |
| 34 | }, | |
| 35 | ]), | |
| 36 | ]; | |
| 37 | return { | |
| 38 | title: "Crew hand-overs", | |
| 39 | keys: [binding(["j", "k"], "move cursor")], | |
| 40 | render(area, s) { | |
| 41 | const c = ctx.theme.c; | |
| 42 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 43 | const text = !allowed | |
| 44 | ? "The crew's hand-overs are read by a forge admin." | |
| 45 | : rows.length === 0 | |
| 46 | ? "Nothing handed to the planner, and no restore drill yet." | |
| 47 | : "Issues the crew parked twice, and the last restore drill."; | |
| 48 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 49 | drawList(s, list!, c, state, rows); | |
| 50 | }, | |
| 51 | key: (e) => state.key(e, rows.length), | |
| 52 | }; | |
| 53 | } |
src/web/pages/crew.ts +54
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 39 | 39 | import { listTeams, type Team } from "../../core/teams.ts"; |
| 40 | 40 | import { crewFor } from "../../crew/dispatch.ts"; |
| 41 | import { crewHandover, type CrewHandover } from "../../crew/handed.ts"; | |
| 41 | 42 | import { MODELS } from "../../crew/models.ts"; |
| 42 | 43 | import type { Ctx } from "../../http/context.ts"; |
| 43 | 44 | import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts"; |
| @@ -464,6 +465,7 @@ function crewView( | ||
| 464 | 465 | const { spending } = data; |
| 465 | 466 | const usernameError = |
| 466 | 467 | error !== undefined && /username/i.test(error) ? error : undefined; |
| 468 | const handover = crewHandover(ctx.app.db); | |
| 467 | 469 | return html`${crewReadiness(ctx)} |
| 468 | 470 | ${when(spending.length > 0, () => |
| 469 | 471 | spendingCard(spending, ctx.app.config.crewCeilingUsd), |
| @@ -576,6 +578,7 @@ function crewView( | ||
| 576 | 578 | byPrompt, |
| 577 | 579 | () => html`<span class="muted">not recorded</span>`, |
| 578 | 580 | )} |
| 581 | ${handedCard(handover.parked)}${drillCard(handover.drill)} | |
| 579 | 582 | ${card( |
| 580 | 583 | html`<form method="post" action="/settings/crew"> |
| 581 | 584 | ${uiField({ |
| @@ -606,6 +609,57 @@ function crewView( | ||
| 606 | 609 | )}`; |
| 607 | 610 | } |
| 608 | 611 | |
| 612 | /** Issues the crew parked twice and the planner now holds; nothing when there are none. */ | |
| 613 | function handedCard(parked: CrewHandover["parked"]): Html { | |
| 614 | if (parked.length === 0) return html``; | |
| 615 | return card( | |
| 616 | html`<div class="table-scroll"><table class="table"> | |
| 617 | <thead> | |
| 618 | <tr> | |
| 619 | <th>Issue</th> | |
| 620 | <th>Last parked</th> | |
| 621 | <th>Why</th> | |
| 622 | </tr> | |
| 623 | </thead> | |
| 624 | <tbody> | |
| 625 | ${parked.map( | |
| 626 | (r) => html`<tr> | |
| 627 | <td> | |
| 628 | <span class="mono">${r.repo}</span> | |
| 629 | <a href="/${r.repo}/issues/${r.number}">#${r.number} ${r.title}</a> | |
| 630 | </td> | |
| 631 | <td>${time(r.at)}</td> | |
| 632 | <td> | |
| 633 | ${r.reasons.map((why) => html`<div class="small">${why}</div>`)} | |
| 634 | </td> | |
| 635 | </tr>`, | |
| 636 | )} | |
| 637 | </tbody> | |
| 638 | </table></div>`, | |
| 639 | { | |
| 640 | pad: false, | |
| 641 | id: "crew-handed", | |
| 642 | head: html`${icon("bot")} | |
| 643 | <h2>Handed to the planner</h2>`, | |
| 644 | }, | |
| 645 | ); | |
The alert icon on a hand-off card reads as a fault on a calm settings page; use a neutral icon and leave tone for the failed drill. |
||
| 646 | } | |
| 647 | ||
| 648 | /** The last restore drill's verdict; nothing before a first drill. */ | |
| 649 | function drillCard(drill: CrewHandover["drill"]): Html { | |
| 650 | if (drill === undefined) return html``; | |
| 651 | return card( | |
| 652 | html`<p>${drill.verdict === "green" ? "Passed" : "Failed"} ${time(drill.at)}.</p>`, | |
| 653 | { | |
| 654 | id: "crew-drill", | |
| 655 | tone: drill.verdict === "green" ? "default" : "danger", | |
| 656 | attrs: html`data-drill="${drill.verdict}"`, | |
| 657 | head: html`${icon("clock")} | |
| 658 | <h2>Restore drill</h2>`, | |
| 659 | }, | |
| 660 | ); | |
| 661 | } | |
| 662 | ||
| 609 | 663 | /** The scorecard cut by one thing the runs ran on; nothing before a first run. */ |
| 610 | 664 | function splitsCard( |
| 611 | 665 | title: string, |
test/crew-handover.test.ts added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | /** | |
| 2 | * Settings → Crew and the terminal's crew screen say what the crew handed | |
| 3 | * on: issues parked twice (the planner's) and the last restore drill — to a | |
| 4 | * forge admin only, each only when it has something to say. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { recordDrill } from "../src/snapshot/drills.ts"; | |
| 9 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 10 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 11 | import { engine, tick, type Engine } from "./helpers/parks-engine.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { settleApp } from "./helpers/tui.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const evil = `<img src=x onerror=alert(1)>`; | |
| 18 | ||
| 19 | describe("what the crew handed on", () => { | |
| 20 | let e: Engine; | |
| 21 | let base: string; | |
| 22 | const cookies: Record<string, string> = {}; | |
| 23 | const signIn = async (name: string): Promise<string> => | |
| 24 | ( | |
| 25 | ( | |
| 26 | await fetch(`${base}/signin`, { | |
| 27 | method: "POST", | |
| 28 | body: new URLSearchParams({ username: name, password: "a-good-passphrase" }), | |
| 29 | redirect: "manual", | |
| 30 | }) | |
| 31 | ).headers.get("set-cookie") ?? "" | |
| 32 | ).split(";")[0]!; | |
| 33 | const crewPage = async (who: string): Promise<{ status: number; text: string }> => { | |
| 34 | const res = await fetch(`${base}/settings/crew`, { headers: { cookie: cookies[who]! } }); | |
| 35 | return { status: res.status, text: await res.text() }; | |
| 36 | }; | |
| 37 | const terminal = async (username: string): Promise<string> => { | |
| 38 | const user = (await import("../src/core/users.ts")).getUserByName(e.app.db, username)!; | |
| 39 | const { app } = createForgeApp( | |
| 40 | e.app, | |
| 41 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 42 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 43 | ); | |
| 44 | app.start(); | |
| 45 | const key = (k: string, ctrl = false) => | |
| 46 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 47 | key("k", true); | |
| 48 | for (const ch of "Crew hand-overs") key(ch); | |
| 49 | await settleApp(app); | |
| 50 | key("enter"); | |
| 51 | await settleApp(app); | |
| 52 | const text = app.screen.toText(); | |
| 53 | app.stop(); | |
| 54 | return text; | |
| 55 | }; | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | e = await engine("forge-handover-"); | |
| 59 | base = e.servers.url; | |
| 60 | const made = await createUser(e.app.db, { | |
| 61 | origin: "seed", | |
| 62 | trusted: true, | |
| 63 | username: "bob", | |
| 64 | email: "[email protected]", | |
| 65 | password: "a-good-passphrase", | |
| 66 | adminIfFirst: false, | |
| 67 | }); | |
| 68 | if (!made.ok) throw new Error(made.error); | |
| 69 | cookies.alice = await signIn("alice"); | |
| 70 | cookies.bob = await signIn("bob"); | |
| 71 | }); | |
| 72 | afterAll(() => e.close()); | |
| 73 | ||
| 74 | test("empty: neither card is drawn, and no drill copy", async () => { | |
| 75 | const { status, text } = await crewPage("alice"); | |
| 76 | expect(status).toBe(200); | |
| 77 | expect(text).not.toContain("Handed to the planner"); | |
| 78 | expect(text).not.toContain("Restore drill"); | |
| 79 | expect(text).not.toContain(`data-drill`); | |
| 80 | expect(await terminal("alice")).not.toContain("Restore drill:"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("an admin sees both; reasons are escaped", async () => { | |
| 84 | const work = e.issue("Scene graph"); | |
| 85 | expect(e.ended(work, `no changes pushed: ${evil}`)).toBe(true); | |
| 86 | await tick(); | |
| 87 | expect(e.ended(work, "nothing to propose")).toBe(true); | |
| 88 | await tick(); | |
| 89 | recordDrill(e.app.db, { | |
| 90 | at: new Date().toISOString(), | |
| 91 | snapshotId: "s1", | |
| 92 | ms: 10, | |
| 93 | counts: { users: 1, repositories: 1, issues: 1, pulls: 0, apps: 0 }, | |
| 94 | health: "ok", | |
| 95 | verdict: "red", | |
| 96 | failedStep: "open", | |
| 97 | phases: [], | |
| 98 | budgetMs: 0, | |
| 99 | reason: "x", | |
| 100 | }); | |
| 101 | const { text } = await crewPage("alice"); | |
| 102 | expect(text).toContain("Handed to the planner"); | |
| 103 | expect(text).toContain("Scene graph"); | |
| 104 | expect(text).toContain(`/alice/engine/issues/${work.number}`); | |
| 105 | expect(text).toContain("Restore drill"); | |
| 106 | expect(text).toContain(`data-drill="red"`); | |
| 107 | expect(text).not.toContain(evil); | |
| 108 | expect(text).toContain("<img src=x onerror=alert(1)>"); | |
| 109 | const tui = await terminal("alice"); | |
| 110 | expect(tui).toContain("Handed to the planner: alice/engine#"); | |
| 111 | expect(tui).toContain("Restore drill: failed"); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a non-admin gets neither", async () => { | |
| 115 | const { status, text } = await crewPage("bob"); | |
| 116 | expect(status).toBe(404); | |
| 117 | expect(text).not.toContain("Handed to the planner"); | |
| 118 | expect(text).not.toContain("Restore drill"); | |
| 119 | expect(await terminal("bob")).not.toContain("Handed to the planner: "); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a split issue leaves the card", async () => { | |
| 123 | const { setIssueState } = await import("../src/actions/issues.ts"); | |
| 124 | const work = e.issue("Another"); | |
| 125 | e.ended(work, "a"); | |
| 126 | await tick(); | |
| 127 | e.ended(work, "b"); | |
| 128 | await tick(); | |
| 129 | expect((await crewPage("alice")).text).toContain("Another"); | |
| 130 | setIssueState(e.app, e.alice, e.fresh(), work, "closed"); | |
| 131 | expect((await crewPage("alice")).text).not.toContain("Another"); | |
| 132 | }); | |
| 133 | }); |
new above
offline
A missing repo becomes "" and the page links to //N. Filter those rows out rather than draw a broken link.