Public: anyone can read this; the crew can change it.
The crew page shows one scorecard line per member: merged, returned, cost this week #10731
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10722-the-crew-page-shows-one-scorecard-line-p → main 5 commits fork 3 threads · 3 unresolved
12 files
changed +394
−1
docs/MCP.md +5
| @@ -66,6 +66,11 @@ each bug `{ repo, number, priority, dueAt }`, soonest due first, at most 100, | ||
| 66 | 66 | from `src/flow/bugs.ts`. An admin sees every repository; anyone else only |
| 67 | 67 | public ones, as the terminal's bugs screen does. |
| 68 | 68 | |
| 69 | `crew` with `{ kind: "week" }` is another (#10722), a forge admin's: `{ days, members }`, | |
| 70 | each member `{ username, merged, returned, spentUsd, idle }` over the last 7 | |
| 71 | days, from `src/crew/week_card.ts` (spend from the usage ledger). The same | |
| 72 | answer is `GET /api/v1/crew/week` and the "This week" block on Settings › Crew. | |
| 73 | ||
| 69 | 74 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 70 | 75 | to relay as it is. |
| 71 | 76 |
docs/features/crew-week-card.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # The crew's week | |
| 2 | ||
| 3 | One line per crew member for the last 7 days: pulls **merged**, pulls **returned** (changes requested by someone other than the author, counted per review) and **spent** (usage ledger). A member with all three at nothing reads `idle`. | |
| 4 | ||
| 5 | - **Page:** the "This week" block on Settings → Crew (`/settings/crew`), above the scorecards. | |
| 6 | - **API:** `GET /api/v1/crew/week` → `{ days, members[{ username, merged, returned, spentUsd, idle }] }`. | |
| 7 | - **MCP:** `crew` with `{ kind: "week" }`, the same JSON. | |
| 8 | - **Terminal:** **Crew this week** in the palette (`j`/`k` to move). | |
| 9 | - **Who sees it:** forge admins only, on all four doors; per-agent spend is theirs. A non-admin gets 404 on the page, 403 from the API and MCP, and one sentence in the terminal. | |
| 10 | - **Cost:** three grouped reads regardless of crew size; a test counts the statements. | |
| 11 | ||
| 12 | Left out: other windows than 7 days; per-repository filtering. |
src/contract/openapi/routes/getApiV1CrewWeek.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/crew/week", | |
| 6 | summary: "Read the crew's last 7 days (a forge admin's, any token scope): { days, members: [{ username, merged, returned, spentUsd, idle }] }, merged pulls, pulls returned by review and ledger spend per agent; idle is true when all three are nothing", | |
| 7 | auth: true, | |
| 8 | refusals: { unauthorized: "no valid credentials were sent", forbidden: "the caller is not a forge admin" }, | |
| 9 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -71,6 +71,7 @@ export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns. | ||
| 71 | 71 | export { route as getApiV1Conversations } from "./getApiV1Conversations.ts"; |
| 72 | 72 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; |
| 73 | 73 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| 74 | export { route as getApiV1CrewWeek } from "./getApiV1CrewWeek.ts"; | |
| 74 | 75 | export { route as getApiV1Explore } from "./getApiV1Explore.ts"; |
| 75 | 76 | export { route as getApiV1Family } from "./getApiV1Family.ts"; |
| 76 | 77 | export { route as getApiV1Features } from "./getApiV1Features.ts"; |
src/crew/week_card.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * The week's scorecard line per crew member (#10722): pulls merged, pulls | |
| 3 | * returned by review, and spend over the last 7 days, so an owner sees who | |
| 4 | * earns its keep without opening runs. Three grouped reads, no per-agent | |
| 5 | * query; spend comes from the usage ledger, not the run rows. | |
| 6 | * | |
| 7 | * - merged = pulls the agent authored that merged in the window | |
| 8 | * - returned = changes-requested reviews by someone else in the window on pulls it authored | |
| 9 | * - spentUsd = ledger cost attributed to the agent in the window | |
| 10 | * A member with all three at nothing is `idle`, said as a word, not as zeros. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | ||
| 15 | export const WEEK_DAYS = 7; | |
| 16 | ||
| 17 | export interface WeekLine { | |
| 18 | readonly username: string; | |
| 19 | readonly merged: number; | |
| 20 | readonly returned: number; | |
| 21 | readonly spentUsd: number; | |
| 22 | readonly idle: boolean; | |
| 23 | } | |
| 24 | ||
| 25 | const money = (n: number) => Math.round(n * 10_000) / 10_000; | |
| 26 | ||
| 27 | /** Every agent's line for the 7 days before `now`, by username. */ | |
| 28 | export function weekCard(db: Database, now: Date = new Date()): WeekLine[] { | |
| 29 | const since = new Date(now.getTime() - WEEK_DAYS * 86_400_000).toISOString(); | |
| 30 | const count = (sql: string) => | |
| 31 | new Map(db.query<{ id: string; n: number }, [string]>(sql).all(since).map((r) => [r.id, r.n])); | |
| 32 | const merged = count( | |
| 33 | `SELECT issue.author_id AS id, COUNT(*) AS n FROM pull | |
| 34 | JOIN issue ON issue.id = pull.issue_id | |
| 35 | WHERE pull.merged_at >= ?1 GROUP BY issue.author_id`, | |
| 36 | ); | |
| 37 | const returned = count( | |
| 38 | `SELECT issue.author_id AS id, COUNT(*) AS n FROM review | |
| 39 | JOIN issue ON issue.id = review.issue_id | |
| 40 | JOIN pull ON pull.issue_id = issue.id | |
| 41 | WHERE review.state = 'changes_requested' AND review.author_id <> issue.author_id | |
| 42 | AND review.created_at >= ?1 | |
| 43 | GROUP BY issue.author_id`, | |
| 44 | ); | |
| 45 | const spent = count( | |
| 46 | `SELECT agent_id AS id, SUM(cost_usd) AS n FROM usage | |
| 47 | WHERE agent_id IS NOT NULL AND at >= ?1 GROUP BY agent_id`, | |
| 48 | ); | |
| 49 | return db | |
| 50 | .query<{ id: string; username: string }, []>( | |
| 51 | `SELECT user.id AS id, user.username AS username FROM user | |
| 52 | WHERE user.is_agent = 1 ORDER BY user.username COLLATE NOCASE`, | |
| 53 | ) | |
| 54 | .all() | |
| 55 | .map((u) => { | |
| 56 | const m = merged.get(u.id) ?? 0; | |
| 57 | const r = returned.get(u.id) ?? 0; | |
| 58 | const s = money(spent.get(u.id) ?? 0); | |
| 59 | return { username: u.username, merged: m, returned: r, spentUsd: s, idle: m === 0 && r === 0 && s === 0 }; | |
| 60 | }); | |
| 61 | } |
src/http/api.ts +11
| @@ -65,6 +65,7 @@ import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts" | ||
| 65 | 65 | import { flowCopy, flowEtag, flowMaxAge } from "../system/flow_cache.ts"; |
| 66 | 66 | import { countRead } from "../system/telemetry_reads.ts"; |
| 67 | 67 | import { historyFor, sourcesOut, systemWithReaders, welcome } from "../system/telemetry.ts"; |
| 68 | import { weekCard, WEEK_DAYS } from "../crew/week_card.ts"; | |
| 68 | 69 | import { markReaders } from "../crew/direct.ts"; |
| 69 | 70 | import { |
| 70 | 71 | addCommentThrough, |
| @@ -1338,6 +1339,16 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1338 | 1339 | return timePhase(ctx.request, "render", () => json(card)); |
| 1339 | 1340 | }); |
| 1340 | 1341 | |
| 1342 | // The week's line per crew member (`crew/week_card.ts`); as MCP `crew` with kind "week". | |
| 1343 | router.add("GET", "/api/v1/crew/week", (ctx) => { | |
| 1344 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1345 | const denied = requireScope(ctx, "ro"); | |
| 1346 | if (denied) return denied; | |
| 1347 | if (ctx.auth.user?.isAdmin !== true) return apiError("forbidden", 403, ADMIN_WORDS.scorecard); | |
| 1348 | const members = timePhase(ctx.request, "week", () => weekCard(ctx.app.db)); | |
| 1349 | return timePhase(ctx.request, "render", () => json({ days: WEEK_DAYS, members })); | |
| 1350 | }); | |
| 1351 | ||
| 1341 | 1352 | // What the crew is doing right now, as MCP `now` answers it: a read, so |
| 1342 | 1353 | // any scope; the visibility checks live in `actions/crew_now.ts`. |
| 1343 | 1354 | router.add("GET", "/api/v1/crew/now", (ctx) => { |
src/http/mcp/tools/crew.ts +9 −1
| @@ -9,11 +9,13 @@ import { crewOffInPreview } from "../../../crew/preview.ts"; | ||
| 9 | 9 | import { setSeatSlots, seatSlotsSaid } from "../../../actions/seat_slots.ts"; |
| 10 | 10 | import { checkSeatSlots, seatSlotsOf } from "../../../crew/seat_slots.ts"; |
| 11 | 11 | import { seatsOf } from "../../../crew/seats.ts"; |
| 12 | import { weekCard, WEEK_DAYS } from "../../../crew/week_card.ts"; | |
| 13 | import { ADMIN_WORDS } from "../../../actions/admin_words.ts"; | |
| 12 | 14 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 13 | 15 | |
| 14 | 16 | export const tool: Tool = { |
| 15 | 17 | name: "crew", |
| 16 | doc: "`{ 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 }`, 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`", | |
| 17 | 19 | description: "List a repository's crew, or add or remove an agent. Plans first; admin token.", |
| 18 | 20 | inputSchema: { |
| 19 | 21 | type: "object", |
| @@ -22,6 +24,7 @@ export const tool: Tool = { | ||
| 22 | 24 | add: { type: "string" }, |
| 23 | 25 | remove: { type: "string" }, |
| 24 | 26 | seat: { type: "string" }, |
| 27 | kind: { type: "string" }, | |
| 25 | 28 | slots: { type: "integer" }, |
| 26 | 29 | dryRun: { type: "boolean" }, |
| 27 | 30 | }, |
| @@ -29,6 +32,11 @@ export const tool: Tool = { | ||
| 29 | 32 | writes: true, |
| 30 | 33 | dryByDefault: true, |
| 31 | 34 | async run(ctx, user, args) { |
| 35 | if (args["kind"] !== undefined) { | |
| 36 | if (args["kind"] !== "week") return bad("invalid", "`kind` is `week`"); | |
| 37 | if (user.isAdmin !== true) return bad("forbidden", ADMIN_WORDS.scorecard); | |
| 38 | return good({ days: WEEK_DAYS, members: weekCard(ctx.app.db) }); | |
| 39 | } | |
| 32 | 40 | const repo = repoArg(ctx, user, args); |
| 33 | 41 | if (isAnswer(repo)) return repo; |
| 34 | 42 | const { app } = ctx; |
src/tui/screens/entries.ts +2
| @@ -60,6 +60,7 @@ import { entry as profile } from "./profile.ts"; | ||
| 60 | 60 | import { entry as quotas } from "./quotas.ts"; |
| 61 | 61 | import { entry as resilience } from "./resilience.ts"; |
| 62 | 62 | import { entry as scorecard } from "./scorecard.ts"; |
| 63 | import { entry as weekcard } from "./weekcard.ts"; | |
| 63 | 64 | import { entry as scrapes } from "./scrapes.ts"; |
| 64 | 65 | import { entry as trafficScreenEntry } from "./traffic.ts"; |
| 65 | 66 | import { entry as securityControls } from "./controls.ts"; |
| @@ -112,6 +113,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 112 | 113 | knobs, |
| 113 | 114 | crewHandover, |
| 114 | 115 | scorecard, |
| 116 | weekcard, | |
| 115 | 117 | modelGrantEntry, |
| 116 | 118 | modelCeilingEntry, |
| 117 | 119 | people, |
src/tui/screens/weekcard.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * The crew's week in the terminal (#10722): one line per member, merged, | |
| 3 | * returned and spend over the last 7 days, as Settings → Crew's "This week" | |
| 4 | * block has it (`weekCard`). Gated as the page, the API and MCP are: a forge | |
| 5 | * admin; anyone else sees one sentence and no figures. | |
| 6 | */ | |
| 7 | ||
| 8 | import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import { style } from "../term/style.ts"; | |
| 12 | import { truncate } from "../term/width.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 16 | import type { PaletteEntry } from "./entries.ts"; | |
| 17 | ||
| 18 | /** One row per member; one with nothing in the week reads "idle", not zeros. */ | |
| 19 | export function weekRows(lines: readonly WeekLine[]): ListRow[] { | |
| 20 | return lines.map((l) => ({ | |
| 21 | title: l.username, | |
| 22 | secondary: l.idle ? "idle" : `merged ${l.merged} · returned ${l.returned} · spent $${l.spentUsd.toFixed(2)}`, | |
| 23 | })); | |
| 24 | } | |
| 25 | ||
| 26 | export function weekCardScreen(f: Session, ctx: Ctx): Screen { | |
| 27 | const state = new ListState(); | |
| 28 | const allowed = f.user?.isAdmin === true; | |
| 29 | const rows = allowed ? weekRows(weekCard(f.db)) : []; | |
| 30 | return { | |
| 31 | title: "Crew this week", | |
| 32 | keys: [binding(["j", "k"], "move cursor")], | |
| 33 | render(area, s) { | |
| 34 | const c = ctx.theme.c; | |
| 35 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 36 | const text = !allowed | |
| 37 | ? "The crew's week is read by a forge admin." | |
| 38 | : rows.length === 0 | |
| 39 | ? "No agents yet." | |
| 40 | : `The last ${WEEK_DAYS} days.`; | |
| 41 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 42 | drawList(s, list!, c, state, rows); | |
| 43 | }, | |
| 44 | key: (e) => state.key(e, rows.length), | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | /** Settings → Crew's "This week" block: a forge admin. */ | |
| 49 | export const entry: PaletteEntry = { | |
| 50 | id: "crew-week", | |
| 51 | title: "Crew this week", | |
| 52 | hint: "each agent's pulls merged, returned and spend over 7 days", | |
| 53 | after: ["scorecard"], | |
| 54 | offered: (session) => session.user?.isAdmin === true, | |
| 55 | run: (session, ctx) => ctx.push(weekCardScreen(session, ctx)), | |
| 56 | }; |
src/web/pages/crew.ts +31
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | type SplitScorecard, |
| 38 | 38 | } from "../../core/scorecard.ts"; |
| 39 | 39 | import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts"; |
| 40 | import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts"; | |
| 40 | 41 | import { knobsCard } from "./knobs.ts"; |
| 41 | 42 | import { decideChange, readReview } from "../../actions/model_review.ts"; |
| 42 | 43 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; |
| @@ -390,6 +391,35 @@ function scorecardCard( | ||
| 390 | 391 | ); |
| 391 | 392 | } |
| 392 | 393 | |
| 394 | /** The week at a glance: one dense row per member; a member with nothing reads "idle". */ | |
| 395 | function weekCardPanel(lines: readonly WeekLine[]): Html { | |
| 396 | return panel( | |
| 397 | html`<div class="table-scroll"> | |
| 398 | <table class="table" id="crew-week" data-sticky-lead> | |
| 399 | <thead> | |
| 400 | <tr><th>Member</th><th class="num">Merged</th><th class="num">Returned</th><th class="num">Spent</th></tr> | |
| 401 | </thead> | |
| 402 | <tbody> | |
| 403 | ${lines.map((l) => | |
| 404 | l.idle | |
| 405 | ? html`<tr data-idle> | |
| 406 | <td>${userLink(l.username, { avatar: false })}</td> | |
| 407 | <td colspan="3" class="muted">idle</td> | |
| 408 | </tr>` | |
| 409 | : html`<tr> | |
| 410 | <td>${userLink(l.username, { avatar: false })}</td> | |
| 411 | <td class="num">${l.merged}</td> | |
| 412 | <td class="num">${l.returned}</td> | |
| 413 | <td class="num">$${l.spentUsd.toFixed(2)}</td> | |
| 414 | </tr>`, | |
| 415 | )} | |
| 416 | </tbody> | |
| 417 | </table> | |
| 418 | </div>`, | |
| 419 | { title: `This week · ${WEEK_DAYS} days`, id: "crew-week-card" }, | |
| 420 | ); | |
| 421 | } | |
| 422 | ||
| 393 | 423 | /** Today's spend against each ceiling: the drain, and the knob for it. */ |
| 394 | 424 | function spendingCard( |
| 395 | 425 | spending: readonly { repo: Repo; status: AutonomyStatus }[], |
| @@ -690,6 +720,7 @@ function crewView( | ||
| 690 | 720 | </div>`, |
| 691 | 721 | { title: "Agents", id: "crew-agents" }, |
| 692 | 722 | )} |
| 723 | ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db)))} | |
| 693 | 724 | ${when(agents.length > 0, () => |
| 694 | 725 | scorecardCard( |
| 695 | 726 | agents, |
test/crew-week-card.test.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** | |
| 2 | * The crew's week (#10722): one line per member — merged, returned, spend — | |
| 3 | * on the crew page, GET /api/v1/crew/week and MCP `crew` { kind: "week" }. | |
| 4 | * Two members with different histories; the rows, the word "idle" and the | |
| 5 | * MCP answer must agree. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createAgentRow } from "../src/core/agents.ts"; | |
| 13 | import { createIssue } from "../src/core/issues.ts"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { weekCard } from "../src/crew/week_card.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { statements } from "./helpers/statements.ts"; | |
| 22 | import { expectWork } from "./helpers/work.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let token: string; | |
| 31 | let cookie: string; | |
| 32 | let repoId: string; | |
| 33 | let reviewer: User; | |
| 34 | ||
| 35 | const bot = async (username: string): Promise<User> => { | |
| 36 | const b = await createUser(app.db, { | |
| 37 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", | |
| 38 | }); | |
| 39 | if (!b.ok) throw new Error(String(b.error)); | |
| 40 | app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(b.value.id); | |
| 41 | createAgentRow(app.db, { userId: b.value.id, model: "opus" }); | |
| 42 | return b.value; | |
| 43 | }; | |
| 44 | ||
| 45 | const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); | |
| 46 | ||
| 47 | /** A pull by `author`, merged `mergedDaysAgo` days ago, returned once at `returnedDaysAgo`. */ | |
| 48 | function pull(author: User, mergedDaysAgo: number, returnedDaysAgo?: number) { | |
| 49 | const issue = createIssue(app.db, { repoId, authorId: author.id, title: "p", kind: "pull" }); | |
| 50 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 51 | app.db | |
| 52 | .query( | |
| 53 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 54 | VALUES (?1, 'main', 'h', 'a', 'b', ?2)`, | |
| 55 | ) | |
| 56 | .run(issue.value.id, daysAgo(mergedDaysAgo)); | |
| 57 | if (returnedDaysAgo !== undefined) | |
| 58 | app.db | |
| 59 | .query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`) | |
| 60 | .run(crypto.randomUUID(), issue.value.id, reviewer.id, daysAgo(returnedDaysAgo)); | |
| 61 | } | |
| 62 | ||
| 63 | function spend(agent: User, usd: number, ago: number) { | |
| 64 | const run = crypto.randomUUID(); | |
| 65 | app.db | |
| 66 | .query(`INSERT INTO run (id, repo_id, kind, actor_id, state) VALUES (?1, ?2, 'agent', ?3, 'ok')`) | |
| 67 | .run(run, repoId, agent.id); | |
| 68 | app.db | |
| 69 | .query( | |
| 70 | `INSERT INTO usage (run_id, repo_id, agent_id, subscription_id, kind, cost_usd, outcome, at) | |
| 71 | VALUES (?1, ?2, ?3, 'platform', 'build', ?4, 'ok', ?5)`, | |
| 72 | ) | |
| 73 | .run(run, repoId, agent.id, usd, daysAgo(ago)); | |
| 74 | } | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | dir = await mkdtemp(join(tmpdir(), "forge-crew-week-")); | |
| 78 | app = createApp({ dataDir: dir, port: 0 }); | |
| 79 | servers = await serve(app); | |
| 80 | const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 81 | if (!ada.ok) throw new Error(ada.error); | |
| 82 | const repo = await createRepo(app, ada.value, { name: "hello", visibility: "public" }); | |
| 83 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 84 | repoId = repo.value.id; | |
| 85 | reviewer = await bot("rev"); | |
| 86 | const smith = await bot("smith"); | |
| 87 | await bot("quiet"); | |
| 88 | const old = await bot("oldtimer"); | |
| 89 | pull(smith, 1, 2); | |
| 90 | pull(smith, 3); | |
| 91 | pull(smith, 20, 20); // outside the week | |
| 92 | spend(smith, 1.25, 1); | |
| 93 | spend(smith, 0.5, 40); // outside the week | |
| 94 | pull(old, 30, 30); | |
| 95 | spend(old, 9, 30); | |
| 96 | token = createToken(app.db, ada.value.id, "t", "ro").token; | |
| 97 | cookie = `forge_session=${createSession(app.db, ada.value.id).token}`; | |
| 98 | }); | |
| 99 | afterAll(async () => { | |
| 100 | await servers.stop(); | |
| 101 | app.close(); | |
| 102 | await rm(dir, { recursive: true, force: true }); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the week counts only the last 7 days, and no activity is idle", () => { | |
| 106 | const lines = weekCard(app.db); | |
| 107 | expect(lines).toEqual([ | |
| 108 | { username: "oldtimer", merged: 0, returned: 0, spentUsd: 0, idle: true }, | |
| 109 | { username: "quiet", merged: 0, returned: 0, spentUsd: 0, idle: true }, | |
| 110 | { username: "rev", merged: 0, returned: 0, spentUsd: 0, idle: true }, | |
| 111 | { username: "smith", merged: 2, returned: 1, spentUsd: 1.25, idle: false }, | |
| 112 | ]); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the crew page: a dense row for the busy one, the word idle for the rest", async () => { | |
| 116 | const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie } }); | |
| 117 | expect(res.status).toBe(200); | |
| 118 | const page = await res.text(); | |
| 119 | const table = page.slice(page.indexOf('id="crew-week"'), page.indexOf("</table>", page.indexOf('id="crew-week"'))); | |
| 120 | const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1); | |
| 121 | expect(rows).toHaveLength(4); | |
| 122 | expect(rows[3]).toMatch(/smith[\s\S]*>2<[\s\S]*>1<[\s\S]*\$1\.25/); | |
| 123 | expect(rows[3]).not.toContain("idle"); | |
| 124 | expect(rows[2]).toContain("idle"); | |
| 125 | expect(rows[2]).not.toContain("$0"); | |
| 126 | expect(rows[1]).toContain("idle"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the API and the MCP crew tool answer the same numbers", async () => { | |
| 130 | const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${token}` } }); | |
| 131 | expect(api.status).toBe(200); | |
| 132 | const body = (await api.json()) as { days: number; members: any[] }; | |
| 133 | expect(body.days).toBe(7); | |
| 134 | expect(body.members).toEqual(weekCard(app.db)); | |
| 135 | const rpc = await fetch(`${servers.url}/mcp`, { | |
| 136 | method: "POST", | |
| 137 | headers: { | |
| 138 | "content-type": "application/json", | |
| 139 | accept: "application/json, text/event-stream", | |
| 140 | authorization: `Bearer ${token}`, | |
| 141 | }, | |
| 142 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "crew", arguments: { kind: "week" } } }), | |
| 143 | }).then((r) => r.json() as Promise<any>); | |
| 144 | expect(JSON.parse(rpc.result.content[0].text)).toEqual(body); | |
| 145 | expect((await fetch(`${servers.url}/api/v1/crew/week`)).status).toBe(401); | |
| 146 | }); | |
| 147 | ||
| 148 | test("20 members cost a fixed few statements, not one per member", async () => { | |
| 149 | for (let i = 0; i < 16; i++) app.db.query("INSERT INTO user (id, username, email, password_hash, is_agent) VALUES (?1, ?2, ?3, '', 1)").run(crypto.randomUUID(), `bulk${i}`, `bulk${i}@x.test`); | |
| 150 | const n = await statements(app.db, () => { | |
| 151 | expect(weekCard(app.db).length).toBe(20); | |
| 152 | }); | |
| 153 | expectWork(n, { max: 6 }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a change request by the pull's own author is not a return", () => { | |
| 157 | const smith = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'smith'`).get()!; | |
| 158 | const issue = app.db.query<{ id: string }, [string]>(`SELECT issue_id AS id FROM pull JOIN issue ON issue.id = pull.issue_id WHERE issue.author_id = ?1 LIMIT 1`).get(smith.id)!; | |
| 159 | const before = weekCard(app.db).find((l) => l.username === "smith")!.returned; | |
| 160 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`).run(crypto.randomUUID(), issue.id, smith.id, daysAgo(1)); | |
| 161 | expect(weekCard(app.db).find((l) => l.username === "smith")!.returned).toBe(before); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a non-admin: the page is not found and has no week block, the API and MCP refuse", async () => { | |
| 165 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "pat", email: "[email protected]", password: "a-good-passphrase" }); | |
| 166 | if (!u.ok) throw new Error(String(u.error)); | |
| 167 | const pc = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 168 | const page = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: pc } }); | |
| 169 | expect(page.status).toBe(404); | |
| 170 | expect(await page.text()).not.toContain("crew-week"); | |
| 171 | const pt = createToken(app.db, u.value.id, "t", "ro").token; | |
| 172 | const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${pt}` } }); | |
| 173 | expect(api.status).toBe(403); | |
| 174 | const rpc = await fetch(`${servers.url}/mcp`, { | |
| 175 | method: "POST", | |
| 176 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${pt}` }, | |
| 177 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "crew", arguments: { kind: "week" } } }), | |
| 178 | }).then((r) => r.json() as Promise<any>); | |
| 179 | expect(JSON.stringify(rpc)).toContain("forbidden"); | |
| 180 | expect(JSON.stringify(rpc)).not.toContain("spentUsd"); | |
| 181 | }); | |
| 182 | ||
| 183 | test("the terminal rows say idle, and show the figures for the busy", async () => { | |
| 184 | const { weekRows } = await import("../src/tui/screens/weekcard.ts"); | |
| 185 | const rows = weekRows(weekCard(app.db)); | |
| 186 | expect(rows.find((r) => r.title === "quiet")!.secondary).toBe("idle"); | |
| 187 | expect(rows.find((r) => r.title === "smith")!.secondary).toContain("spent $1.25"); | |
| 188 | }); | |
| 189 | ||
| 190 | test("the crew page costs a bounded number of statements", async () => { | |
| 191 | const n = await statements(app.db, async () => { | |
| 192 | await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text(); | |
| 193 | }); | |
| 194 | expectWork(n, { max: 150 }); | |
| 195 | }); |
test/palette-roster.test.ts +2
| @@ -129,6 +129,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 129 | 129 | ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"], |
| 130 | 130 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 131 | 131 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| 132 | ["crew-week", "Crew this week", "each agent's pulls merged, returned and spend over 7 days"], | |
| 132 | 133 | ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"], |
| 133 | 134 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 134 | 135 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| @@ -224,6 +225,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 224 | 225 | ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"], |
| 225 | 226 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 226 | 227 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| 228 | ["crew-week", "Crew this week", "each agent's pulls merged, returned and spend over 7 days"], | |
| 227 | 229 | ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"], |
| 228 | 230 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 229 | 231 | ["can", "What I can do", "your role and what it allows here"], |
Principle 1: this note explains twice what the title and headers say. Cut it.