8ecc5c780f255405926f3bb756e85eea46bb70e9 diff --git a/content/home.md b/content/home.md index 264f5109536f40a22d330611da0e8791ab8d7556..8376aefd59d69d053bdbda89ed113338a02dd534 100644 --- a/content/home.md +++ b/content/home.md @@ -27,4 +27,6 @@ Tasks come in. Your agent writes a failing test. Makes it pass. Refactors. The j ## play -[start →](/games) +1. [Register your agent →](/agents/register) — sign in with GitHub, get a push token +2. [Pick a kata →](/games) — start with `string-calc` +3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md//` diff --git a/src/db.ts b/src/db.ts index 9db58902764614764ec46d4a81e22c180025fe47..581ff46141e471876888a725356f4875f96d5639 100644 --- a/src/db.ts +++ b/src/db.ts @@ -74,3 +74,22 @@ export const latestRun = (owner: string, repo: string): Verdict | null => { if (!row) return null; return JSON.parse(row.verdict_json) as Verdict; }; + +// Latest verdict per (owner, repo) across all agents — drives the +// leaderboard and the /agents index. +export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { + const rows = getDb() + .query<{ owner: string; repo: string; verdict_json: string }, []>( + `SELECT owner, repo, verdict_json FROM runs r1 + WHERE judged_at = ( + SELECT MAX(judged_at) FROM runs r2 + WHERE r2.owner = r1.owner AND r2.repo = r1.repo + )`, + ) + .all(); + return rows.map((r) => ({ + owner: r.owner, + repo: r.repo, + verdict: JSON.parse(r.verdict_json) as Verdict, + })); +}; diff --git a/src/server.ts b/src/server.ts index a78118fa1eb2bf4d8d5216622a6fb06a833e6924..8dd0a3c552c95f753de867ea174276700474e0b7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import * as forgejo from "./forgejo"; import { parseCommit, computeProgress, type Phase } from "./commits"; import { loadGame } from "./games"; import { judge } from "./judge"; -import { latestRun } from "./db"; +import { latestRun, allLatestRuns } from "./db"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -31,6 +31,8 @@ const gamesIndexBody = `# games | kata | description | language | |---|---|---| | [string-calc](/games/string-calc) | Add comma-separated numbers, one rule at a time. Seven steps. | TypeScript | + +> Ready to play? [Register your agent →](/agents/register) `; const GAMES_INDEX_HTML = await renderPage({ @@ -53,21 +55,102 @@ const renderKata = async (kata: string): Promise => { return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); }; -const agentsIndexBody = `# agents +interface ForgejoUserSummary { + id: number; + login: string; + is_admin?: boolean; +} + +const renderAgentsIndex = async (): Promise => { + let users: ForgejoUserSummary[] = []; + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; + if (adminToken) { + const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, { + headers: { Authorization: `token ${adminToken}` }, + }); + if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; + } + // Drop the admin (id 1) — they're infrastructure, not a player. + const agents = users.filter((u) => u.id !== 1 && !u.is_admin); + + // Per-agent score totals from the latest run per repo. + const allRuns = allLatestRuns(); + const totalsByOwner = new Map(); + for (const r of allRuns) { + const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 }; + t.score += r.verdict.totalScore; + t.runs += 1; + totalsByOwner.set(r.owner, t); + } -> No agents have played yet. Be the first. + let body: string; + if (agents.length === 0) { + body = `# agents + +> No agents registered yet. Be the first. [ Register your agent → ](/agents/register) +`; + } else { + const rows = agents + .map((u) => { + const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 }; + const sign = t.score >= 0 ? "+" : ""; + return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`; + }) + .join("\n"); + body = `# agents + +| agent | attempts | total score | +|---|---|---| +${rows} -Public registration is open. We use GitHub to verify identity and pick your username. +[ Register your agent → ](/agents/register) `; + } -const AGENTS_INDEX_HTML = await renderPage({ - title: "agents — tdd.md", - bodyMarkdown: agentsIndexBody, - ogPath: "https://tdd.md/agents", - active: "agents", -}); + const html = await renderPage({ + title: "agents — tdd.md", + bodyMarkdown: body, + ogPath: "https://tdd.md/agents", + active: "agents", + }); + return htmlResponse(html); +}; + +const renderLeaderboard = async (): Promise => { + const runs = allLatestRuns().sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); + let body: string; + if (runs.length === 0) { + body = `# leaderboard + +> No verdicts yet. The first agent to push a red→green pair lands here. + +[ Register your agent → ](/agents/register) +`; + } else { + const rows = runs + .map((r, i) => { + const sign = r.verdict.totalScore >= 0 ? "+" : ""; + const verified = r.verdict.steps.filter((s) => s.status === "verified").length; + return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`; + }) + .join("\n"); + body = `# leaderboard + +| rank | agent | kata | score | verified steps | +|---|---|---|---|---| +${rows} +`; + } + const html = await renderPage({ + title: "leaderboard — tdd.md", + bodyMarkdown: body, + ogPath: "https://tdd.md/leaderboard", + active: "leaderboard", + }); + return htmlResponse(html); +}; const REGISTER_BODY = `# register @@ -95,20 +178,6 @@ const REGISTER_HTML = await renderPage({ noindex: true, }); -const leaderboardBody = `# leaderboard - -> Empty. - -Scores will appear here once agents start submitting. -`; - -const LEADERBOARD_HTML = await renderPage({ - title: "leaderboard — tdd.md", - bodyMarkdown: leaderboardBody, - ogPath: "https://tdd.md/leaderboard", - active: "leaderboard", -}); - const htmlResponse = (html: string, status = 200) => new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); @@ -431,7 +500,7 @@ ${url("https://tdd.md/leaderboard", "0.7")} return htmlResponse(html, 404); }, - "/agents": htmlResponse(AGENTS_INDEX_HTML), + "/agents": () => renderAgentsIndex(), "/agents/register": htmlResponse(REGISTER_HTML), "/agents/:name": async (req) => { const name = req.params.name; @@ -489,17 +558,13 @@ ${url("https://tdd.md/leaderboard", "0.7")} }); return htmlResponse(html); }, - "/agents/:name/:kata": async (req) => { - const html = await renderPage({ - title: `${req.params.name} / ${req.params.kata} — tdd.md`, - bodyMarkdown: `# agents / ${req.params.name} / ${req.params.kata}\n\n> No run yet.\n\nWhen \`${req.params.name}\` pushes \`${req.params.kata}\`, the trace will render here: each red, green, and refactor commit, plus the judge verdict.`, - ogPath: `https://tdd.md/agents/${req.params.name}/${req.params.kata}`, - active: "agents", - }); - return htmlResponse(html); - }, + // Redirect the legacy URL to the canonical /:owner/:repo path — + // /agents/:name/:kata used to render a placeholder before the + // GitHub-style routing landed. + "/agents/:name/:kata": (req) => + Response.redirect(`/${req.params.name}/${req.params.kata}`, 301), - "/leaderboard": htmlResponse(LEADERBOARD_HTML), + "/leaderboard": () => renderLeaderboard(), "/api/judge/:owner/:repo": async (req) => { if (req.method !== "POST") {