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

Merged opened this

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

Merged

scout/forge:scout/10722-the-crew-page-shows-one-scorecard-line-p → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/10722-the-crew-page-shows-one-scorecard-line-p was merged into main as 8d8a8a5.

Review

Sign in to review this change.
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,
6666 from `src/flow/bugs.ts`. An admin sees every repository; anyone else only
6767 public ones, as the terminal's bugs screen does.
6868
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
6974 Answers are JSON in a text block; most carry a `says` sentence short enough
7075 to relay as it is.
7176
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.
7171 export { route as getApiV1Conversations } from "./getApiV1Conversations.ts";
7272 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
7373 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
74 export { route as getApiV1CrewWeek } from "./getApiV1CrewWeek.ts";
7475 export { route as getApiV1Explore } from "./getApiV1Explore.ts";
7576 export { route as getApiV1Family } from "./getApiV1Family.ts";
7677 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"
6565 import { flowCopy, flowEtag, flowMaxAge } from "../system/flow_cache.ts";
6666 import { countRead } from "../system/telemetry_reads.ts";
6767 import { historyFor, sourcesOut, systemWithReaders, welcome } from "../system/telemetry.ts";
68 import { weekCard, WEEK_DAYS } from "../crew/week_card.ts";
6869 import { markReaders } from "../crew/direct.ts";
6970 import {
7071 addCommentThrough,
@@ -1338,6 +1339,16 @@ export function apiRoutes(router: Router<Handler>): void {
13381339 return timePhase(ctx.request, "render", () => json(card));
13391340 });
13401341
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
13411352 // What the crew is doing right now, as MCP `now` answers it: a read, so
13421353 // any scope; the visibility checks live in `actions/crew_now.ts`.
13431354 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";
99 import { setSeatSlots, seatSlotsSaid } from "../../../actions/seat_slots.ts";
1010 import { checkSeatSlots, seatSlotsOf } from "../../../crew/seat_slots.ts";
1111 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";
1214 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1315
1416 export const tool: Tool = {
1517 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`",
1719 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
1820 inputSchema: {
1921 type: "object",
@@ -22,6 +24,7 @@ export const tool: Tool = {
2224 add: { type: "string" },
2325 remove: { type: "string" },
2426 seat: { type: "string" },
27 kind: { type: "string" },
2528 slots: { type: "integer" },
2629 dryRun: { type: "boolean" },
2730 },
@@ -29,6 +32,11 @@ export const tool: Tool = {
2932 writes: true,
3033 dryByDefault: true,
3134 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 }
3240 const repo = repoArg(ctx, user, args);
3341 if (isAnswer(repo)) return repo;
3442 const { app } = ctx;
src/tui/screens/entries.ts +2
@@ -60,6 +60,7 @@ import { entry as profile } from "./profile.ts";
6060 import { entry as quotas } from "./quotas.ts";
6161 import { entry as resilience } from "./resilience.ts";
6262 import { entry as scorecard } from "./scorecard.ts";
63 import { entry as weekcard } from "./weekcard.ts";
6364 import { entry as scrapes } from "./scrapes.ts";
6465 import { entry as trafficScreenEntry } from "./traffic.ts";
6566 import { entry as securityControls } from "./controls.ts";
@@ -112,6 +113,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
112113 knobs,
113114 crewHandover,
114115 scorecard,
116 weekcard,
115117 modelGrantEntry,
116118 modelCeilingEntry,
117119 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 {
3737 type SplitScorecard,
3838 } from "../../core/scorecard.ts";
3939 import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts";
40 import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts";
4041 import { knobsCard } from "./knobs.ts";
4142 import { decideChange, readReview } from "../../actions/model_review.ts";
4243 import { wastedDays, type WastedDay } from "../../core/wasted_days.ts";
@@ -390,6 +391,35 @@ function scorecardCard(
390391 );
391392 }
392393
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
outdated

Principle 1: this note explains twice what the title and headers say. Cut it.

393423 /** Today's spend against each ceiling: the drain, and the knob for it. */
394424 function spendingCard(
395425 spending: readonly { repo: Repo; status: AutonomyStatus }[],
@@ -690,6 +720,7 @@ function crewView(
690720 </div>`,
691721 { title: "Agents", id: "crew-agents" },
692722 )}
723 ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db)))}
693724 ${when(agents.length > 0, () =>
694725 scorecardCard(
695726 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", () => {
129129 ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"],
130130 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
131131 ["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"],
132133 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
133134 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
134135 ["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",
224225 ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"],
225226 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
226227 ["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"],
227229 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
228230 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
229231 ["can", "What I can do", "your role and what it allows here"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs