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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8713-bets-the-owner-backs-a-few-named-wholes → main 5 commits fork
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 { | ||
| 178 | 178 | kind === "forge.upstream" || |
| 179 | 179 | kind === "forge.updates_taken" || |
| 180 | 180 | kind === "cloud.write_allowed" || |
| 181 | // A bet's name, share and what is left are the owner's plan (#8713). | |
| 182 | kind.startsWith("bet.") || | |
| 181 | 183 | // A pinned forge's deploy, update and rollback name the owner's cloud (#8697). |
| 182 | 184 | kind.startsWith("pinned.") || |
| 183 | 185 | // 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 @@ | ||
| 12 | 12 | * repository's origin. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { tickBets } from "../actions/bets.ts"; | |
| 15 | 16 | import { onlyListDocs, resolveListConflicts } from "../actions/list-conflicts.ts"; |
| 16 | 17 | import { closedToday, closeStale } from "./stale_pulls.ts"; |
| 17 | 18 | import type { Database } from "bun:sqlite"; |
| @@ -31,6 +32,8 @@ import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, typ | ||
| 31 | 32 | import { checksFor } from "../checks/service.ts"; |
| 32 | 33 | import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts"; |
| 33 | 34 | 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"; | |
| 34 | 37 | import { raisedAt } from "../core/thought_links.ts"; |
| 35 | 38 | import { readConflictLane, readConflictRoom } from "./conflict_room.ts"; |
| 36 | 39 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| @@ -410,8 +413,20 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re | ||
| 410 | 413 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| 411 | 414 | // Issues picked earlier in this tick hold their share before their runs show (#8729). |
| 412 | 415 | 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 | ); | |
| 413 | 428 | return slotOrder( |
| 414 | ready, | |
| 429 | allowed, | |
| 415 | 430 | (r) => ({ |
| 416 | 431 | ranked: ranked.has(r.issue.id), |
| 417 | 432 | raised: raised.has(r.issue.id) || first.has(r.issue.id), |
| @@ -470,6 +485,7 @@ async function driveOnce( | ||
| 470 | 485 | taken: Set<string>, |
| 471 | 486 | ): Promise<Drove> { |
| 472 | 487 | if (repo.autonomy === "off") return "off"; |
| 488 | tickBets(app, repo, now); | |
| 473 | 489 | const status = autonomyStatus(app, repo, now); |
| 474 | 490 | if (status.paused !== null) return "paused"; |
| 475 | 491 | 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"; | ||
| 27 | 27 | export { tool as backups } from "./backups.ts"; |
| 28 | 28 | export { tool as backups_destination } from "./backups_destination.ts"; |
| 29 | 29 | 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"; | |
| 30 | 32 | export { tool as board_delete } from "./board_delete.ts"; |
| 31 | 33 | export { tool as board_pins } from "./board_pins.ts"; |
| 32 | 34 | 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"; | ||
| 138 | 138 | import { activityAtom, changelogAtom } from "../web/feeds.ts"; |
| 139 | 139 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 140 | 140 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 141 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; | |
| 141 | 142 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 142 | 143 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 143 | 144 | import { |
| @@ -1033,6 +1034,10 @@ export function buildRouter(): Router<Handler> { | ||
| 1033 | 1034 | router.add("GET", "/:owner/:name/next", nextPage); |
| 1034 | 1035 | router.add("POST", "/:owner/:name/next", nextSubmit); |
| 1035 | 1036 | 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); | |
| 1036 | 1041 | router.add("GET", "/:owner/:name/milestones", milestonesPage); |
| 1037 | 1042 | router.add("GET", "/:owner/:name/milestones/:number", milestonePage); |
| 1038 | 1043 | router.add("POST", "/:owner/:name/milestones/:number/watch", milestoneWatch); |
src/release/landings.ts +2
| @@ -26,6 +26,8 @@ export interface Landing { | ||
| 26 | 26 | /** When the pull merged. */ |
| 27 | 27 | readonly at: string; |
| 28 | 28 | readonly live: boolean; |
| 29 | /** The bet whose scopes this pull built (#8713), set by the page that shows it. */ | |
| 30 | readonly bet?: string; | |
| 29 | 31 | } |
| 30 | 32 | |
| 31 | 33 | 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"; | ||
| 12 | 12 | import { entry as agents } from "./agents.ts"; |
| 13 | 13 | import { entry as archived } from "./archived.ts"; |
| 14 | 14 | import { entry as audit } from "./audit.ts"; |
| 15 | import { entry as bets } from "./bets.ts"; | |
| 15 | 16 | import { entry as boards } from "./boards.ts"; |
| 16 | 17 | import { entry as bulk } from "./bulk.ts"; |
| 17 | 18 | import { entry as cloud } from "./cloud.ts"; |
| @@ -86,6 +87,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 86 | 87 | subscriptions, |
| 87 | 88 | cloud, |
| 88 | 89 | pinned, |
| 90 | bets, | |
| 89 | 91 | connections, |
| 90 | 92 | domains, |
| 91 | 93 | 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 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | 10 | 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"; | |
| 12 | 13 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; |
| 13 | 14 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 14 | 15 | 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 | ||
| 29 | 30 | return row({ |
| 30 | 31 | href: `/${l.repo}/pulls/${l.number}`, |
| 31 | 32 | 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" })}`}`, | |
| 33 | 34 | trail: time(l.at), |
| 34 | 35 | }); |
| 35 | 36 | } |
| @@ -144,8 +145,11 @@ export function shippedRegion( | ||
| 144 | 145 | // GET /changelog?repo=all|owner/name |
| 145 | 146 | export async function changelogPage(ctx: Ctx): Promise<Response> { |
| 146 | 147 | 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)); | |
| 148 | 149 | 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); | |
| 149 | 153 | const everything = [...read.onItsWay, ...read.live]; |
| 150 | 154 | const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own); |
| 151 | 155 | 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> { | ||
| 154 | 158 | { label: "Everything", href: "/changelog?repo=all", current: choice === "all" }, |
| 155 | 159 | ...others.map((r) => ({ label: r, href: `/changelog?repo=${encodeURIComponent(r)}`, current: choice === r })), |
| 156 | 160 | ]); |
| 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} | |
| 160 | 169 | <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", newestLive(ctx.app.db), readable)}</div>`; |
| 161 | 170 | 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}` }); |
| 162 | 171 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -168,3 +177,20 @@ export async function changelogJson(ctx: Ctx): Promise<Response> { | ||
| 168 | 177 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 169 | 178 | return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo")), 200, { "cache-control": "public, max-age=5" }); |
| 170 | 179 | } |
| 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("<b>x</b>"); | |
| 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", () => { | ||
| 121 | 121 | // settings and can be put back. A new `writes: true` tool is on neither list until someone decides. |
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "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", | |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "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", () => { | ||
| 303 | 303 | "egress_grants", |
| 304 | 304 | "explore", |
| 305 | 305 | "person", |
| 306 | "bets", | |
| 307 | "bet", | |
| 306 | 308 | "pinned_forge", |
| 307 | 309 | "search", |
| 308 | 310 | "feed", |
test/palette-roster.test.ts +2
| @@ -115,6 +115,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 115 | 115 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 116 | 116 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 117 | 117 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
| 118 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], | |
| 118 | 119 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 119 | 120 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 120 | 121 | ["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" | ||
| 228 | 229 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 229 | 230 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 230 | 231 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 232 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], | |
| 231 | 233 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 232 | 234 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 233 | 235 | ["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"; | ||
| 43 | 43 | const GO = "needs a short code minted by a share; the seed shares nothing"; |
| 44 | 44 | const ARCH = "needs an archived-home operation id; the seed archives nothing"; |
| 45 | 45 | 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"; | |
| 46 | 47 | const HOOK = "needs a webhook, which the seed does not register"; |
| 47 | 48 | |
| 48 | 49 | /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */ |
| 49 | 50 | const UNSEEDED: Record<string, string> = { |
| 50 | 51 | "/:owner/-/teams/:team": ORG, |
| 52 | "/:owner/:name/bets/:id": BET, | |
| 51 | 53 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 52 | 54 | "/:owner/:name/milestones/:number/limb/:op": LIMB, |
| 53 | 55 | "/:owner/:name/variants/:group": VARIANT, |
new above
offline