Public: anyone can read this; the crew can change it.

Bets: the owner backs a few named wholes with a share of the week, and the crew finishes them #8771

Merged opened this in answer to

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

Merged

scout/forge:scout/8713-bets-the-owner-backs-a-few-named-wholes → main 5 commits fork

  • Merged. scout/8713-bets-the-owner-backs-a-few-named-wholes was merged into main as fca2566.

Review

Sign in to review this change.
22 files changed +1452 −8
docs/features/bets.md added +14
@@ -0,0 +1,14 @@
1 # Bets
2
3 A bet is a name, an appetite (a share of the week), what done looks like in a few journeys, and scopes — existing issues — that each finish on their own. One crew member leads. Open `/<owner>/<repo>/bets`; the MCP has `bets` (read) and `bet` (act); the terminal has **Bets** in the palette (`p` propose, `b` back, `s` stop, `c` cut, `e` end, `l` what is left, `d` feels done, `n` not yet). All three go through `actions/bets.ts`, and every write emits a `bet.*` event, which the web pages follow with their "new activity" pill.
4 - **Who sees what.** The plan (shares, journeys, left lines, a verdict's reason) is for a person who administers the repo; any other reader sees names, states and scopes. Agents never act on a bet. `bet.*` events are admin-only to match.
5
6 - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet by their first label; a theme with two or more issues becomes a proposal at 20% of the week. At most five proposals a pass.
7 - **Back.** The owner (a person who administers the repo, never an agent) backs one with a share. At most 5 run, and their shares stay within 100%.
8 - **Picks.** Once a repository has ever had a bet backed, builders pick only scopes of a running bet, issues the owner asked for or raised, and small fixes (held to `slots.smallShare`, #7571). An issue in no bet does not start by itself. A repository with no bet picks as before.
9 - **Appetite.** The budget is share × builder slots × one week of builder time, counted from the runs on the bet's scopes since it was backed. At the budget the bet stops: no extension. Its left line defaults to the open scopes; the lead rewrites it in one line; the owner re-backs, cuts or ends it (ending as done is for a stopped bet only; a running bet finishes when every scope closes).
10 - **Scopes** read *figuring out* (no run yet), *executing*, or *raised hand* (nothing moved for two days), or *done*.
11 - **Finishing.** When every scope is closed the bet is finished and its page says "This is done. Try it." with a link; the owner taps *Feels done* or *Not yet, because…* (the bet returns to stopped with that reason). `/changelog` marks each landing with its bet and filters by it (`?bet=`).
12 - **Idle forge.** If nothing was ever backed and the oldest proposal is a day old, the top two start at 10%, marked "not backed by the owner". One tap on *Stop it* ends any of them, and the planner does not restart a stopped bet.
13
14 Left out: proposing from notes (only open issues, by label; tracked as a follow-up), a per-week reset of the appetite window.
src/actions/bets.ts added +183
@@ -0,0 +1,183 @@
1 /**
2 * Bets (#8713): the planner proposes them from open work, the owner backs
3 * one with a tap and a share, the crew finishes them. A bet that spends its
4 * appetite stops — never extended by itself — and the lead says in one line
5 * what is left, for the owner to re-back, cut or end. Every change is an
6 * event; only a person who administers the repository decides.
7 */
8
9 import type { App } from "../app.ts";
10 import { listCrewRoles } from "../core/agents.ts";
11 import {
12 type Bet, betRuns, betScopes, getBet, insertBet, listBets, markBacked, markEnded, setLeftLine, setTryUrl, setVerdict,
13 } from "../core/bets.ts";
14 import { labelsForIssues } from "../core/issues.ts";
15 import { canAdmin, type Repo } from "../core/repos.ts";
16 import type { User } from "../core/users.ts";
17 import { AUTO_SHARE, autoStarts, budgetSeconds, mayBack, spentSeconds, spentUp } from "../crew/bets.ts";
18 import { err, ok, type Result } from "../kernel/result.ts";
19
20 export type BetError = "forbidden" | "not_found" | "refused";
21 type Answer = Result<Bet, { error: BetError; message: string }>;
22
23 const refuse = (error: BetError, message: string): Answer => err({ error, message });
24 const NAME_MAX = 80;
25 const LINE_MAX = 240;
26 export const oneLine = (s: string, max = LINE_MAX): string => s.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max);
27
28 /** A person (never an agent) who administers the repository. */
29 function owns(app: App, repo: Repo, user: User): boolean {
30 return user.kind !== "agent" && canAdmin(app.db, repo, user);
31 }
32
33 function load(app: App, repo: Repo, id: string): Bet | undefined {
34 const bet = getBet(app.db, id);
35 return bet !== undefined && bet.repoId === repo.id ? bet : undefined;
36 }
37
38 const backed = (app: App, repo: Repo) => listBets(app.db, repo.id).filter((b) => b.state === "backed");
39
40 /**
41 * The planner's proposals: open issues in no live bet, grouped by their first
42 * label (what they share, the theme), a bet each with a modest share and the
43 * issues as scopes, led by the crew's first builder. Run again it adds only
44 * what is new. At most five.
45 */
46 export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] {
47 const live = new Set(
48 app.db
49 .query<{ issue_id: string }, [string]>(
50 `SELECT bi.issue_id FROM bet_issue bi JOIN bet b ON b.id = bi.bet_id
51 WHERE b.repo_id = ?1 AND b.state IN ('proposed','backed','stopped')`,
52 )
53 .all(repo.id)
54 .map((r) => r.issue_id),
55 );
56 const open = app.db
57 .query<{ id: string; number: number; title: string }, [string]>(
58 "SELECT id, number, title FROM issue WHERE repo_id = ?1 AND state = 'open' AND kind = 'issue' ORDER BY number",
59 )
60 .all(repo.id)
61 .filter((i) => !live.has(i.id));
62 const labels = labelsForIssues(app.db, open.map((i) => i.id));
63 const themes = new Map<string, typeof open>();
64 for (const i of open) {
65 const theme = (labels.get(i.id) ?? [])[0]?.name ?? "";
66 if (theme !== "") themes.set(theme, [...(themes.get(theme) ?? []), i]);
67 }
68 const named = new Set(listBets(app.db, repo.id).filter((b) => b.state !== "cut" && b.state !== "done").map((b) => b.name));
69 const lead = listCrewRoles(app.db, repo.id).find((c) => c.role === "builder")?.agent.user.id ?? null;
70 const made: Bet[] = [];
71 for (const [theme, issues] of [...themes].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) {
72 const name = oneLine(theme[0]!.toUpperCase() + theme.slice(1), NAME_MAX);
73 if (made.length >= 5 || issues.length < 2 || named.has(name)) continue;
74 const journeys = issues.slice(0, 3).map((i) => `Someone can ${oneLine(i.title, 100)}`).join("\n");
75 const bet = insertBet(app.db, { repoId: repo.id, name, journeys, appetite: 20, leadId: lead, issueIds: issues.map((i) => i.id) }, now);
76 app.bus.append({ kind: "bet.proposed", repoId: repo.id, payload: { bet: bet.id, name } });
77 made.push(bet);
78 }
79 return made;
80 }
81
82 /** The owner backs a proposed or stopped bet with a share of the week. */
83 export function backBet(app: App, user: User, repo: Repo, id: string, share: number, now = new Date()): Answer {
84 if (!owns(app, repo, user)) return refuse("forbidden", "only the owner backs a bet");
85 const bet = load(app, repo, id);
86 if (bet === undefined) return refuse("not_found", "no such bet");
87 if (bet.state === "done" || bet.state === "cut") return refuse("refused", "that bet has ended");
88 const others = backed(app, repo).filter((b) => b.id !== id);
89 const why = mayBack(others, share);
90 if (why !== null) return refuse("refused", why);
91 markBacked(app.db, id, share, "owner", now);
92 app.bus.append({ kind: "bet.backed", repoId: repo.id, actorId: user.id, payload: { bet: id, share, by: "owner" } });
93 return ok(getBet(app.db, id)!);
94 }
95
96 /** One tap stops a bet, whoever started it: it keeps its scopes and can be backed again. */
97 export function stopBet(app: App, user: User, repo: Repo, id: string, now = new Date()): Answer {
98 if (!owns(app, repo, user)) return refuse("forbidden", "only the owner stops a bet");
99 const bet = load(app, repo, id);
100 if (bet === undefined) return refuse("not_found", "no such bet");
101 if (bet.state !== "backed") return refuse("refused", "that bet is not running");
102 markEnded(app.db, id, "stopped", now, bet.leftLine === "" ? "Stopped by the owner." : bet.leftLine);
103 app.bus.append({ kind: "bet.stopped", repoId: repo.id, actorId: user.id, payload: { bet: id, why: "owner" } });
104 return ok(getBet(app.db, id)!);
105 }
106
107 /** Cut (drop what is left) any live bet, or end a stopped one as done. */
108 export function endBet(app: App, user: User, repo: Repo, id: string, how: "cut" | "done", now = new Date()): Answer {
109 if (!owns(app, repo, user)) return refuse("forbidden", "only the owner ends a bet");
110 const bet = load(app, repo, id);
111 if (bet === undefined) return refuse("not_found", "no such bet");
112 if (bet.state === "done" || bet.state === "cut") return ok(bet);
113 // Only a bet that stopped is called done by hand; a running one finishes when its scopes do.
114 if (how === "done" && bet.state !== "stopped") return refuse("refused", "only a stopped bet can be ended as done; a running one finishes when every scope closes");
115 markEnded(app.db, id, how, now);
116 if (how === "done" && bet.tryUrl === "") setTryUrl(app.db, id, `/${repo.ownerName}/${repo.name}`);
117 app.bus.append({ kind: `bet.${how === "cut" ? "cut" : "finished"}`, repoId: repo.id, actorId: user.id, payload: { bet: id } });
118 return ok(getBet(app.db, id)!);
119 }
120
121 /** The lead (or the owner) writes what is left, in one line. */
122 export function writeLeft(app: App, user: User, repo: Repo, id: string, line: string): Answer {
123 const bet = load(app, repo, id);
124 if (bet === undefined) return refuse("not_found", "no such bet");
125 if (user.id !== bet.leadId && !owns(app, repo, user)) return refuse("forbidden", "only the lead writes what is left");
126 const text = oneLine(line);
127 if (text === "") return refuse("refused", "say what is left in one line");
128 setLeftLine(app.db, id, text);
129 app.bus.append({ kind: "bet.left", repoId: repo.id, actorId: user.id, payload: { bet: id } });
130 return ok(getBet(app.db, id)!);
131 }
132
133 /** The owner's word on a finished bet: "feels done", or "not yet" and why (it returns to stopped with that reason). */
134 export function giveVerdict(app: App, user: User, repo: Repo, id: string, verdict: "done" | "not_yet", why: string, now = new Date()): Answer {
135 if (!owns(app, repo, user)) return refuse("forbidden", "only the owner says whether it feels done");
136 const bet = load(app, repo, id);
137 if (bet === undefined) return refuse("not_found", "no such bet");
138 if (bet.state !== "done") return refuse("refused", "that bet has not finished");
139 const reason = oneLine(why);
140 if (verdict === "not_yet" && reason === "") return refuse("refused", "say why it is not yet");
141 setVerdict(app.db, id, verdict, reason);
142 if (verdict === "not_yet") markEnded(app.db, id, "stopped", now, reason);
143 app.bus.append({ kind: "bet.verdict", repoId: repo.id, actorId: user.id, payload: { bet: id, verdict } });
144 return ok(getBet(app.db, id)!);
145 }
146
147 /**
148 * The bets' turn, once per tick: a backed bet whose scopes are all closed is
149 * finished (the owner is asked to try it); one that has spent its appetite
150 * stops with the open scopes as its left line, never extended; and when
151 * nothing is backed a day after the first proposal, the top proposals start
152 * at a modest share, marked as the planner's.
153 */
154 export function tickBets(app: App, repo: Repo, now = new Date()): { finished: number; stopped: number; started: number } {
155 const out = { finished: 0, stopped: 0, started: 0 };
156 for (const bet of backed(app, repo)) {
157 const scopes = betScopes(app.db, bet.id);
158 const open = scopes.filter((s) => !s.closed);
159 if (scopes.length > 0 && open.length === 0) {
160 markEnded(app.db, bet.id, "done", now);
161 setTryUrl(app.db, bet.id, `/${repo.ownerName}/${repo.name}`);
162 app.bus.append({ kind: "bet.finished", repoId: repo.id, payload: { bet: bet.id } });
163 out.finished++;
164 continue;
165 }
166 const spent = spentSeconds(betRuns(app.db, bet.id, bet.backedAt ?? bet.proposedAt), now);
167 if (spentUp(spent, budgetSeconds(bet.appetite, repo.autonomySlots))) {
168 const line = bet.leftLine !== "" ? bet.leftLine : `Left: ${open.slice(0, 5).map((s) => `#${s.number} ${oneLine(s.title, 50)}`).join("; ")}.`;
169 markEnded(app.db, bet.id, "stopped", now, line);
170 app.bus.append({ kind: "bet.stopped", repoId: repo.id, payload: { bet: bet.id, why: "appetite" } });
171 out.stopped++;
172 }
173 }
174 const all = listBets(app.db, repo.id);
175 const running = all.filter((b) => b.state === "backed").length;
176 const proposed = all.filter((b) => b.state === "proposed");
177 for (const id of autoStarts(proposed, all.some((b) => b.backedBy !== "") ? 1 : running, now)) {
178 markBacked(app.db, id, AUTO_SHARE, "planner", now);
179 app.bus.append({ kind: "bet.backed", repoId: repo.id, payload: { bet: id, share: AUTO_SHARE, by: "planner" } });
180 out.started++;
181 }
182 return out;
183 }
src/core/bets.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * Bets (#8713): the thin record over a repository's issues. A bet names a
3 * whole, says how much of the week it may take, what done looks like, and
4 * which issues are its scopes. Plain rows in and out; the rules are in
5 * `crew/bets.ts`, authorization and events in `actions/bets.ts`.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { id as newId } from "../kernel/id.ts";
10 import type { Span } from "../crew/bets.ts";
11
12 export type BetState = "proposed" | "backed" | "stopped" | "done" | "cut";
13
14 export interface Bet {
15 readonly id: string;
16 readonly repoId: string;
17 readonly name: string;
18 readonly journeys: string;
19 readonly appetite: number;
20 readonly leadId: string | null;
21 readonly state: BetState;
22 readonly backedBy: "" | "owner" | "planner";
23 readonly leftLine: string;
24 readonly tryUrl: string;
25 readonly verdict: "" | "done" | "not_yet";
26 readonly verdictWhy: string;
27 readonly proposedAt: string;
28 readonly backedAt: string | null;
29 readonly endedAt: string | null;
30 }
31
32 interface Row {
33 id: string; repo_id: string; name: string; journeys: string; appetite: number; lead_id: string | null;
34 state: BetState; backed_by: "" | "owner" | "planner"; left_line: string; try_url: string;
35 verdict: "" | "done" | "not_yet"; verdict_why: string; proposed_at: string; backed_at: string | null; ended_at: string | null;
36 }
37
38 const toBet = (r: Row): Bet => ({
39 id: r.id, repoId: r.repo_id, name: r.name, journeys: r.journeys, appetite: r.appetite, leadId: r.lead_id,
40 state: r.state, backedBy: r.backed_by, leftLine: r.left_line, tryUrl: r.try_url, verdict: r.verdict,
41 verdictWhy: r.verdict_why, proposedAt: r.proposed_at, backedAt: r.backed_at, endedAt: r.ended_at,
42 });
43
44 export function getBet(db: Database, id: string): Bet | undefined {
45 const r = db.query<Row, [string]>("SELECT * FROM bet WHERE id = ?1").get(id);
46 return r === null ? undefined : toBet(r);
47 }
48
49 /** A repository's bets, the running first, then by when they were proposed. */
50 export function listBets(db: Database, repoId: string): Bet[] {
51 return db
52 .query<Row, [string]>(
53 `SELECT * FROM bet WHERE repo_id = ?1
54 ORDER BY CASE state WHEN 'backed' THEN 0 WHEN 'stopped' THEN 1 WHEN 'proposed' THEN 2 ELSE 3 END, proposed_at, id`,
55 )
56 .all(repoId)
57 .map(toBet);
58 }
59
60 export function insertBet(
61 db: Database,
62 b: { repoId: string; name: string; journeys: string; appetite: number; leadId: string | null; issueIds: readonly string[] },
63 at: Date,
64 ): Bet {
65 const id = newId();
66 db.transaction(() => {
67 db.query(
68 "INSERT INTO bet (id, repo_id, name, journeys, appetite, lead_id, proposed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
69 ).run(id, b.repoId, b.name, b.journeys, b.appetite, b.leadId, at.toISOString());
70 for (const issue of b.issueIds) db.query("INSERT OR IGNORE INTO bet_issue (bet_id, issue_id) VALUES (?1, ?2)").run(id, issue);
71 })();
72 return getBet(db, id)!;
73 }
74
75 /** Back a bet: running, with this share, by the owner or the planner. */
76 export function markBacked(db: Database, id: string, share: number, by: "owner" | "planner", at: Date): void {
77 db.query("UPDATE bet SET state = 'backed', appetite = ?2, backed_by = ?3, backed_at = CASE WHEN state = 'backed' THEN backed_at ELSE ?4 END, left_line = '', ended_at = NULL WHERE id = ?1")
78 .run(id, share, by, at.toISOString());
79 }
80
81 export function markEnded(db: Database, id: string, state: "stopped" | "done" | "cut", at: Date, leftLine?: string): void {
82 db.query("UPDATE bet SET state = ?2, ended_at = ?3, left_line = COALESCE(?4, left_line) WHERE id = ?1")
83 .run(id, state, at.toISOString(), leftLine ?? null);
84 }
85
86 export function setLeftLine(db: Database, id: string, line: string): void {
87 db.query("UPDATE bet SET left_line = ?2 WHERE id = ?1").run(id, line);
88 }
89
90 export function setVerdict(db: Database, id: string, verdict: "done" | "not_yet", why: string): void {
91 db.query("UPDATE bet SET verdict = ?2, verdict_why = ?3 WHERE id = ?1").run(id, verdict, why);
92 }
93
94 export function setTryUrl(db: Database, id: string, url: string): void {
95 db.query("UPDATE bet SET try_url = ?2 WHERE id = ?1").run(id, url);
96 }
97
98 /** Whether the owner has ever backed a bet here: from then on, the picks come from bets. */
99 export function betsInUse(db: Database, repoId: string): boolean {
100 return db.query("SELECT 1 FROM bet WHERE repo_id = ?1 AND backed_by <> '' LIMIT 1").get(repoId) !== null;
101 }
102
103 /** For these issues, the backed bet each is a scope of (one read). */
104 export function backedBetOf(db: Database, repoId: string, issueIds: readonly string[]): Map<string, string> {
105 const out = new Map<string, string>();
106 if (issueIds.length === 0) return out;
107 const rows = db
108 .query<{ issue_id: string; bet_id: string }, string[]>(
109 `SELECT bi.issue_id, bi.bet_id FROM bet_issue bi JOIN bet b ON b.id = bi.bet_id
110 WHERE b.repo_id = ?1 AND b.state = 'backed' AND bi.issue_id IN (${issueIds.map((_, n) => `?${n + 2}`).join(",")})
111 ORDER BY b.backed_at, b.id`,
112 )
113 .all(repoId, ...issueIds);
114 for (const r of rows) if (!out.has(r.issue_id)) out.set(r.issue_id, r.bet_id);
115 return out;
116 }
117
118 export interface Scope {
119 readonly issueId: string;
120 readonly number: number;
121 readonly title: string;
122 readonly closed: boolean;
123 readonly ran: boolean;
124 readonly movedAt: string;
125 }
126
127 /** A bet's scopes: each issue with whether it closed, whether a run ever worked it, and when it last moved. */
128 export function betScopes(db: Database, betId: string): Scope[] {
129 return db
130 .query<{ id: string; number: number; title: string; state: string; updated_at: string; last_run: string | null }, [string]>(
131 `SELECT i.id, i.number, i.title, i.state, i.updated_at,
132 (SELECT MAX(created_at) FROM run r WHERE r.issue_id = i.id AND r.kind = 'agent') AS last_run
133 FROM bet_issue bi JOIN issue i ON i.id = bi.issue_id
134 WHERE bi.bet_id = ?1 ORDER BY i.number`,
135 )
136 .all(betId)
137 .map((r) => ({
138 issueId: r.id, number: r.number, title: r.title, closed: r.state !== "open", ran: r.last_run !== null,
139 movedAt: r.last_run !== null && r.last_run > r.updated_at ? r.last_run : r.updated_at,
140 }));
141 }
142
143 /** The builder runs on a bet's scopes since it was backed. */
144 export function betRuns(db: Database, betId: string, since: string): Span[] {
145 return db
146 .query<{ started_at: string | null; finished_at: string | null }, [string, string]>(
147 `SELECT r.started_at, r.finished_at FROM run r
148 WHERE r.kind = 'agent' AND r.started_at IS NOT NULL AND r.started_at >= ?2
149 AND r.issue_id IN (SELECT issue_id FROM bet_issue WHERE bet_id = ?1)`,
150 )
151 .all(betId, since)
152 .map((r) => ({ startedAt: r.started_at, finishedAt: r.finished_at }));
153 }
154
155 /** The bet name for pulls (by number) in `repoId`: through the run that built each from a scope, or the pull as a scope itself. */
156 export function betNamesOfPulls(db: Database, repoId: string, numbers: readonly number[]): Map<number, string> {
157 const out = new Map<number, string>();
158 if (numbers.length === 0) return out;
159 const rows = db
160 .query<{ number: number; name: string }, (string | number)[]>(
161 `SELECT p.number, b.name FROM issue p
162 JOIN pull pl ON pl.issue_id = p.id
163 JOIN bet b ON b.repo_id = p.repo_id
164 WHERE p.repo_id = ?1 AND p.number IN (${numbers.map((_, n) => `?${n + 2}`).join(",")})
165 AND (EXISTS (SELECT 1 FROM bet_issue bi WHERE bi.bet_id = b.id AND bi.issue_id = p.id)
166 OR EXISTS (SELECT 1 FROM run r JOIN bet_issue bi ON bi.issue_id = r.issue_id
167 WHERE bi.bet_id = b.id AND r.repo_id = p.repo_id AND r.branch = pl.head_branch))
168 ORDER BY b.proposed_at, b.id`,
169 )
170 .all(repoId, ...numbers);
171 for (const r of rows) if (!out.has(r.number)) out.set(r.number, r.name);
172 return out;
173 }
src/core/event_audience.ts +2
@@ -178,6 +178,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
178178 kind === "forge.upstream" ||
179179 kind === "forge.updates_taken" ||
180180 kind === "cloud.write_allowed" ||
181 // A bet's name, share and what is left are the owner's plan (#8713).
182 kind.startsWith("bet.") ||
181183 // A pinned forge's deploy, update and rollback name the owner's cloud (#8697).
182184 kind.startsWith("pinned.") ||
183185 // Which of a person's connected accounts a mirror syncs through is too.
src/core/migration-pins/20261103100000-bets.pin added +1
@@ -0,0 +1,1 @@
1 713406eab4426e7c65dcc76ce9eb635a9cb1ef81e7bd85a086ef6bc55de91791
src/core/migrations/20261103100000-bets.ts added +30
@@ -0,0 +1,30 @@
1 // Bets (#8713): a name, an appetite (a share of the week), what done looks
2 // like in a few journeys, and scopes that are existing issues. One crew member
3 // leads. Additive: a repository with no bet picks as it always did.
4 export default `
5 CREATE TABLE bet (
6 id TEXT PRIMARY KEY,
7 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
8 name TEXT NOT NULL,
9 journeys TEXT NOT NULL DEFAULT '',
10 appetite INTEGER NOT NULL CHECK (appetite BETWEEN 1 AND 100),
11 lead_id TEXT REFERENCES user(id) ON DELETE SET NULL,
12 state TEXT NOT NULL DEFAULT 'proposed'
13 CHECK (state IN ('proposed','backed','stopped','done','cut')),
14 backed_by TEXT NOT NULL DEFAULT '' CHECK (backed_by IN ('','owner','planner')),
15 left_line TEXT NOT NULL DEFAULT '',
16 try_url TEXT NOT NULL DEFAULT '',
17 verdict TEXT NOT NULL DEFAULT '' CHECK (verdict IN ('','done','not_yet')),
18 verdict_why TEXT NOT NULL DEFAULT '',
19 proposed_at TEXT NOT NULL,
20 backed_at TEXT,
21 ended_at TEXT
22 );
23 CREATE INDEX bet_repo_state ON bet (repo_id, state);
24 CREATE TABLE bet_issue (
25 bet_id TEXT NOT NULL REFERENCES bet(id) ON DELETE CASCADE,
26 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
27 PRIMARY KEY (bet_id, issue_id)
28 );
29 CREATE INDEX bet_issue_issue ON bet_issue (issue_id);
30 `;
src/crew/autonomy.ts +17 −1
@@ -12,6 +12,7 @@
1212 * repository's origin.
1313 */
1414
15 import { tickBets } from "../actions/bets.ts";
1516 import { onlyListDocs, resolveListConflicts } from "../actions/list-conflicts.ts";
1617 import { closedToday, closeStale } from "./stale_pulls.ts";
1718 import type { Database } from "bun:sqlite";
@@ -31,6 +32,8 @@ import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, typ
3132 import { checksFor } from "../checks/service.ts";
3233 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
3334 import { roadmapIssues, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
35 import { backedBetOf, betsInUse } from "../core/bets.ts";
36 import { mayPick } from "./bets.ts";
3437 import { raisedAt } from "../core/thought_links.ts";
3538 import { readConflictLane, readConflictRoom } from "./conflict_room.ts";
3639 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
@@ -410,8 +413,20 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re
410413 const raised = raisedAt(app.db, issues.map((i) => i.id));
411414 // Issues picked earlier in this tick hold their share before their runs show (#8729).
412415 const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]);
416 // Once the owner has backed a bet, builders pick from backed bets, the owner's raised issues and the small lane (#8713).
417 const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined;
418 const allowed =
419 bets === undefined
420 ? ready
421 : ready.filter((r) =>
422 mayPick({
423 raised: raised.has(r.issue.id) || first.has(r.issue.id),
424 small: small.has(r.issue.id),
425 bet: bets.get(r.issue.id),
426 }),
427 );
413428 return slotOrder(
414 ready,
429 allowed,
415430 (r) => ({
416431 ranked: ranked.has(r.issue.id),
417432 raised: raised.has(r.issue.id) || first.has(r.issue.id),
@@ -470,6 +485,7 @@ async function driveOnce(
470485 taken: Set<string>,
471486 ): Promise<Drove> {
472487 if (repo.autonomy === "off") return "off";
488 tickBets(app, repo, now);
473489 const status = autonomyStatus(app, repo, now);
474490 if (status.paused !== null) return "paused";
475491 const by = getUserById(app.db, repo.autonomyBy!);
src/crew/bets.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * The rules of a bet (#8713), as plain data in and out. A bet has an
3 * appetite — a share of the week's builder time — and a fixed budget:
4 * when the time spent reaches it the bet stops, with no extension. Builders
5 * pick only from backed bets (plus the small-fixes lane); a scope that has
6 * not moved for two days is a raised hand. The same state always gives the
7 * same picks.
8 */
9
10 /** Bets that run at once. */
11 export const MAX_BACKED = 5;
12 /** The share a planner-started bet gets, until the owner says otherwise. */
13 export const AUTO_SHARE = 10;
14 /** How long nothing backed lets the planner start its top bets. */
15 export const AUTO_AFTER_MS = 24 * 3600_000;
16 /** How many bets the planner may start by itself. */
17 export const AUTO_COUNT = 2;
18 /** A scope untouched this long is a raised hand. */
19 export const STILL_MS = 2 * 24 * 3600_000;
20 export const WEEK_S = 7 * 24 * 3600;
21
22 export interface Span {
23 readonly startedAt: string | null;
24 readonly finishedAt: string | null;
25 }
26
27 /** Seconds of builder time the runs took, an unfinished run counted up to `now`. */
28 export function spentSeconds(runs: readonly Span[], now: Date): number {
29 let total = 0;
30 for (const r of runs) {
31 if (r.startedAt === null) continue;
32 const from = Date.parse(r.startedAt);
33 const to = r.finishedAt === null ? now.getTime() : Date.parse(r.finishedAt);
34 if (Number.isFinite(from) && Number.isFinite(to) && to > from) total += (to - from) / 1000;
35 }
36 return Math.round(total);
37 }
38
39 /** The builder seconds a share of the week buys with `slots` builders. */
40 export function budgetSeconds(appetite: number, slots: number): number {
41 return Math.round((Math.max(0, Math.min(100, appetite)) / 100) * Math.max(0, slots) * WEEK_S);
42 }
43
44 /** Whether the bet has used its appetite. */
45 export function spentUp(spent: number, budget: number): boolean {
46 return spent >= budget;
47 }
48
49 /** Whether backing `share` fits: fewer than 5 running and the shares within the whole week. */
50 export function mayBack(backed: readonly { readonly appetite: number }[], share: number): string | null {
51 if (!Number.isInteger(share) || share < 1 || share > 100) return "a share of the week is a whole number from 1 to 100";
52 if (backed.length >= MAX_BACKED) return `${MAX_BACKED} bets already run; end or cut one first`;
53 if (backed.reduce((n, b) => n + b.appetite, 0) + share > 100) return "the shares would pass the whole week";
54 return null;
55 }
56
57 export interface Proposed {
58 readonly id: string;
59 readonly proposedAt: string;
60 }
61
62 /**
63 * The proposed bets the planner starts by itself: when nothing is backed and
64 * the oldest proposal is a day old, the top few (oldest first, then id), each
65 * for a modest share. Empty while anything runs, so one tap by the owner
66 * always wins.
67 */
68 export function autoStarts(proposed: readonly Proposed[], backedCount: number, now: Date): string[] {
69 if (backedCount > 0 || proposed.length === 0) return [];
70 const sorted = [...proposed].sort((a, b) => a.proposedAt.localeCompare(b.proposedAt) || a.id.localeCompare(b.id));
71 if (now.getTime() - Date.parse(sorted[0]!.proposedAt) < AUTO_AFTER_MS) return [];
72 return sorted.slice(0, AUTO_COUNT).map((p) => p.id);
73 }
74
75 export type ScopeState = "figuring out" | "executing" | "raised hand" | "done";
76
77 /** What a scope reads as: finished, quiet for too long, building, or still being worked out. */
78 export function scopeState(s: { closed: boolean; ran: boolean; movedAt: string }, now: Date): ScopeState {
79 if (s.closed) return "done";
80 if (now.getTime() - Date.parse(s.movedAt) >= STILL_MS) return "raised hand";
81 return s.ran ? "executing" : "figuring out";
82 }
83
84 export interface Pickable {
85 readonly raised: boolean;
86 readonly small: boolean;
87 /** The backed, running bet this issue is a scope of, or undefined. */
88 readonly bet: string | undefined;
89 }
90
91 /**
92 * Whether builders may pick an issue: one the owner raised or asked for; one
93 * in a backed bet; a small fix (the small lane's cap holds it elsewhere). An
94 * issue in no bet does not start by itself.
95 */
96 export function mayPick(p: Pickable): boolean {
97 return p.raised || p.small || p.bet !== undefined;
98 }
src/http/mcp/tools/bet.ts added +48
@@ -0,0 +1,48 @@
1 import { backBet, endBet, giveVerdict, proposeBets, stopBet, writeLeft } from "../../../actions/bets.ts";
2 import { canAdmin } from "../../../core/repos.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "bet",
8 doc: "`{ app, verb, bet?, share?, line?, verdict?, why? }`: `propose` has the planner propose bets from open work; the owner `back`s one with a `share` of the week (at most 5 run), `stop`s, `cut`s or `end`s it, and gives a `verdict` (done, or not_yet with `why`) on a finished one; the lead writes what is `left` in one `line`. Needs a write token",
9 description: "Propose, back, stop, cut or end a bet; write what is left; say whether it feels done.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 app: APP_ARG,
14 verb: { type: "string", enum: ["propose", "back", "stop", "cut", "end", "left", "verdict"] },
15 bet: { type: "string", maxLength: 64 },
16 share: { type: "integer", minimum: 1, maximum: 100 },
17 line: { type: "string", maxLength: 240 },
18 verdict: { type: "string", enum: ["done", "not_yet"] },
19 why: { type: "string", maxLength: 240 },
20 },
21 required: ["app", "verb"],
22 },
23 writes: true,
24 async run(ctx, user, args) {
25 const repo = planRepo(ctx, user, args);
26 if (isAnswer(repo)) return repo;
27 if (!scopeAllows(ctx.auth.scope ?? "ro", "rw")) return bad("forbidden", "this needs a write token");
28 const verb = str(args["verb"]);
29 const id = str(args["bet"]);
30 if (verb === "propose") {
31 if (user.kind === "agent" || !canAdmin(ctx.app.db, repo, user)) return bad("forbidden", "only the owner asks for proposals");
32 const made = proposeBets(ctx.app, repo);
33 return good({ says: `${made.length} proposed.`, items: made.map((b) => ({ id: b.id, name: b.name })) });
34 }
35 const verdict = str(args["verdict"]);
36 const done =
37 verb === "back" ? backBet(ctx.app, user, repo, id, Number(args["share"]))
38 : verb === "stop" ? stopBet(ctx.app, user, repo, id)
39 : verb === "cut" ? endBet(ctx.app, user, repo, id, "cut")
40 : verb === "end" ? endBet(ctx.app, user, repo, id, "done")
41 : verb === "left" ? writeLeft(ctx.app, user, repo, id, str(args["line"]))
42 : verb === "verdict" && (verdict === "done" || verdict === "not_yet") ? giveVerdict(ctx.app, user, repo, id, verdict, str(args["why"]))
43 : undefined;
44 if (done === undefined) return bad("bad_verb", "verb is propose, back, stop, cut, end, left or verdict");
45 if (!done.ok) return bad(done.error.error, done.error.message);
46 return good({ says: `${done.value.name}: ${done.value.state}.`, state: done.value.state });
47 },
48 };
src/http/mcp/tools/bets.ts added +29
@@ -0,0 +1,29 @@
1 import { listBets, betScopes } from "../../../core/bets.ts";
2 import { canAdmin } from "../../../core/repos.ts";
3 import { scopeState } from "../../../crew/bets.ts";
4 import { good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "bets",
8 doc: "`{ app }`: the app's bets — each a name, a share of the week, what done looks like, its state (proposed, running, stopped, finished, cut), who backed it, what is left, and its scopes as figuring out, executing, raised hand or done",
9 description: "A repository's bets and their scopes.",
10 inputSchema: { type: "object", properties: { app: APP_ARG }, required: ["app"] },
11 writes: false,
12 async run(ctx, user, args) {
13 const repo = planRepo(ctx, user, args);
14 if (isAnswer(repo)) return repo;
15 const now = new Date();
16 // The plan is for those who administer; a reader gets names, states and scopes.
17 const plan = user.kind !== "agent" && canAdmin(ctx.app.db, repo, user);
18 return good({
19 items: listBets(ctx.app.db, repo.id).map((b) => !plan ? ({
20 id: b.id, name: b.name, state: b.state,
21 scopes: betScopes(ctx.app.db, b.id).map((s) => ({ issue: s.number, title: s.title, state: scopeState(s, now) })),
22 }) : ({
23 id: b.id, name: b.name, share: b.appetite, state: b.state, backedBy: b.backedBy, journeys: b.journeys.split("\n").filter((j) => j !== ""),
24 left: b.leftLine, verdict: b.verdict,
25 scopes: betScopes(ctx.app.db, b.id).map((s) => ({ issue: s.number, title: s.title, state: scopeState(s, now) })),
26 })),
27 });
28 },
29 };
src/http/mcp/tools/index.ts +2
@@ -27,6 +27,8 @@ export { tool as audit } from "./audit.ts";
2727 export { tool as backups } from "./backups.ts";
2828 export { tool as backups_destination } from "./backups_destination.ts";
2929 export { tool as backups_test } from "./backups_test.ts";
30 export { tool as bet } from "./bet.ts";
31 export { tool as bets } from "./bets.ts";
3032 export { tool as board_delete } from "./board_delete.ts";
3133 export { tool as board_pins } from "./board_pins.ts";
3234 export { tool as board_write } from "./board_write.ts";
src/http/server.ts +5
@@ -138,6 +138,7 @@ import { reconnectSubmit } from "../web/pages/frozen.ts";
138138 import { activityAtom, changelogAtom } from "../web/feeds.ts";
139139 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
140140 import { apiPage, appApiPage } from "../web/pages/api.ts";
141 import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts";
141142 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
142143 import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
143144 import {
@@ -1033,6 +1034,10 @@ export function buildRouter(): Router<Handler> {
10331034 router.add("GET", "/:owner/:name/next", nextPage);
10341035 router.add("POST", "/:owner/:name/next", nextSubmit);
10351036 router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit);
1037 router.add("GET", "/:owner/:name/bets", betsPage);
1038 router.add("POST", "/:owner/:name/bets/propose", betPropose);
1039 router.add("GET", "/:owner/:name/bets/:id", betPage);
1040 router.add("POST", "/:owner/:name/bets/:id/:verb", betAct);
10361041 router.add("GET", "/:owner/:name/milestones", milestonesPage);
10371042 router.add("GET", "/:owner/:name/milestones/:number", milestonePage);
10381043 router.add("POST", "/:owner/:name/milestones/:number/watch", milestoneWatch);
src/release/landings.ts +2
@@ -26,6 +26,8 @@ export interface Landing {
2626 /** When the pull merged. */
2727 readonly at: string;
2828 readonly live: boolean;
29 /** The bet whose scopes this pull built (#8713), set by the page that shows it. */
30 readonly bet?: string;
2931 }
3032
3133 export interface Landings {
src/tui/screens/bets.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * Bets in the terminal (#8713): every bet of the repositories the signed-in
3 * person administers, one row each, with the same actions as the page and the
4 * MCP: `p` asks the planner for proposals, `b` backs with a share, `s` stops,
5 * `c` cuts, `e` ends a stopped bet as done, `l` writes what is left, `d` says
6 * it feels done and `n` not yet. All of it goes through `actions/bets.ts`, so
7 * who may act, the rules and the events web pages follow are the same; an
8 * agent, a guest or a reader without admin sees nothing and does nothing.
9 */
10
11 import { backBet, endBet, giveVerdict, proposeBets, stopBet, writeLeft, type BetError } from "../../actions/bets.ts";
12 import { type Bet, listBets } from "../../core/bets.ts";
13 import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts";
14 import type { Result } from "../../kernel/result.ts";
15 import type { Ctx, Screen } from "../app/app.ts";
16 import { binding } from "../app/keys.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { fill, len, split } from "../ui/layout.ts";
21 import { ListState, confirmScreen, drawList, live, promptScreen, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23
24 interface Line {
25 readonly repo: Repo;
26 readonly bet: Bet;
27 }
28
29 /** The repositories this session's person administers; none for a guest or an agent. */
30 function administered(f: Session): Repo[] {
31 const user = f.user;
32 if (user === undefined || user.kind === "agent") return [];
33 return f.app.db
34 .query<{ id: string }, [string]>("SELECT id FROM repo WHERE owner_id = ?1 ORDER BY name")
35 .all(user.id)
36 .flatMap((r) => {
37 const repo = getRepoById(f.app.db, r.id);
38 return repo !== undefined && canAdmin(f.app.db, repo, user) ? [repo] : [];
39 });
40 }
41
42 export function betsScreen(f: Session, ctx: Ctx): Screen {
43 const state = new ListState();
44 let note = "";
45 const lines = (): Line[] => administered(f).flatMap((repo) => listBets(f.app.db, repo.id).map((bet) => ({ repo, bet })));
46 const say = (text: string): void => {
47 note = text;
48 ctx.invalidate();
49 };
50 const settle = (r: Result<Bet, { error: BetError; message: string }>, what: string): string | undefined => {
51 say(r.ok ? `${r.value.name}: ${what}.` : r.error.message);
52 return r.ok ? undefined : r.error.message;
53 };
54
55 const screen: Screen = {
56 title: "Bets",
57 keys: [
58 binding(["j", "k"], "move cursor"), binding(["p"], "propose"), binding(["b"], "back"), binding(["s"], "stop"),
59 binding(["c"], "cut"), binding(["e"], "end"), binding(["l"], "what is left"), binding(["d"], "feels done"), binding(["n"], "not yet"),
60 ],
61 async load(signal) {
62 live(f, signal, { where: (e) => e.kind.startsWith("bet.") }, () => ctx.invalidate());
63 },
64 render(area, s) {
65 const c = ctx.theme.c;
66 const all = lines();
67 const [head, list] = split(area, "v", [len(1), fill(1)]);
68 const sel = all[state.index];
69 const text =
70 note !== ""
71 ? note
72 : administered(f).length === 0
73 ? "Bets are for the person who administers a repository."
74 : sel
75 ? sel.bet.leftLine || sel.bet.journeys.split("\n")[0] || sel.bet.name
76 : "No bets. Press p to have the planner propose some.";
77 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
78 const rows: ListRow[] = all.map(({ repo, bet }) => ({
79 title: bet.name,
80 ...(bet.state === "stopped" ? { tone: "danger" as const } : {}),
81 secondary: `${repo.name} · ${bet.state} · ${bet.appetite}% of the week${bet.backedBy === "planner" ? " · not backed by the owner" : ""}`,
82 meta: bet.state === "done" && bet.verdict === "" ? "try it, then d or n" : "",
83 }));
84 drawList(s, list!, c, state, rows);
85 },
86 key(e) {
87 if (state.key(e, lines().length)) return true;
88 if (e.ctrl || e.alt || f.user === undefined) return false;
89 const all = lines();
90 if (e.key === "p") {
91 const repo = all[state.index]?.repo ?? administered(f)[0];
92 if (repo === undefined) return false;
93 const made = proposeBets(f.app, repo);
94 say(made.length === 0 ? "Nothing new to propose." : `${made.length} proposed.`);
95 return true;
96 }
97 const at = all[state.index];
98 if (at === undefined) return false;
99 const { repo, bet } = at;
100 const user = f.user;
101 if (e.key === "b")
102 ctx.push(
103 promptScreen(ctx, screen, {
104 title: `Back ${bet.name}`,
105 label: "a share of the week, 1 to 100 (%)",
106 value: String(bet.appetite),
107 onSubmit: (raw) => settle(backBet(f.app, user, repo, bet.id, Number(raw.trim())), "backed"),
108 }),
109 );
110 else if (e.key === "s") settle(stopBet(f.app, user, repo, bet.id), "stopped");
111 else if (e.key === "e") settle(endBet(f.app, user, repo, bet.id, "done"), "ended as done");
112 else if (e.key === "c")
113 ctx.push(
114 confirmScreen(ctx, screen, {
115 title: `Cut ${bet.name}?`,
116 body: ["Its scopes stay open issues; builders stop picking them for this bet."],
117 actions: [{ label: "Cut it", variant: "danger" }, { label: "Keep" }],
118 onChoose: (i) => {
119 if (i === 0) settle(endBet(f.app, user, repo, bet.id, "cut"), "cut");
120 },
121 }),
122 );
123 else if (e.key === "l")
124 ctx.push(
125 promptScreen(ctx, screen, {
126 title: `What is left of ${bet.name}`,
127 label: "in one line",
128 value: bet.leftLine,
129 onSubmit: (raw) => settle(writeLeft(f.app, user, repo, bet.id, raw), "left written"),
130 }),
131 );
132 else if (e.key === "d") settle(giveVerdict(f.app, user, repo, bet.id, "done", ""), "feels done");
133 else if (e.key === "n")
134 ctx.push(
135 promptScreen(ctx, screen, {
136 title: `${bet.name}: not yet`,
137 label: "because",
138 onSubmit: (raw) => settle(giveVerdict(f.app, user, repo, bet.id, "not_yet", raw), "not yet"),
139 }),
140 );
141 else return false;
142 return true;
143 },
144 };
145 return screen;
146 }
147
148 /** Bets, as the page and the MCP have them: whoever administers a repository. */
149 export const entry: PaletteEntry = {
150 id: "bets",
151 title: "Bets",
152 hint: "back, stop and finish a repository's bets",
153 offered: (session) => session.user !== undefined && session.user.kind !== "agent",
154 run: (session, ctx) => ctx.push(betsScreen(session, ctx)),
155 };
src/tui/screens/entries.ts +2
@@ -12,6 +12,7 @@ import { entry as access } from "./access.ts";
1212 import { entry as agents } from "./agents.ts";
1313 import { entry as archived } from "./archived.ts";
1414 import { entry as audit } from "./audit.ts";
15 import { entry as bets } from "./bets.ts";
1516 import { entry as boards } from "./boards.ts";
1617 import { entry as bulk } from "./bulk.ts";
1718 import { entry as cloud } from "./cloud.ts";
@@ -86,6 +87,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8687 subscriptions,
8788 cloud,
8889 pinned,
90 bets,
8991 connections,
9092 domains,
9193 feed,
src/web/pages/bets.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * Bets (#8713): `/:owner/:name/bets` lists the repository's bets, each in
3 * one row with its appetite and what to do about it; `/bets/:id` is one bet
4 * on one screen — journeys, scopes (figuring out, executing or a raised
5 * hand), what is left, and for a finished bet the "try it" moment. Every
6 * change is a same-origin POST that lands back on the page with a flash.
7 * Server-rendered, no script.
8 */
9
10 import {
11 backBet, endBet, giveVerdict, oneLine, proposeBets, stopBet, writeLeft, type BetError,
12 } from "../../actions/bets.ts";
13 import { type Bet, betRuns, betScopes, getBet, listBets } from "../../core/bets.ts";
14 import { getUserById } from "../../core/users.ts";
15 import { canAdmin, type Repo } from "../../core/repos.ts";
16 import { budgetSeconds, MAX_BACKED, scopeState, spentSeconds } from "../../crew/bets.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
19 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
20 import { field, readForm } from "../form.ts";
21 import { html, type Html } from "../html.ts";
22 import { flashTo, page } from "../layout.ts";
23 import { liveClientJs, liveMount } from "../live.ts";
24 import { badge, btn, card, field as fieldRow, listCard, row } from "../ui.ts";
25
26 const betHref = (repo: Repo, id: string) => `${repoBase(repo)}/bets/${id}`;
27
28 const STATE_WORDS: Record<Bet["state"], string> = {
29 proposed: "proposed", backed: "running", stopped: "stopped", done: "finished", cut: "cut",
30 };
31
32 function whoBacked(b: Bet): string {
33 return b.backedBy === "planner" ? "not backed by the owner" : b.backedBy === "owner" ? "backed by the owner" : "";
34 }
35
36 function days(seconds: number): string {
37 const d = seconds / 86400;
38 return `${d < 10 ? Math.round(d * 10) / 10 : Math.round(d)}d`;
39 }
40
41 /** The share of the week used, as words: `1.2d of 2.8d`. */
42 function useWords(ctx: Ctx, repo: Repo, b: Bet, now: Date): string {
43 const spent = b.backedAt === null ? 0 : spentSeconds(betRuns(ctx.app.db, b.id, b.backedAt), now);
44 return `${days(spent)} of ${days(budgetSeconds(b.appetite, repo.autonomySlots))} builder time`;
45 }
46
47 function betRow(ctx: Ctx, repo: Repo, b: Bet, now: Date, owner: boolean): Html {
48 // The plan — shares, what is left — is for those who administer; a reader sees names and states.
49 if (!owner) return row({ href: betHref(repo, b.id), title: html`${b.name}`, trail: badge(STATE_WORDS[b.state]) });
50 const meta = [b.state === "backed" ? useWords(ctx, repo, b, now) : "", whoBacked(b), b.state === "stopped" ? b.leftLine : ""]
51 .filter((x) => x !== "")
52 .join(" · ");
53 return row({
54 href: betHref(repo, b.id),
55 title: html`${b.name}`,
56 meta: html`${meta}`,
57 trail: badge(`${STATE_WORDS[b.state]} · ${b.appetite}% of the week`, { tone: b.state === "backed" ? "success" : b.state === "stopped" ? "attn" : "default" }),
58 });
59 }
60
61 const post = (repo: Repo, b: Bet, verb: string) => `${betHref(repo, b.id)}/${verb}`;
62
63 function backForm(repo: Repo, b: Bet, label: string): Html {
64 return html`<form method="post" action="${post(repo, b, "back")}" class="bar">
65 ${fieldRow({ name: "share", label: "Share of the week (%)", type: "number", min: 1, max: 100, value: String(b.appetite), required: true, id: `share-${b.id}` })}
66 ${btn(label, { variant: "primary" })}
67 </form>`;
68 }
69
70 function oneButton(repo: Repo, b: Bet, verb: string, label: string, variant: "danger" | "secondary" = "secondary"): Html {
71 return html`<form method="post" action="${post(repo, b, verb)}">${btn(label, { variant })}</form>`;
72 }
73
74 /** What the owner can do with a bet, by where it is. */
75 function ownerActions(repo: Repo, b: Bet): Html | null {
76 if (b.state === "proposed") return html`${backForm(repo, b, "Back it")}${oneButton(repo, b, "cut", "Cut it", "danger")}`;
77 if (b.state === "backed")
78 return html`${backForm(repo, b, "Change the share")}${oneButton(repo, b, "stop", "Stop it")}`;
79 if (b.state === "stopped")
80 return html`${backForm(repo, b, "Back it again")}${oneButton(repo, b, "cut", "Cut it", "danger")}${oneButton(repo, b, "end", "End it: it's done")}`;
81 return null;
82 }
83
84 // GET /:owner/:name/bets
85 export function betsPage(ctx: Ctx): Response {
86 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
87 const repo = loadRepo(ctx);
88 if (!repo) return notFoundPage(ctx);
89 const rendering = performance.now();
90 const now = new Date();
91 const bets = listBets(ctx.app.db, repo.id);
92 const owner = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canAdmin(ctx.app.db, repo, ctx.auth.user);
93 const running = bets.filter((b) => b.state === "backed").length;
94 const body = html`${repoHeader(ctx, repo, "issues")}
95 ${pageHead("Bets", { sub: `A few named wholes, each with a share of the week. ${running} of ${MAX_BACKED} running. Builders pick only from running bets, the owner's own asks and small fixes.` })}
96 ${bets.length === 0
97 ? html`<p class="muted">No bets yet. The planner proposes them from the open work.</p>`
98 : listCard(bets.map((b) => betRow(ctx, repo, b, now, owner)))}
99 ${owner
100 ? html`<form method="post" action="${repoBase(repo)}/bets/propose" class="mt">${btn("Ask the planner to propose bets", { variant: "secondary" })}</form>`
101 : null}
102 ${liveMount({ repo: repoBase(repo) })}`;
103 const response = page({ ctx, scriptJs: liveClientJs, title: `Bets · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
104 notePhase(ctx.request, "render", performance.now() - rendering);
105 return response;
106 }
107
108 // GET /:owner/:name/bets/:id
109 export function betPage(ctx: Ctx): Response {
110 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
111 const repo = loadRepo(ctx);
112 const bet = repo === undefined ? undefined : getBet(ctx.app.db, ctx.params.id ?? "");
113 if (!repo || bet === undefined || bet.repoId !== repo.id) return notFoundPage(ctx);
114 const rendering = performance.now();
115 const now = new Date();
116 const user = ctx.auth.user;
117 const owner = user !== undefined && user.kind !== "agent" && canAdmin(ctx.app.db, repo, user);
118 const lead = bet.leadId === null ? undefined : getUserById(ctx.app.db, bet.leadId);
119 const scopes = betScopes(ctx.app.db, bet.id);
120 const scopeRows = scopes.map((s) => {
121 const state = scopeState(s, now);
122 return row({
123 href: `${repoBase(repo)}/issues/${s.number}`,
124 title: html`#${s.number} ${s.title}`,
125 trail: badge(state, { tone: state === "raised hand" ? "attn" : state === "done" ? "success" : "default" }),
126 });
127 });
128 const finished = bet.state === "done";
129 const moment = finished
130 ? card(
131 html`<h2>This is done. Try it.</h2>
132 <p><a href="${bet.tryUrl === "" ? repoBase(repo) : bet.tryUrl}">Open it and try it.</a></p>
133 ${bet.verdict !== ""
134 ? html`<p>${bet.verdict === "done" ? "The owner says it feels done." : owner ? `The owner says not yet: ${bet.verdictWhy}` : "The owner says not yet."}</p>`
135 : owner
136 ? html`<div class="bar">
137 <form method="post" action="${post(repo, bet, "verdict")}">
138 ${btn("Feels done", { variant: "primary", name: "verdict", value: "done" })}
139 </form>
140 <form method="post" action="${post(repo, bet, "verdict")}" class="bar">
141 ${fieldRow({ name: "why", label: "Not yet, because", maxlength: 240, required: true, id: "why" })}
142 ${btn("Not yet", { name: "verdict", value: "not_yet" })}
143 </form>
144 </div>`
145 : null}`,
146 )
147 : null;
148 const leftBox =
149 owner && (bet.state === "stopped" || bet.state === "backed")
150 ? html`<h2 class="mt">What is left</h2>
151 <p>${bet.leftLine === "" ? "Not written yet." : bet.leftLine}</p>
152 ${owner || (user !== undefined && user.id === bet.leadId)
153 ? html`<form method="post" action="${post(repo, bet, "left")}" class="bar">
154 ${fieldRow({ name: "line", label: "In one line", maxlength: 240, required: true, id: "line" })}${btn("Save")}
155 </form>`
156 : null}`
157 : null;
158 const body = html`${repoHeader(ctx, repo, "issues")}
159 ${pageHead(bet.name, { sub: owner ? `${STATE_WORDS[bet.state]} · ${bet.appetite}% of the week${bet.state === "backed" ? ` · ${useWords(ctx, repo, bet, now)}` : ""}${whoBacked(bet) === "" ? "" : ` · ${whoBacked(bet)}`}${lead === undefined ? "" : ` · lead: ${lead.username}`}` : STATE_WORDS[bet.state] })}
160 ${moment}
161 ${owner ? html`<h2 class="mt">Done looks like</h2>
162 <ul>${bet.journeys.split("\n").filter((j) => j !== "").map((j) => html`<li>${j}</li>`)}</ul>` : null}
163 <h2 class="mt">Scopes</h2>
164 ${scopes.length === 0 ? html`<p class="muted">No scopes yet.</p>` : listCard(scopeRows)}
165 ${leftBox}
166 ${owner ? html`<div class="mt">${ownerActions(repo, bet)}</div>` : null}
167 ${liveMount({ repo: repoBase(repo) })}`;
168 const response = page({ ctx, scriptJs: liveClientJs, title: `${bet.name} · bets · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
169 notePhase(ctx.request, "render", performance.now() - rendering);
170 return response;
171 }
172
173 const WHY: Record<BetError, string> = { forbidden: "Only the owner can do that.", not_found: "No such bet.", refused: "" };
174
175 // POST /:owner/:name/bets/propose
176 export async function betPropose(ctx: Ctx): Promise<Response> {
177 const repo = loadRepo(ctx);
178 if (!repo) return notFoundPage(ctx);
179 const form = await readForm(ctx, "rw");
180 if (form instanceof Response) return form;
181 const list = `${repoBase(repo)}/bets`;
182 const user = ctx.auth.user;
183 if (user === undefined || user.kind === "agent" || !canAdmin(ctx.app.db, repo, user))
184 return flashTo(ctx, list, { tone: "danger", message: WHY.forbidden });
185 const made = proposeBets(ctx.app, repo);
186 return flashTo(ctx, list, { message: made.length === 0 ? "Nothing new to propose." : `${made.length} proposed.` });
187 }
188
189 // POST /:owner/:name/bets/:id/:verb
190 export async function betAct(ctx: Ctx): Promise<Response> {
191 const repo = loadRepo(ctx);
192 if (!repo) return notFoundPage(ctx);
193 const user = ctx.auth.user;
194 const form = await readForm(ctx, "rw");
195 if (form instanceof Response) return form;
196 const list = `${repoBase(repo)}/bets`;
197 if (user === undefined) return flashTo(ctx, list, { tone: "danger", message: "Sign in first." });
198 const id = ctx.params.id ?? "";
199 const verb = ctx.params.verb ?? "";
200 const share = Number(field(form, "share"));
201 const verdict = field(form, "verdict");
202 const result =
203 verb === "back" ? backBet(ctx.app, user, repo, id, share)
204 : verb === "stop" ? stopBet(ctx.app, user, repo, id)
205 : verb === "cut" ? endBet(ctx.app, user, repo, id, "cut")
206 : verb === "end" ? endBet(ctx.app, user, repo, id, "done")
207 : verb === "left" ? writeLeft(ctx.app, user, repo, id, field(form, "line"))
208 : verb === "verdict" && (verdict === "done" || verdict === "not_yet") ? giveVerdict(ctx.app, user, repo, id, verdict, field(form, "why"))
209 : null;
210 if (result === null) return notFoundPage(ctx);
211 if (!result.ok) return flashTo(ctx, result.error.error === "not_found" ? list : betHref(repo, id), { tone: "danger", message: result.error.message || WHY[result.error.error] });
212 return flashTo(ctx, betHref(repo, id), { message: oneLine(`${result.value.name}: ${STATE_WORDS[result.value.state]}.`) });
213 }
src/web/pages/changelog.ts +32 −6
@@ -8,7 +8,8 @@
88
99 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { canRead, getRepo } from "../../core/repos.ts";
11 import { betNamesOfPulls } from "../../core/bets.ts";
12 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
1213 import { newestLive, secondsWords } from "../../keeper/landed.ts";
1314 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1415 import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts";
@@ -29,7 +30,7 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
2930 return row({
3031 href: `/${l.repo}/pulls/${l.number}`,
3132 title: l.title,
32 meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}`,
33 meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}`,
3334 trail: time(l.at),
3435 });
3536 }
@@ -144,8 +145,11 @@ export function shippedRegion(
144145 // GET /changelog?repo=all|owner/name
145146 export async function changelogPage(ctx: Ctx): Promise<Response> {
146147 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
147 const read = await timePhaseAsync(ctx.request, "releases", () => readLandings(ctx.app));
148 const landed = await timePhaseAsync(ctx.request, "releases", () => readLandings(ctx.app));
148149 const rendering = performance.now();
150 const ownRepo = landed.own === undefined ? undefined : getRepo(ctx.app.db, ...(landed.own.split("/") as [string, string]));
151 const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user);
152 const { read, bets, bet } = withBets(ctx, landed, readable ? ownRepo : undefined);
149153 const everything = [...read.onItsWay, ...read.live];
150154 const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own);
151155 const others = [...new Set(everything.map((l) => l.repo))].filter((r) => r !== read.own).slice(0, FILTERS);
@@ -154,9 +158,14 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
154158 { label: "Everything", href: "/changelog?repo=all", current: choice === "all" },
155159 ...others.map((r) => ({ label: r, href: `/changelog?repo=${encodeURIComponent(r)}`, current: choice === r })),
156160 ]);
157 const ownRepo = read.own === undefined ? undefined : getRepo(ctx.app.db, ...(read.own.split("/") as [string, string]));
158 const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user);
159 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}
161 const betFilters =
162 bets.length === 0 || choice !== read.own
163 ? null
164 : seg([
165 { label: "Every bet", href: "/changelog", current: bet === "" },
166 ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })),
167 ]);
168 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}${betFilters}
160169 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", newestLive(ctx.app.db), readable)}</div>`;
161170 const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
162171 notePhase(ctx.request, "render", performance.now() - rendering);
@@ -168,3 +177,20 @@ export async function changelogJson(ctx: Ctx): Promise<Response> {
168177 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
169178 return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo")), 200, { "cache-control": "public, max-age=5" });
170179 }
180
181 /**
182 * The landings with the bet each built (#8713), and `?bet=` narrowing them to
183 * one bet's. Only the forge's own repository, and only for a reader of it.
184 */
185 function withBets(ctx: Ctx, read: Landings, repo: Repo | undefined): { read: Landings; bets: string[]; bet: string } {
186 if (repo === undefined) return { read, bets: [], bet: "" };
187 const names = betNamesOfPulls(ctx.app.db, repo.id, [...read.live, ...read.onItsWay].filter((l) => l.repo === read.own).map((l) => l.number));
188 const mark = (l: Landing): Landing => (l.repo === read.own && names.has(l.number) ? { ...l, bet: names.get(l.number)! } : l);
189 const live = read.live.map(mark);
190 const onItsWay = read.onItsWay.map(mark);
191 const bets = [...new Set([...live, ...onItsWay].flatMap((l) => (l.bet === undefined ? [] : [l.bet])))].sort();
192 const asked = ctx.url.searchParams.get("bet") ?? "";
193 const bet = bets.includes(asked) ? asked : "";
194 const keep = (l: Landing) => bet === "" || l.bet === bet;
195 return { read: { ...read, live: live.filter(keep), onItsWay: onItsWay.filter(keep) }, bets, bet };
196 }
test/bets.test.ts added +439
@@ -0,0 +1,439 @@
1 /**
2 * Bets (#8713): the owner backs a few named wholes with a share of the week;
3 * builders pick from backed bets; a bet that spends its appetite stops; a
4 * finished bet is a moment; the same state gives the same picks.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { backBet, endBet, giveVerdict, proposeBets, stopBet, tickBets, writeLeft } from "../src/actions/bets.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { getBet, insertBet, listBets, markBacked } from "../src/core/bets.ts";
16 import { createLabel, getIssueById, setIssueLabels, type Issue } from "../src/core/issues.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { autoStarts, budgetSeconds, mayBack, mayPick, scopeState, spentSeconds, spentUp } from "../src/crew/bets.ts";
20 import { driveRepo } from "../src/crew/autonomy.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22
23 describe("the rules", () => {
24 test("builder time is the runs' span; an unfinished run counts up to now", () => {
25 const now = new Date("2026-11-03T12:00:00Z");
26 expect(spentSeconds([{ startedAt: "2026-11-03T10:00:00Z", finishedAt: "2026-11-03T11:00:00Z" }, { startedAt: "2026-11-03T11:30:00Z", finishedAt: null }], now)).toBe(5400);
27 expect(spentSeconds([{ startedAt: null, finishedAt: null }], now)).toBe(0);
28 });
29
30 test("a share of the week buys slots x week x share, and the bet stops at it", () => {
31 expect(budgetSeconds(50, 2)).toBe(604800);
32 expect(spentUp(604799, 604800)).toBe(false);
33 expect(spentUp(604800, 604800)).toBe(true);
34 });
35
36 test("at most 5 run, the shares stay within the week, a share is 1 to 100", () => {
37 expect(mayBack(Array(5).fill({ appetite: 10 }), 10)).toContain("5 bets");
38 expect(mayBack([{ appetite: 60 }, { appetite: 30 }], 20)).toContain("whole week");
39 expect(mayBack([], 0)).not.toBeNull();
40 expect(mayBack([], 1.5)).not.toBeNull();
41 expect(mayBack([{ appetite: 60 }], 40)).toBeNull();
42 });
43
44 test("the planner starts its top bets only when nothing is backed and the first proposal is a day old; the same state, the same ones", () => {
45 const p = [{ id: "c", proposedAt: "2026-11-01T00:00:00Z" }, { id: "a", proposedAt: "2026-11-01T00:00:00Z" }, { id: "b", proposedAt: "2026-11-01T05:00:00Z" }];
46 const day = new Date("2026-11-02T00:00:00Z");
47 expect(autoStarts(p, 0, new Date("2026-11-01T23:59:00Z"))).toEqual([]);
48 expect(autoStarts(p, 0, day)).toEqual(["a", "c"]);
49 expect(autoStarts([...p].reverse(), 0, day)).toEqual(["a", "c"]);
50 expect(autoStarts(p, 1, day)).toEqual([]);
51 });
52
53 test("a scope reads figuring out, executing, a raised hand when quiet for two days, or done", () => {
54 const now = new Date("2026-11-05T00:00:00Z");
55 expect(scopeState({ closed: false, ran: false, movedAt: "2026-11-04T00:00:00Z" }, now)).toBe("figuring out");
56 expect(scopeState({ closed: false, ran: true, movedAt: "2026-11-04T00:00:00Z" }, now)).toBe("executing");
57 expect(scopeState({ closed: false, ran: true, movedAt: "2026-11-02T00:00:00Z" }, now)).toBe("raised hand");
58 expect(scopeState({ closed: true, ran: true, movedAt: "2026-11-02T00:00:00Z" }, now)).toBe("done");
59 });
60
61 test("an issue in no bet is not picked; the owner's ask, a small fix or a scope of a backed bet is", () => {
62 expect(mayPick({ raised: false, small: false, bet: undefined })).toBe(false);
63 expect(mayPick({ raised: true, small: false, bet: undefined })).toBe(true);
64 expect(mayPick({ raised: false, small: true, bet: undefined })).toBe(true);
65 expect(mayPick({ raised: false, small: false, bet: "b" })).toBe(true);
66 });
67 });
68
69 let dataDir: string;
70 let app: App;
71 let alice: User;
72 let bob: User;
73 let repo: Repo;
74 let servers: Servers;
75 const fresh = () => getRepoById(app.db, repo.id)!;
76 const NOW = new Date("2026-11-03T12:00:00Z");
77
78 function open(title: string): Issue {
79 const made = openIssue(app, alice, fresh(), { title });
80 if (!made.ok) throw new Error(String(made.error));
81 return made.value;
82 }
83
84 beforeAll(async () => {
85 dataDir = await mkdtemp(join(tmpdir(), "forge-bets-"));
86 app = createApp({ dataDir, port: 0 });
87 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
88 if (!a.ok) throw new Error(a.error);
89 alice = a.value;
90 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
91 if (!b.ok) throw new Error(b.error);
92 bob = b.value;
93 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
94 if (!made.ok) throw new Error(String(made.error));
95 repo = made.value;
96 const first = await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "a.txt", content: "a\n" }] });
97 if (!first.ok) throw new Error(JSON.stringify(first.error));
98 for (const name of ["b1", "b2", "b3"]) {
99 const agent = await createAgent(app, alice, { username: name });
100 if (!agent.ok) throw new Error(String(agent.error));
101 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
102 }
103 expect((await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Keep notes tidy.", autonomyCeilingUsd: 10, autonomySlots: 3, autonomyBy: alice.id })).ok).toBe(true);
104 servers = await serve(app);
105 });
106
107 afterAll(async () => {
108 servers.stop();
109 app.close();
110 await rm(dataDir, { recursive: true, force: true });
111 });
112
113 describe("proposing and backing", () => {
114 test("the planner groups open work by theme, once; only the owner backs, at most 5 run", () => {
115 const label = createLabel(app.db, repo.id, "search", "aabbcc");
116 if (!label.ok) throw new Error("label");
117 const s1 = open("Find a note by word");
118 const s2 = open("Highlight the match");
119 setIssueLabels(app.db, s1.id, [label.value.id]);
120 setIssueLabels(app.db, s2.id, [label.value.id]);
121 const made = proposeBets(app, fresh(), NOW);
122 expect(made.map((m) => m.name)).toEqual(["Search"]);
123 expect(proposeBets(app, fresh(), NOW)).toEqual([]);
124 const bet = made[0]!;
125 expect(bet.state).toBe("proposed");
126 expect(backBet(app, bob, fresh(), bet.id, 30, NOW).ok).toBe(false);
127 expect(backBet(app, alice, fresh(), bet.id, 0, NOW).ok).toBe(false);
128 const backed = backBet(app, alice, fresh(), bet.id, 30, NOW);
129 expect(backed.ok && backed.value.state).toBe("backed");
130 expect(backed.ok && backed.value.backedBy).toBe("owner");
131 for (let n = 0; n < 4; n++) {
132 const extra = insertBet(app.db, { repoId: repo.id, name: `Extra ${n}`, journeys: "", appetite: 5, leadId: null, issueIds: [] }, NOW);
133 expect(backBet(app, alice, fresh(), extra.id, 5, NOW).ok).toBe(true);
134 }
135 const sixth = insertBet(app.db, { repoId: repo.id, name: "Sixth", journeys: "", appetite: 5, leadId: null, issueIds: [] }, NOW);
136 const refused = backBet(app, alice, fresh(), sixth.id, 5, NOW);
137 expect(!refused.ok && refused.error.message).toContain("5 bets");
138 for (const b of listBets(app.db, repo.id)) if (b.name !== "Search" && b.state === "backed") expect(stopBet(app, alice, fresh(), b.id, NOW).ok).toBe(true);
139 });
140 });
141
142 describe("builders pick from backed bets", () => {
143 test("an issue in no bet does not start; a scope of a backed bet does, the same every time", async () => {
144 const outside = open("Rename the app");
145 const bet = listBets(app.db, repo.id).find((b) => b.name === "Search")!;
146 const scopes = app.db.query<{ issue_id: string }, [string]>("SELECT issue_id FROM bet_issue WHERE bet_id = ?1").all(bet.id).map((r) => r.issue_id);
147 expect(scopes.length).toBe(2);
148 const picked: string[] = [];
149 for (let n = 0; n < 3; n++) {
150 if ((await driveRepo(app, fresh())) === "picked") {
151 const hit = [...scopes, outside.id].filter((id) => getIssueById(app.db, id)!.assigneeId !== null);
152 picked.push(...hit);
153 break;
154 }
155 }
156 expect(picked.length).toBeGreaterThan(0);
157 expect(picked.every((id) => scopes.includes(id))).toBe(true);
158 expect(getIssueById(app.db, outside.id)!.assigneeId).toBeNull();
159 });
160 });
161
162 describe("a bet that hits its appetite stops", () => {
163 test("no extension: it stops with a left line the lead can rewrite, and the owner re-backs it", () => {
164 const issue = open("Tag a note");
165 const bet = insertBet(app.db, { repoId: repo.id, name: "Tags", journeys: "Someone can tag a note", appetite: 10, leadId: alice.id, issueIds: [issue.id] }, NOW);
166 markBacked(app.db, bet.id, 10, "owner", NOW);
167 const runAt = new Date(NOW.getTime() + 1000);
168 app.db.query("INSERT INTO run (id, repo_id, kind, issue_id, state, started_at, finished_at) VALUES ('r-bet', ?1, 'agent', ?2, 'ok', ?3, ?4)")
169 .run(repo.id, issue.id, NOW.toISOString(), new Date(NOW.getTime() + budgetSeconds(10, 3) * 1000).toISOString());
170 expect(getBet(app.db, bet.id)!.state).toBe("backed");
171 const out = tickBets(app, fresh(), runAt);
172 expect(out.stopped).toBe(1);
173 const stopped = getBet(app.db, bet.id)!;
174 expect(stopped.state).toBe("stopped");
175 expect(stopped.leftLine).toContain("Tag a note");
176 expect(tickBets(app, fresh(), runAt).stopped).toBe(0);
177 expect(writeLeft(app, bob, fresh(), bet.id, "x").ok).toBe(false);
178 expect(writeLeft(app, alice, fresh(), bet.id, "Only the tag colours remain.").ok).toBe(true);
179 expect(getBet(app.db, bet.id)!.leftLine).toBe("Only the tag colours remain.");
180 expect(endBet(app, alice, fresh(), bet.id, "cut", runAt).ok).toBe(true);
181 });
182 });
183
184 describe("finishing is a moment", () => {
185 test("all scopes closed finishes the bet; the owner says feels done or not yet and why", () => {
186 const issue = open("Pin a note");
187 const bet = insertBet(app.db, { repoId: repo.id, name: "Pins", journeys: "Someone can pin a note", appetite: 10, leadId: null, issueIds: [issue.id] }, NOW);
188 markBacked(app.db, bet.id, 10, "owner", NOW);
189 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(issue.id);
190 expect(tickBets(app, fresh(), NOW).finished).toBe(1);
191 const done = getBet(app.db, bet.id)!;
192 expect(done.state).toBe("done");
193 expect(done.tryUrl).toBe("/alice/notes");
194 expect(giveVerdict(app, bob, fresh(), bet.id, "done", "", NOW).ok).toBe(false);
195 expect(giveVerdict(app, alice, fresh(), bet.id, "not_yet", "", NOW).ok).toBe(false);
196 expect(giveVerdict(app, alice, fresh(), bet.id, "not_yet", "Pins do not survive a reload.", NOW).ok).toBe(true);
197 const back = getBet(app.db, bet.id)!;
198 expect(back.state).toBe("stopped");
199 expect(back.leftLine).toBe("Pins do not survive a reload.");
200 });
201 });
202
203 describe("when nothing is backed", () => {
204 test("a day later the planner's top bets start, marked as not the owner's; one tap stops one", async () => {
205 app.db.query("DELETE FROM bet WHERE repo_id = ?1").run(repo.id);
206 const a = insertBet(app.db, { repoId: repo.id, name: "A", journeys: "", appetite: 20, leadId: null, issueIds: [] }, NOW);
207 insertBet(app.db, { repoId: repo.id, name: "B", journeys: "", appetite: 20, leadId: null, issueIds: [] }, new Date(NOW.getTime() + 1000));
208 expect(tickBets(app, fresh(), new Date(NOW.getTime() + 3600_000)).started).toBe(0);
209 expect(tickBets(app, fresh(), new Date(NOW.getTime() + 25 * 3600_000)).started).toBe(2);
210 const started = getBet(app.db, a.id)!;
211 expect(started.state).toBe("backed");
212 expect(started.backedBy).toBe("planner");
213 expect(stopBet(app, alice, fresh(), a.id, NOW).ok).toBe(true);
214 // The owner's stop is not undone by the planner.
215 expect(tickBets(app, fresh(), new Date(NOW.getTime() + 26 * 3600_000)).started).toBe(0);
216 });
217 });
218
219 describe("the pages", () => {
220 test("the list and a bet render server-side, escaped, and a cross-origin POST is refused", async () => {
221 const evil = insertBet(app.db, { repoId: repo.id, name: "<script>alert(1)</script>", journeys: "<b>x</b>", appetite: 5, leadId: null, issueIds: [] }, NOW);
222 const list = await (await fetch(`${servers.url}/alice/notes/bets`)).text();
223 expect(list).toContain("Bets");
224 expect(list).not.toContain("<script>alert(1)</script>");
225 const one = await (await fetch(`${servers.url}/alice/notes/bets/${evil.id}`, { headers: { cookie: await signIn("alice") } })).text();
226 expect(one).toContain("&lt;b&gt;x&lt;/b&gt;");
227 const post = await fetch(`${servers.url}/alice/notes/bets/${evil.id}/stop`, { method: "POST", redirect: "manual", headers: { origin: "https://evil.example" } });
228 expect(post.status).toBe(403);
229 });
230 });
231
232 // ---- review round: ending, visibility, the pick path, the changelog, the MCP, the terminal ----
233
234 import { betNamesOfPulls } from "../src/core/bets.ts";
235 import { getUserByName } from "../src/core/users.ts";
236 import { tool as betTool } from "../src/http/mcp/tools/bet.ts";
237 import { tool as betsTool } from "../src/http/mcp/tools/bets.ts";
238 import { createForgeApp } from "../src/tui/forge.ts";
239 import type { TuiIdentity } from "../src/tui/session.ts";
240 import { headlessTerminal } from "../src/tui/term/terminal.ts";
241 import { settleApp } from "./helpers/tui.ts";
242
243 const stateOf = (id: string) => getBet(app.db, id)!.state;
244 const fresh2 = (name: string, scopes: string[] = []) =>
245 insertBet(app.db, { repoId: repo.id, name, journeys: "Someone can see the plan", appetite: 10, leadId: null, issueIds: scopes }, NOW);
246
247 async function signIn(username: string): Promise<string> {
248 const r = await fetch(`${servers.url}/signin`, {
249 method: "POST", redirect: "manual",
250 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
251 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
252 });
253 return (r.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
254 }
255 const act = (cookie: string, path: string, body: Record<string, string> = {}) =>
256 fetch(`${servers.url}${path}`, {
257 method: "POST", redirect: "manual",
258 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
259 body: new URLSearchParams(body),
260 });
261
262 describe("ending", () => {
263 test("only a stopped bet is ended as done; a proposed or running one is refused and stays", () => {
264 const b = fresh2("Ending");
265 expect(endBet(app, alice, fresh(), b.id, "done", NOW).ok).toBe(false);
266 markBacked(app.db, b.id, 10, "owner", NOW);
267 expect(endBet(app, alice, fresh(), b.id, "done", NOW).ok).toBe(false);
268 expect(stateOf(b.id)).toBe("backed");
269 expect(stopBet(app, alice, fresh(), b.id, NOW).ok).toBe(true);
270 expect(endBet(app, alice, fresh(), b.id, "done", NOW).ok).toBe(true);
271 expect(getBet(app.db, b.id)!.tryUrl).toBe("/alice/notes");
272 });
273 });
274
275 describe("who sees and who acts, on the page", () => {
276 test("a reader sees names and states, not the plan; a non-owner's POST changes nothing; another repo's bet is 404", async () => {
277 const b = fresh2("Visible");
278 setLeft(b.id);
279 const bob = await signIn("bob");
280 const list = await (await fetch(`${servers.url}/alice/notes/bets`, { headers: { cookie: bob } })).text();
281 expect(list).toContain("Visible");
282 expect(list).not.toContain("10% of the week");
283 const one = await (await fetch(`${servers.url}/alice/notes/bets/${b.id}`, { headers: { cookie: bob } })).text();
284 expect(one).not.toContain("Someone can see the plan");
285 expect(one).not.toContain("secret reason");
286 const post = await act(bob, `/alice/notes/bets/${b.id}/back`, { share: "10" });
287 expect(post.status).toBe(303);
288 expect(stateOf(b.id)).toBe("proposed");
289 const guest = await fetch(`${servers.url}/alice/notes/bets/${b.id}/back`, { method: "POST", redirect: "manual", headers: { origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, body: "share=10" });
290 expect([303, 401, 403]).toContain(guest.status);
291 expect(stateOf(b.id)).toBe("proposed");
292
293 const other = await createRepo(app, alice, { name: "other", visibility: "public" });
294 if (!other.ok) throw new Error("repo");
295 const foreign = insertBet(app.db, { repoId: other.value.id, name: "Foreign", journeys: "", appetite: 5, leadId: null, issueIds: [] }, NOW);
296 expect((await fetch(`${servers.url}/alice/notes/bets/${foreign.id}`)).status).toBe(404);
297 const cross = await act(await signIn("alice"), `/alice/notes/bets/${foreign.id}/back`, { share: "5" });
298 expect(stateOf(foreign.id)).toBe("proposed");
299 expect(cross.status).toBe(303);
300 });
301
302 test("the owner's page POSTs back, a refusal lands on the bet, and not-yet returns it to stopped", async () => {
303 const alicec = await signIn("alice");
304 const b = fresh2("Flow");
305 expect((await act(alicec, `/alice/notes/bets/${b.id}/back`, { share: "15" })).status).toBe(303);
306 expect(stateOf(b.id)).toBe("backed");
307 const refused = await act(alicec, `/alice/notes/bets/${b.id}/end`);
308 expect(refused.headers.get("location")).toContain(`/bets/${b.id}`);
309 expect(stateOf(b.id)).toBe("backed");
310 app.db.query("UPDATE bet SET state = 'done', ended_at = ?2 WHERE id = ?1").run(b.id, NOW.toISOString());
311 await act(alicec, `/alice/notes/bets/${b.id}/verdict`, { verdict: "not_yet", why: "Too slow." });
312 expect(stateOf(b.id)).toBe("stopped");
313 expect(getBet(app.db, b.id)!.leftLine).toBe("Too slow.");
314 });
315 });
316
317 function setLeft(id: string): void {
318 app.db.query("UPDATE bet SET left_line = 'secret reason' WHERE id = ?1").run(id);
319 }
320
321 describe("the MCP", () => {
322 const ctxFor = (user: User) => ({ app, auth: { user, scope: "rw" } }) as never;
323 test("the owner reads the plan and acts; a reader reads names; an agent and a reader are refused every act", async () => {
324 const b = fresh2("Mcp", []);
325 const agent = getUserByName(app.db, "b1")!;
326 const read = await betsTool.run(ctxFor(alice), alice, { app: "alice/notes" });
327 expect(JSON.stringify(read)).toContain("Someone can see the plan");
328 const seen = await betsTool.run(ctxFor(bob), bob, { app: "alice/notes" });
329 expect(JSON.stringify(seen)).toContain("Mcp");
330 expect(JSON.stringify(seen)).not.toContain("Someone can see the plan");
331 for (const who of [agent, bob]) {
332 for (const args of [{ verb: "back", bet: b.id, share: 10 }, { verb: "stop", bet: b.id }, { verb: "cut", bet: b.id }, { verb: "propose" }]) {
333 const r = await betTool.run(ctxFor(who), who, { app: "alice/notes", ...args });
334 expect(r.ok).toBe(false);
335 }
336 }
337 expect(stateOf(b.id)).toBe("proposed");
338 const ok = await betTool.run(ctxFor(alice), alice, { app: "alice/notes", verb: "back", bet: b.id, share: 10 });
339 expect(ok.ok).toBe(true);
340 expect(stateOf(b.id)).toBe("backed");
341 // A read-only token cannot act.
342 const ro = await betTool.run({ app, auth: { user: alice, scope: "ro" } } as never, alice, { app: "alice/notes", verb: "stop", bet: b.id });
343 expect(ro.ok).toBe(false);
344 expect(stateOf(b.id)).toBe("backed");
345 });
346 });
347
348 describe("the changelog names the bet", () => {
349 test("a pull built from a bet's scope, by its run's branch, is named for the bet", () => {
350 const scope = open("Build the thing");
351 const pull = open("The pull");
352 app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(pull.id);
353 app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'crew/x', 'a', 'b')").run(pull.id);
354 app.db.query("INSERT INTO run (id, repo_id, kind, issue_id, state, branch) VALUES ('r-log', ?1, 'agent', ?2, 'ok', 'crew/x')").run(repo.id, scope.id);
355 const b = fresh2("Logged", [scope.id]);
356 expect(betNamesOfPulls(app.db, repo.id, [pull.number]).get(pull.number)).toBe("Logged");
357 expect(betNamesOfPulls(app.db, repo.id, [pull.number + 100]).size).toBe(0);
358 expect(b.name).toBe("Logged");
359 });
360 });
361
362 describe("the same state gives the same pick", () => {
363 test("the pick path run twice from the same state picks the same issue", async () => {
364 for (const b of listBets(app.db, repo.id)) if (b.state === "backed") stopBet(app, alice, fresh(), b.id, NOW);
365 app.db.query("UPDATE issue SET assignee_id = NULL").run();
366 const one = open("Twin one");
367 const two = open("Twin two");
368 const bet = fresh2("Twins", [one.id, two.id]);
369 markBacked(app.db, bet.id, 10, "owner", NOW);
370 const pickOnce = async (): Promise<string | undefined> => {
371 if ((await driveRepo(app, fresh())) !== "picked") return undefined;
372 const hit = [one, two].find((i) => getIssueById(app.db, i.id)!.assigneeId !== null);
373 app.db.query("UPDATE issue SET assignee_id = NULL WHERE id IN (?1, ?2)").run(one.id, two.id);
374 app.db.query("DELETE FROM run WHERE issue_id IN (?1, ?2)").run(one.id, two.id);
375 return hit?.id;
376 };
377 const first = await pickOnce();
378 const second = await pickOnce();
379 expect(first).toBeDefined();
380 expect(second).toBe(first);
381 });
382 });
383
384 describe("the terminal", () => {
385 function boot(username: string) {
386 const terminal = headlessTerminal(140, 32);
387 const user = getUserByName(app.db, username)!;
388 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
389 const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false });
390 tui.start();
391 const press = async (...keys: string[]) => {
392 for (const k of keys) {
393 tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
394 await settleApp(tui);
395 }
396 };
397 const type = async (t: string) => {
398 for (const ch of t) tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
399 await settleApp(tui);
400 };
401 const openBets = async () => {
402 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
403 await settleApp(tui);
404 await type("Bets");
405 await press("enter");
406 };
407 return { text: () => tui.screen.toText(), press, type, openBets, stop: () => tui.stop() };
408 }
409
410 test("the owner stops, ends, says not yet and proposes from the screen, each an event the web follows; a reader sees nothing and changes nothing", async () => {
411 app.db.query("DELETE FROM bet WHERE repo_id = ?1").run(repo.id);
412 const b = fresh2("Terminal");
413 markBacked(app.db, b.id, 10, "owner", NOW);
414 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind LIKE 'bet.%'").get()!.n;
415
416 const reader = boot("bob");
417 await reader.openBets();
418 expect(reader.text()).toContain("Bets are for the person who administers a repository.");
419 await reader.press("s");
420 expect(stateOf(b.id)).toBe("backed");
421 reader.stop();
422
423 const h = boot("alice");
424 await h.openBets();
425 expect(h.text()).toContain("Terminal");
426 await h.press("s");
427 expect(stateOf(b.id)).toBe("stopped");
428 await h.press("e");
429 expect(stateOf(b.id)).toBe("done");
430 await h.press("n");
431 await h.type("Not good enough");
432 await h.press("enter");
433 expect(stateOf(b.id)).toBe("stopped");
434 expect(getBet(app.db, b.id)!.leftLine).toBe("Not good enough");
435 const after = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind LIKE 'bet.%'").get()!.n;
436 expect(after).toBeGreaterThanOrEqual(before + 3);
437 h.stop();
438 });
439 });
test/conversation-confirm.test.ts +1 −1
@@ -121,7 +121,7 @@ describe("the one list of what asks first", () => {
121121 // settings and can be put back. A new `writes: true` tool is on neither list until someone decides.
122122 const NOT_ASKED = new Set([
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
test/mcp.test.ts +2
@@ -303,6 +303,8 @@ describe("the forge over MCP", () => {
303303 "egress_grants",
304304 "explore",
305305 "person",
306 "bets",
307 "bet",
306308 "pinned_forge",
307309 "search",
308310 "feed",
test/palette-roster.test.ts +2
@@ -115,6 +115,7 @@ test("a site-admin person is offered the whole roster", () => {
115115 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
116116 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
117117 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
118 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
118119 ["origins", "Connected origins", "the hosts your mirrors come from"],
119120 ["domains", "Failure domains", "failure domains and their last drill"],
120121 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
@@ -228,6 +229,7 @@ test("a member person is offered the signed-in roster without the admin entries"
228229 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
229230 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
230231 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
232 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
231233 ["origins", "Connected origins", "the hosts your mirrors come from"],
232234 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
233235 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
test/security-floor-routes.test.ts +2
@@ -43,11 +43,13 @@ const SECRET = "needs a secret scope an operator has set";
4343 const GO = "needs a short code minted by a share; the seed shares nothing";
4444 const ARCH = "needs an archived-home operation id; the seed archives nothing";
4545 const GATE = "needs a kept gate log, written only when a full gate runs on land";
46 const BET = "needs a bet, which the seed does not propose";
4647 const HOOK = "needs a webhook, which the seed does not register";
4748
4849 /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */
4950 const UNSEEDED: Record<string, string> = {
5051 "/:owner/-/teams/:team": ORG,
52 "/:owner/:name/bets/:id": BET,
5153 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
5254 "/:owner/:name/milestones/:number/limb/:op": LIMB,
5355 "/:owner/:name/variants/:group": VARIANT,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs