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

Merged opened this

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

Merged

scout/forge:scout/3879-settings-crew-shows-issues-the-crew-park → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/3879-settings-crew-shows-issues-the-crew-park was merged into main as a202031.

Review

Sign in to review this change.
6 files changed +334 −0
README.md +6
@@ -27,6 +27,12 @@ claim state and the same apps-running note, never a claim link.
2727 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
2828 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
2929 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.
3036 Under the agents, each team that has a pull has one pooled row (its members'
3137 counts added, the ratios taken from the sums; an agent on two teams counts in
3238 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 [
outdated

A missing repo becomes "" and the page links to /​/N. Filter those rows out rather than draw a broken link.

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";
5252 import { scorecardScreen } from "./screens/scorecard.ts";
5353 import { cloudScreen } from "./screens/cloud.ts";
5454 import { crewSplitsScreen } from "./screens/crewsplits.ts";
55 import { crewHandoverScreen } from "./screens/crewhandover.ts";
5556 import { usageScreen } from "./screens/usage.ts";
5657 import { canScreen } from "./screens/can.ts";
5758 import { peopleScreen } from "./screens/people.ts";
@@ -397,6 +398,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
397398 hint: "agent runs per model and per prompt version",
398399 run: () => ctx.push(crewSplitsScreen(session, ctx)),
399400 });
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 });
400409 // Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677).
401410 if (session.user?.isAdmin === true)
402411 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 {
3838 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
3939 import { listTeams, type Team } from "../../core/teams.ts";
4040 import { crewFor } from "../../crew/dispatch.ts";
41 import { crewHandover, type CrewHandover } from "../../crew/handed.ts";
4142 import { MODELS } from "../../crew/models.ts";
4243 import type { Ctx } from "../../http/context.ts";
4344 import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
@@ -464,6 +465,7 @@ function crewView(
464465 const { spending } = data;
465466 const usernameError =
466467 error !== undefined && /username/i.test(error) ? error : undefined;
468 const handover = crewHandover(ctx.app.db);
467469 return html`${crewReadiness(ctx)}
468470 ${when(spending.length > 0, () =>
469471 spendingCard(spending, ctx.app.config.crewCeilingUsd),
@@ -576,6 +578,7 @@ function crewView(
576578 byPrompt,
577579 () => html`<span class="muted">not recorded</span>`,
578580 )}
581 ${handedCard(handover.parked)}${drillCard(handover.drill)}
579582 ${card(
580583 html`<form method="post" action="/settings/crew">
581584 ${uiField({
@@ -606,6 +609,57 @@ function crewView(
606609 )}`;
607610 }
608611
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
609663 /** The scorecard cut by one thing the runs ran on; nothing before a first run. */
610664 function splitsCard(
611665 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("&lt;img src=x onerror=alert(1)&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs