diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts new file mode 100644 index 0000000000..e73c5b8b83 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * WHAT ACCEPTED SPEC WORK HAS EARNED, AND WHAT WAS TAKEN BACK. + * + * Public on the same terms as the leaderboard, plus what an earning needs to + * be checkable: the repository, issue number, judged commit and declared + * `.t27` paths - all of them already public on GitHub - and the work id, which + * anyone can recompute from those. It carries no issue title, no worker text, + * no review note and no credential: a revocation says only which verdict + * revoked it. + * + * Nothing here is withdrawable and the answer says so in its own body, since a + * number on a page reads as money (queen-tri-earnings.ts). + */ +import { Hono } from 'hono' +import { createQueenPool } from '../../lib/db/queen-pool' +import { logger } from '../../lib/logger' +import { parseOwners } from '../services/queen-leaderboard' +import { + earningByWorkId, + earningsLedger, + earningsOfLogin, +} from '../services/queen-tri-earnings' + +const WORK_ID = /^[0-9a-f]{64}$/ +/** GitHub's own login rule: 1-39 alphanumerics or single hyphens. */ +const GITHUB_LOGIN_PARAM = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/ + +export function createQueenPublicEarningsRoute() { + return ( + new Hono() + .get('/', async (c) => { + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + // One pool per request, closed when the answer is built, so a public + // route that anyone can call cannot accumulate connections. + const pool = createQueenPool(url, { max: 1 }) + try { + const ledger = await earningsLedger(pool) + return c.json(ledger, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earnings could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) + // Every earning credited to one GitHub login: what a wallet lists as + // claimable. A login that is not a GitHub login is refused unqueried. + .get('/by/:github', async (c) => { + const github = c.req.param('github') + if (!GITHUB_LOGIN_PARAM.test(github)) + return c.json({ error: 'not a GitHub login' }, 400) + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + const pool = createQueenPool(url, { max: 1 }) + try { + const found = await earningsOfLogin( + pool, + github, + parseOwners(process.env.TRIOS_KEY_OWNERS), + ) + return c.json(found, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earnings of a login could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) + // One earning and who it is credited to: what a TRI signer reads before + // it signs for this work id. A malformed id is refused before any query. + .get('/:workId', async (c) => { + const workId = c.req.param('workId') + if (!WORK_ID.test(workId)) + return c.json({ error: 'work id must be 64 lowercase hex' }, 400) + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + const pool = createQueenPool(url, { max: 1 }) + try { + const found = await earningByWorkId( + pool, + workId, + parseOwners(process.env.TRIOS_KEY_OWNERS), + ) + if (!found) return c.json({ error: 'No such earning' }, 404) + return c.json(found, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earning could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) + ) +} diff --git a/trios/agent-server/apps/server/src/api/server.ts b/trios/agent-server/apps/server/src/api/server.ts index c591e6373f..291477cdd1 100644 --- a/trios/agent-server/apps/server/src/api/server.ts +++ b/trios/agent-server/apps/server/src/api/server.ts @@ -57,6 +57,7 @@ import { createQueenLeaseRoute } from './routes/queen-lease' import { createQueenNeedsYouRoute } from './routes/queen-needs-you' import { createQueenPublicActivityRoute } from './routes/queen-public-activity' import { createQueenPublicAgentsRoute } from './routes/queen-public-agents' +import { createQueenPublicEarningsRoute } from './routes/queen-public-earnings' import { createQueenPublicHardwareRoute } from './routes/queen-public-hardware' import { createQueenPublicLeaderboardRoute } from './routes/queen-public-leaderboard' import { createQueenPublicResearchRoute } from './routes/queen-public-research' @@ -373,6 +374,7 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/queen/public-research', publicReadCorsMiddleware()) .use('/queen/public-agents', publicReadCorsMiddleware()) .use('/queen/public-leaderboard', publicReadCorsMiddleware()) + .use('/queen/public-earnings', publicReadCorsMiddleware()) .use('/queen/scheduler', publicReadCorsMiddleware()) .use('/*', trustedCorsMiddleware()) // The Inngest server registers and invokes functions here; each request @@ -391,6 +393,9 @@ export async function createHttpServer(config: HttpServerConfig) { .route('/queen/public-board', createQueenPublicBoardRoute()) // Who lent a lane and what it did; no titles, no worker text, no key. .route('/queen/public-leaderboard', createQueenPublicLeaderboardRoute()) + // What accepted spec work earned; recorded, not withdrawable. Repository, + // issue, commit and declared .t27 paths only - no titles, notes or keys. + .route('/queen/public-earnings', createQueenPublicEarningsRoute()) // Separate server-to-server capability; never a public-read or operator-token route. .route('/queen/contributor-keys', createQueenContributorKeysRoute()) .route('/queen/registry', queenRegistryRoutes) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index 2fc10388f8..04202fae4c 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -103,6 +103,7 @@ import { sameModelAs, visiblePatchPaths, } from './queen-reviewer' +import { recordEarnings } from './queen-tri-earnings' /** * The last non-secret allocator cursor already written durably. It survives a @@ -1577,6 +1578,22 @@ export async function runRound( return [] }) + // Write down what accepted spec work has earned, and revoke what a verdict + // just took back - after the review and the CI take-back, so this round's + // verdicts are what it records (queen-tri-earnings.ts). Housekeeping: a + // failure is logged and the round goes on; the next round records the rest. + await recordEarnings(pool, repo) + .then((done) => { + if (done.recorded > 0 || done.revoked > 0) { + logger.info('Queen recorded spec earnings', done) + } + }) + .catch((error) => { + logger.warn('Queen could not record spec earnings', { + error: error instanceof Error ? error.message : String(error), + }) + }) + const reaped = await reapStalledDispatches(pool) if (reaped.length > 0) { logger.info('Queen tick reaped stalled dispatches', { issues: reaped }) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts new file mode 100644 index 0000000000..4f822db055 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts @@ -0,0 +1,371 @@ +/** + * WHAT AN ACCEPTED SPEC HAS EARNED, WRITTEN DOWN ONCE. + * + * The owner, 2026-10-01: the people who write `.t27` specs should mine TRI for + * the ones the Queen accepts, and later move it to a wallet. Nothing can be + * minted from a number nobody wrote down, so this is the first half: an + * append-only record of every accepted spec turn, the commit it was judged on, + * and the lane that carried it. No token exists yet, so nothing here is + * withdrawable, and the public answer says so in words. + * + * WHY A TABLE, WHEN THE LEADERBOARD DERIVES ITS SCORE ON EVERY READ. + * The leaderboard counts turns; an earning is a claim somebody will later sign. + * `queen_dispatch` is keyed by issue and overwritten on redispatch, and the + * archive keeps a snapshot only when an attempt is overwritten - a CI take-back + * edits the row in place. Derived on read, an acceptance that was later taken + * back would simply stop existing, and the record would say it never happened. + * Here it stays, with `revoked_at` beside it. Rows are inserted and revoked, + * never deleted and never edited otherwise. + * + * ONE EARNING = ONE (repository, issue, judged commit). Its id is + * sha256('t27-accept:v1|||'), recomputable by anyone from + * public data, which is what `work_id` in the mint protocol asks for: a hash of + * the accepted work, not a bare counter (trinity-fpga, + * specs/trinet/mint_on_acceptance.t27). The same commit accepted twice is one + * earning; a new commit accepted after a send-back is a second one. + * + * WHAT COUNTS AS A SPEC, HONESTLY: an accepted turn whose declared boundary + * (`owned_paths`) names a `.t27` file - the same rule the leaderboard's `specs` + * uses. That is the claim, not the diff; the spec paths are stored so the + * claim can be checked against the commit. + * + * WHAT REVOKES ONE: a later verdict on the SAME commit that is a send-back or + * an escalation - which is what a CI take-back is (queen-ci-verdict.ts). A + * revocation is final for that earning: a fresh accept of the same commit does + * not resurrect it, because an earning that can flip back and forth is not one + * anybody can sign. + * + * THE AMOUNT is the owner's decision O2 (trinity-fpga + * docs/docs/depin/decisions.md), taken 2026-10-01: 27 TRI per accepted spec in + * epoch 1. It is published here, beside the record, so a signer can refuse an + * attestation whose amount differs from the one anybody can read. + * + * WHAT IS NOT HERE: whether the commit was merged. Decision O4 says an earning + * mints only once its judged commit landed; that is checked on GitHub by each + * signer independently, not asserted by this record, so a Queen that lied about + * a merge would convince nobody. + */ +import type { Pool } from 'pg' + +import { githubLoginOf, parseOwners } from './queen-leaderboard' + +/** The scheme a work id is hashed under; bumped if the inputs ever change. */ +export const EARNING_SCHEME = 't27-accept:v1' + +/** O2, 2026-10-01: TRI one accepted spec earns in epoch 1. */ +export const TRI_PER_SPEC = 27 + +/** + * Record every accepted spec turn not yet recorded, then revoke the ones a + * later verdict on the same commit refused. Idempotent: a second run inserts + * and revokes nothing new. Returns how many of each this run did. + * + * `repo` is the repository the round supervises (TRIOS_GITHUB_REPO). An issue + * number means nothing without it, and the round already refuses to run when + * it is unset, so this never guesses one. + * + * (No backticks in the SQL below: it is a template literal.) + */ +export async function recordEarnings( + pool: Pool, + repo: string, +): Promise<{ recorded: number; revoked: number }> { + const inserted = await pool.query( + `WITH accepted AS ( + SELECT issue, judged_head, key_index, owned_paths, reviewed_at + FROM queen_dispatch + WHERE review_state = 'accept' + AND judged_head IS NOT NULL AND key_index IS NOT NULL + UNION ALL + SELECT issue, + snapshot->>'judged_head', + (snapshot->>'key_index')::integer, + coalesce(snapshot->'owned_paths', '[]'::jsonb), + (snapshot->>'reviewed_at')::timestamptz + FROM queen_dispatch_history + WHERE snapshot->>'review_state' = 'accept' + AND snapshot->>'judged_head' IS NOT NULL + AND snapshot->>'key_index' ~ '^[0-9]+$' + ), + specs AS ( + SELECT a.issue, a.judged_head, a.key_index, a.reviewed_at, + (SELECT coalesce(jsonb_agg(p.path ORDER BY p.path), '[]'::jsonb) + FROM jsonb_array_elements_text(a.owned_paths) AS p(path) + WHERE p.path LIKE '%.t27') AS spec_paths + FROM accepted a + ), + first_accept AS ( + -- One earning per commit: the earliest acceptance of it. + SELECT DISTINCT ON (issue, judged_head) + issue, judged_head, key_index, spec_paths, reviewed_at + FROM specs + WHERE jsonb_array_length(spec_paths) > 0 + ORDER BY issue, judged_head, reviewed_at ASC NULLS LAST + ) + INSERT INTO queen_tri_earnings + (work_id, repo, issue, judged_head, key_index, spec_paths, accepted_at) + SELECT encode(sha256(convert_to( + $2::text || '|' || $1::text || '|' || issue::text || '|' || judged_head, + 'UTF8')), 'hex'), + $1::text, issue, judged_head, key_index, spec_paths, + coalesce(reviewed_at, now()) + FROM first_accept + ON CONFLICT (work_id) DO NOTHING`, + [repo, EARNING_SCHEME], + ) + + // Only the verdict's STATE is kept as the reason. The note is worker text + // and CI log lines, and this table is read by a public route. + const revoked = await pool.query( + `UPDATE queen_tri_earnings e + SET revoked_at = now(), + revoked_reason = 'a later verdict on the same commit: ' || r.state + FROM ( + SELECT issue, judged_head, review_state AS state, reviewed_at + FROM queen_dispatch + WHERE review_state IN ('sendBack', 'escalate') + AND judged_head IS NOT NULL + UNION ALL + SELECT issue, + snapshot->>'judged_head', + snapshot->>'review_state', + (snapshot->>'reviewed_at')::timestamptz + FROM queen_dispatch_history + WHERE snapshot->>'review_state' IN ('sendBack', 'escalate') + AND snapshot->>'judged_head' IS NOT NULL + ) r + WHERE e.revoked_at IS NULL + AND e.repo = $1 + AND r.issue = e.issue + AND r.judged_head = e.judged_head + AND r.reviewed_at > e.accepted_at`, + [repo], + ) + + return { + recorded: inserted.rowCount ?? 0, + revoked: revoked.rowCount ?? 0, + } +} + +export interface Earning { + workId: string + repo: string + issue: number + /** The commit the acceptance was about. */ + commit: string + keyIndex: number + /** The `.t27` files the turn's declared boundary named. */ + specPaths: string[] + acceptedAt: string + revokedAt: string | null + revokedReason: string | null +} + +export interface Earner { + name: string + claimed: boolean + github?: string + keys: number[] + /** Earnings standing. */ + earned: number + /** Earnings a later verdict took back; shown, never hidden. */ + revoked: number +} + +/** + * Gather earnings by lender, the same way the leaderboard gathers lanes: by + * the operator's name for the lane (TRIOS_KEY_OWNERS), or `key #N` when nobody + * claimed it. Pure, so the suite drives it directly. + */ +export function earnersOf( + earnings: Earning[], + owners: Record, +): Earner[] { + const byName = new Map() + for (const earning of earnings) { + const claimed = Object.hasOwn(owners, earning.keyIndex) + const name = claimed ? owners[earning.keyIndex] : `key #${earning.keyIndex}` + const into: Earner = byName.get(name) ?? { + name, + claimed, + ...(claimed ? { github: githubLoginOf(name) } : {}), + keys: [], + earned: 0, + revoked: 0, + } + if (!into.keys.includes(earning.keyIndex)) { + into.keys.push(earning.keyIndex) + into.keys.sort((a, b) => a - b) + } + if (earning.revokedAt) into.revoked += 1 + else into.earned += 1 + byName.set(name, into) + } + return [...byName.values()].sort( + (a, b) => + b.earned - a.earned || + b.revoked - a.revoked || + a.name.localeCompare(b.name), + ) +} + +function toEarning(row: Record): Earning { + return { + workId: String(row.work_id), + repo: String(row.repo), + issue: Number(row.issue), + commit: String(row.judged_head), + keyIndex: Number(row.key_index), + specPaths: Array.isArray(row.spec_paths) ? row.spec_paths.map(String) : [], + acceptedAt: new Date(row.accepted_at as string).toISOString(), + revokedAt: row.revoked_at + ? new Date(row.revoked_at as string).toISOString() + : null, + revokedReason: row.revoked_reason ? String(row.revoked_reason) : null, + } +} + +export async function readEarnings(pool: Pool): Promise { + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + ORDER BY accepted_at DESC, work_id`, + ) + return rows.map(toEarning) +} + +/** How many of the most recent earnings the public answer lists one by one. */ +export const RECENT_EARNINGS = 100 + +export interface EarningsLedger { + measuredAt: string + scheme: string + /** + * In words, because a number on a page reads as money: a mint exists only on + * TON testnet, behind a signer quorum, and is not trustless. + */ + status: typeof EARNINGS_STATUS + /** TRI per accepted spec (O2). */ + triPerSpec: typeof TRI_PER_SPEC + rules: { + counts: string + revokes: string + notYet: string[] + } + totals: { earned: number; revoked: number } + earners: Earner[] + recent: Earning[] +} + +export const EARNINGS_STATUS = + 'recorded; mintable on TON testnet only -- V1, signer quorum, NOT trustless' + +export async function earningsLedger(pool: Pool): Promise { + const all = await readEarnings(pool) + const revoked = all.filter((e) => e.revokedAt).length + return { + measuredAt: new Date().toISOString(), + scheme: EARNING_SCHEME, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, + rules: { + counts: + 'one earning per (repository, issue, judged commit) the Queen accepted, ' + + 'when the turn declared a .t27 file in its boundary', + revokes: + 'a later send-back or escalation of the same commit, such as a CI take-back', + notYet: [ + 'an earning mints only after its judged commit is part of a pull request merged ' + + 'into the default branch that changes a declared .t27 file; each signer checks ' + + 'that on GitHub, this record does not assert it', + 'spec paths are what the turn declared; the merge check above is what ties them to a diff', + 'no mainnet token exists', + ], + }, + totals: { earned: all.length - revoked, revoked }, + earners: earnersOf(all, parseOwners(process.env.TRIOS_KEY_OWNERS)), + recent: all.slice(0, RECENT_EARNINGS), + } +} + +/** + * One earning by its work id, and who it is credited to: what a signer reads + * before it signs an attestation for that work id. Null when no such earning + * was recorded. + */ +export interface EarningLookup { + scheme: string + status: typeof EARNINGS_STATUS + triPerSpec: typeof TRI_PER_SPEC + earning: Earning + earner: Pick +} + +export async function earningByWorkId( + pool: Pool, + workId: string, + owners: Record, +): Promise { + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + WHERE work_id = $1`, + [workId], + ) + if (rows.length === 0) return null + const earning = toEarning(rows[0]) + const [who] = earnersOf([earning], owners) + return { + scheme: EARNING_SCHEME, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, + earning, + earner: { + name: who.name, + claimed: who.claimed, + ...(who.github ? { github: who.github } : {}), + }, + } +} + +/** + * Every earning credited to one GitHub login, newest first: what a wallet + * shows its owner as claimable. A login no key is lent under has none. + */ +export interface EarningsOfLogin { + scheme: string + status: typeof EARNINGS_STATUS + triPerSpec: typeof TRI_PER_SPEC + github: string + earnings: Earning[] +} + +export async function earningsOfLogin( + pool: Pool, + github: string, + owners: Record, +): Promise { + const want = github.toLowerCase() + const keys = Object.entries(owners) + .filter(([, name]) => githubLoginOf(name)?.toLowerCase() === want) + .map(([index]) => Number(index)) + const base: Omit = { + scheme: EARNING_SCHEME, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, + github, + } + if (keys.length === 0) return { ...base, earnings: [] } + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + WHERE key_index = ANY($1::int[]) + ORDER BY accepted_at DESC, work_id`, + [keys], + ) + return { ...base, earnings: rows.map(toEarning) } +} diff --git a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts index c74b3d7425..29ac242525 100644 --- a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts +++ b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts @@ -288,6 +288,30 @@ CREATE TABLE IF NOT EXISTS queen_report ( CREATE INDEX IF NOT EXISTS idx_queen_report_at ON queen_report (at DESC); +-- What an accepted spec has earned, written down once (queen-tri-earnings.ts). +-- +-- Append-only: a row is inserted, and later perhaps revoked, never deleted. +-- queen_dispatch is overwritten in place by a CI take-back, so an earning +-- derived from it on read would vanish instead of showing as taken back. +-- work_id is sha256 of the scheme, repository, issue and judged commit, so +-- anyone can recompute it from public data. No amount column: TRI per spec is +-- not decided, and a column would invite somebody to fill it. +CREATE TABLE IF NOT EXISTS queen_tri_earnings ( + work_id text PRIMARY KEY, + repo text NOT NULL, + issue int NOT NULL, + judged_head text NOT NULL, + key_index int NOT NULL, + spec_paths jsonb NOT NULL DEFAULT '[]'::jsonb, + accepted_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + revoked_reason text +); + +CREATE INDEX IF NOT EXISTS idx_queen_tri_earnings_issue + ON queen_tri_earnings (repo, issue, judged_head); + CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation_order ON "conversationMessages" ("conversationId", "orderIndex"); ` diff --git a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts new file mode 100644 index 0000000000..5860d71671 --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { Pool } from 'pg' + +import { createQueenPublicEarningsRoute } from '../../src/api/routes/queen-public-earnings' +import { + EARNING_SCHEME, + EARNINGS_STATUS, + type Earning, + earnersOf, + earningByWorkId, + earningsLedger, + earningsOfLogin, + recordEarnings, + TRI_PER_SPEC, +} from '../../src/api/services/queen-tri-earnings' + +/** + * The SQL itself is exercised against a real PostgreSQL in + * tests/pglive/queen-tri-earnings-live.test.ts. What is pinned here is what a + * reader can see without a database: the grouping, the parameters each query + * is given, and the words the public answer uses about money. + */ + +const earning = ( + keyIndex: number, + commit: string, + revoked = false, +): Earning => ({ + workId: `id-${keyIndex}-${commit}`, + repo: 'gHashTag/trios', + issue: 1, + commit, + keyIndex, + specPaths: ['specs/a.t27'], + acceptedAt: '2026-10-01T00:00:00.000Z', + revokedAt: revoked ? '2026-10-01T01:00:00.000Z' : null, + revokedReason: revoked + ? 'a later verdict on the same commit: sendBack' + : null, +}) + +describe('who earned', () => { + it('gathers lanes by their lender and counts a revoked earning apart', () => { + const rows = earnersOf( + [ + earning(0, 'a'), + earning(2, 'b'), + earning(0, 'c', true), + earning(5, 'd'), + ], + { 0: '@dmitrii', 2: '@dmitrii' }, + ) + expect(rows).toEqual([ + { + name: '@dmitrii', + claimed: true, + github: 'dmitrii', + keys: [0, 2], + earned: 2, + revoked: 1, + }, + { name: 'key #5', claimed: false, keys: [5], earned: 1, revoked: 0 }, + ]) + }) + + it('ranks by standing earnings, then by revoked, then by name', () => { + const rows = earnersOf( + [ + earning(1, 'a'), + earning(2, 'b'), + earning(2, 'c', true), + earning(3, 'd'), + ], + {}, + ) + expect(rows.map((r) => r.name)).toEqual(['key #2', 'key #1', 'key #3']) + }) + + it('shows a lane whose only earning was taken back, rather than hiding it', () => { + const [row] = earnersOf([earning(4, 'a', true)], {}) + expect(row).toMatchObject({ name: 'key #4', earned: 0, revoked: 1 }) + }) +}) + +describe('what the record asks the database', () => { + const spy = () => { + const seen: { text: string; params: unknown[] }[] = [] + const pool = { + query: (text: string, params: unknown[] = []) => { + seen.push({ text, params }) + return Promise.resolve({ rows: [], rowCount: seen.length }) + }, + } as unknown as Pool + return { pool, seen } + } + + it('inserts under the scheme and the repository, and never overwrites', async () => { + const { pool, seen } = spy() + const done = await recordEarnings(pool, 'gHashTag/trios') + expect(done).toEqual({ recorded: 1, revoked: 2 }) + + const [insert, revoke] = seen + expect(insert.params).toEqual(['gHashTag/trios', EARNING_SCHEME]) + expect(insert.text).toContain('ON CONFLICT (work_id) DO NOTHING') + expect(insert.text).toContain("LIKE '%.t27'") + // Both the live row and the archive, or an overwritten accept is lost. + expect(insert.text).toContain('FROM queen_dispatch\n') + expect(insert.text).toContain('FROM queen_dispatch_history') + + expect(revoke.params).toEqual(['gHashTag/trios']) + // Only a verdict AFTER the acceptance revokes it, and only once. + expect(revoke.text).toContain('r.reviewed_at > e.accepted_at') + expect(revoke.text).toContain('e.revoked_at IS NULL') + // The reason is the verdict's state, never its note. + expect(revoke.text).not.toContain('review_note') + }) + + it('says in words that a mint is testnet-only and not trustless, and publishes the amount', async () => { + const { pool } = spy() + const ledger = await earningsLedger(pool) + expect(ledger.status).toBe(EARNINGS_STATUS) + expect(EARNINGS_STATUS).toContain('testnet only') + expect(EARNINGS_STATUS).toContain('NOT trustless') + // O2, 2026-10-01. A signer refuses any other amount. + expect(ledger.triPerSpec).toBe(27) + expect(TRI_PER_SPEC).toBe(27) + expect(ledger.scheme).toBe(EARNING_SCHEME) + expect(ledger.totals).toEqual({ earned: 0, revoked: 0 }) + // O4: the merge is checked by the signers on GitHub, not asserted here. + expect(ledger.rules.notYet.join(' ')).toContain('merged') + expect(ledger.rules.notYet.join(' ')).toContain('no mainnet token') + }) + + it('looks one earning up by its id and names who it is credited to', async () => { + const seen: unknown[][] = [] + const row = { + work_id: 'a'.repeat(64), + repo: 'gHashTag/t27', + issue: 5429, + judged_head: '7808383a3ca84c8a7ec813ae0869d8f3f7dc6309', + key_index: 6, + spec_paths: ['specs/x.t27'], + accepted_at: '2026-10-01T00:00:00.000Z', + revoked_at: null, + revoked_reason: null, + } + const pool = { + query: (_text: string, params: unknown[] = []) => { + seen.push(params) + return Promise.resolve({ rows: params[0] === row.work_id ? [row] : [] }) + }, + } as unknown as Pool + const found = await earningByWorkId(pool, row.work_id, { 6: '@gHashTag' }) + expect(found).toMatchObject({ + triPerSpec: 27, + earning: { issue: 5429, commit: row.judged_head, revokedAt: null }, + earner: { name: '@gHashTag', claimed: true, github: 'gHashTag' }, + }) + expect(await earningByWorkId(pool, 'b'.repeat(64), {})).toBeNull() + expect(seen).toEqual([[row.work_id], ['b'.repeat(64)]]) + }) + + it('credits an unclaimed lane to nobody on GitHub', async () => { + const pool = { + query: () => + Promise.resolve({ + rows: [ + { + work_id: 'c'.repeat(64), + repo: 'r', + issue: 1, + judged_head: 'h', + key_index: 21, + spec_paths: [], + accepted_at: '2026-10-01T00:00:00.000Z', + }, + ], + }), + } as unknown as Pool + const found = await earningByWorkId(pool, 'c'.repeat(64), {}) + expect(found?.earner).toEqual({ name: 'key #21', claimed: false }) + }) +}) + +describe('the earnings of one GitHub login', () => { + const owners = { 0: '@gHashTag', 1: '@dmitrii-f-t27', 6: '@gHashTag' } + + it('asks only for the keys lent under that login, case-blind', async () => { + const seen: unknown[][] = [] + const pool = { + query: (_text: string, params: unknown[] = []) => { + seen.push(params) + return Promise.resolve({ + rows: [ + { + work_id: 'a'.repeat(64), + repo: 'gHashTag/t27', + issue: 5429, + judged_head: 'h', + key_index: 6, + spec_paths: ['specs/x.t27'], + accepted_at: '2026-10-01T00:00:00.000Z', + }, + ], + }) + }, + } as unknown as Pool + const found = await earningsOfLogin(pool, 'ghashtag', owners) + expect(seen).toEqual([[[0, 6]]]) + expect(found).toMatchObject({ triPerSpec: 27, github: 'ghashtag' }) + expect(found.earnings.map((e) => e.issue)).toEqual([5429]) + }) + + it('answers none, without a query, for a login no key is lent under', async () => { + const pool = { + query: () => { + throw new Error('should not query') + }, + } as unknown as Pool + expect((await earningsOfLogin(pool, 'stranger', owners)).earnings).toEqual( + [], + ) + }) +}) + +describe('the public route', () => { + let saved: string | undefined + beforeEach(() => { + saved = process.env.DATABASE_URL + delete process.env.DATABASE_URL + }) + afterEach(() => { + if (saved === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = saved + }) + + it('answers 503 rather than an empty ledger when there is no database', async () => { + const response = await createQueenPublicEarningsRoute().request('/') + expect(response.status).toBe(503) + // An empty list would read as "nobody has earned anything". + expect(await response.json()).toEqual({ error: 'No database configured' }) + }) + + it('refuses a malformed work id before touching any database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/not-a-work-id') + expect(response.status).toBe(400) + }) + + it('answers 503 for a well-formed id when there is no database', async () => { + const response = await createQueenPublicEarningsRoute().request( + `/${'d'.repeat(64)}`, + ) + expect(response.status).toBe(503) + }) + + it('refuses something that is not a GitHub login before any database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/by/-not-a-login-') + expect(response.status).toBe(400) + }) + + it('answers 503 for a real login when there is no database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/by/gHashTag') + expect(response.status).toBe(503) + }) +}) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index cee95fcc39..e06f356506 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -85,13 +85,17 @@ describe('route-guard audit over src/api/server.ts', () => { // where a route named `public` belongs. Every other number here is // unchanged, which is the part worth stating: no guarded route quietly lost // its guard to make room for it. - // RE-MEASURED 2026-10-02: 45 became 46 with /queen/contributor-keys + // RE-MEASURED 2026-10-01: 45 became 46, the same way. One mount added on + // purpose - `/queen/public-earnings`, an explicit + // `publicReadCorsMiddleware()` on the record of accepted spec work - and + // the audit puts it in `public-read`. Prefix and wrapper counts unchanged. + // RE-MEASURED 2026-10-02: 46 became 47 with /queen/contributor-keys // (#522), a server-to-server route behind its own capability token. It is // allowlisted with that reason; no other number moved. - expect(report.totalMounts).toBe(46) + expect(report.totalMounts).toBe(47) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(15) - expect(report.publicReadCount).toBe(8) + expect(report.publicReadCount).toBe(9) }) it('reports zero unguarded mounts once the reasoned allowlist is applied', () => { @@ -109,7 +113,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-three /queen mounts into 8 public-read, 8 wrapper-guarded and 7 allowlisted', () => { + it('splits the twenty-four /queen mounts into 9 public-read, 8 wrapper-guarded and 7 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -131,11 +135,17 @@ describe('route-guard audit over src/api/server.ts', () => { // issue title, no worker text and no credential: only a key's INDEX ever // reaches the database, so there is nothing here a stranger could read that // the board does not already show. - // RE-MEASURED 2026-10-02: twenty-two became twenty-three. The seventh + // RE-MEASURED 2026-10-01: twenty-two became twenty-three. The ninth + // public-read is /queen/public-earnings - which accepted spec commits were + // recorded as earnings and which a later verdict took back. Like the + // leaderboard it carries no issue title, no worker text, no review note and + // no credential; the repository, issue, commit and declared .t27 paths are + // already public on GitHub. + // RE-MEASURED 2026-10-02: twenty-three became twenty-four. The seventh // allowlisted mount is /queen/contributor-keys (#522), which serves data // only to a caller holding QUEEN_CONTRIBUTOR_PROXY_TOKEN - its own guard, // not the trusted-origin one, because its caller is a server. - expect(queenMounts.length).toBe(23) + expect(queenMounts.length).toBe(24) const counts: Record = { 'public-read': 0, @@ -149,7 +159,7 @@ describe('route-guard audit over src/api/server.ts', () => { // The four buckets must account for every mount with the exact expected // split; anything unaccounted for breaks one of these numbers. expect(counts).toEqual({ - 'public-read': 8, + 'public-read': 9, 'prefix-guard': 0, wrapper: 8, unguarded: 7, diff --git a/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts b/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts new file mode 100644 index 0000000000..9b1782a5ec --- /dev/null +++ b/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The earnings record, run against a real PostgreSQL. + * + * recordEarnings is two statements of SQL over jsonb snapshots, a DISTINCT ON + * and a sha256 - nothing a fake pool can say anything about. So it runs here, + * in the live group, next to the migration gate and for the same reason: other + * test files mock `pg` at module scope, and a group is its own bun process + * (pg-migrate-live.test.ts explains the measurement). + * + * Like that gate, this FAILS when no server is reachable, unless + * TRIOS_PG_MIGRATE_GATE=offline asks for a printed skip. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { createHash, randomBytes } from 'node:crypto' +import { userInfo } from 'node:os' +import { Pool } from 'pg' +import { + EARNING_SCHEME, + earningsLedger, + readEarnings, + recordEarnings, +} from '../../src/api/services/queen-tri-earnings' +import { runPgMigrations } from '../../src/lib/db/pg-migrate' +import { createQueenPool } from '../../src/lib/db/queen-pool' + +const REPO = 'gHashTag/trios' + +function adminUrl(): string { + return ( + process.env.TRIOS_PG_TEST_URL ?? + `postgres://${userInfo().username}@127.0.0.1:5432/postgres` + ) +} + +function isLocal(url: string): boolean { + try { + const host = new URL(url).hostname + return host === '127.0.0.1' || host === 'localhost' || host === '::1' + } catch { + return false + } +} + +const offline = + (process.env.TRIOS_PG_MIGRATE_GATE ?? '').toLowerCase() === 'offline' + +/** What anybody outside can compute from the repository, issue and commit. */ +const workIdOf = (issue: number, commit: string) => + createHash('sha256') + .update(`${EARNING_SCHEME}|${REPO}|${issue}|${commit}`, 'utf8') + .digest('hex') + +const at = (minutes: number) => + new Date(Date.UTC(2026, 9, 1, 12, minutes)).toISOString() + +let admin: Pool | undefined +let pool: Pool | undefined +let scratchName = '' +let skipped = false + +beforeAll(async () => { + const url = adminUrl() + if (!isLocal(url) && process.env.TRIOS_PG_TEST_ALLOW_REMOTE !== '1') { + throw new Error( + 'TRIOS_PG_TEST_URL is not local; this test creates and drops a database.', + ) + } + admin = new Pool({ + connectionString: url, + max: 1, + connectionTimeoutMillis: 4000, + }) + try { + await admin.query('SELECT 1') + } catch (error) { + await admin.end().catch(() => {}) + admin = undefined + if (offline) { + skipped = true + console.error( + '\n THE EARNINGS LIVE TEST COULD NOT REACH A POSTGRESQL; TRIOS_PG_MIGRATE_GATE=offline, so it is a SKIP.\n', + ) + return + } + throw error + } + + scratchName = `trios_earn_${process.pid}_${randomBytes(4).toString('hex')}` + await admin.query(`CREATE DATABASE ${scratchName}`) + const scratch = new URL(url) + scratch.pathname = `/${scratchName}` + + const saved = process.env.DATABASE_URL + const savedRailway = process.env.RAILWAY_SSOT_URL + process.env.DATABASE_URL = scratch.toString() + delete process.env.RAILWAY_SSOT_URL + try { + await runPgMigrations() + } finally { + if (saved === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = saved + if (savedRailway !== undefined) process.env.RAILWAY_SSOT_URL = savedRailway + } + + pool = createQueenPool(scratch.toString(), { max: 1 }) + // judged_head is added by the round's own boot (queen-tick + // ensureQueenColumns), not by MIGRATION_SQL, so a scratch database built + // from the migration alone does not have it. + await pool.query( + 'ALTER TABLE queen_dispatch ADD COLUMN IF NOT EXISTS judged_head text', + ) + // The whole migration block runs here, which takes longer than bun's + // default five-second hook budget. +}, 60_000) + +afterAll(async () => { + await pool?.end().catch(() => {}) + if (admin && scratchName) { + await admin + .query(`DROP DATABASE IF EXISTS ${scratchName} WITH (FORCE)`) + .catch(() => {}) + } + await admin?.end().catch(() => {}) +}) + +async function dispatch( + issue: number, + state: string, + head: string, + key: number, + paths: string[], + reviewedAt: string, +) { + await pool!.query( + `INSERT INTO queen_dispatch + (issue, branch, started, detail, owned_paths, key_index, + review_state, reviewed_at, judged_head) + VALUES ($1, 'b', true, 'd', $2::jsonb, $3, $4, $5, $6) + ON CONFLICT (issue) DO UPDATE + SET owned_paths = EXCLUDED.owned_paths, key_index = EXCLUDED.key_index, + review_state = EXCLUDED.review_state, + reviewed_at = EXCLUDED.reviewed_at, + judged_head = EXCLUDED.judged_head`, + [issue, JSON.stringify(paths), key, state, reviewedAt, head], + ) +} + +async function archive( + issue: number, + state: string, + head: string, + key: number, + paths: string[], + reviewedAt: string, +) { + await pool!.query( + 'INSERT INTO queen_dispatch_history (issue, snapshot) VALUES ($1, $2::jsonb)', + [ + issue, + JSON.stringify({ + review_state: state, + judged_head: head, + key_index: key, + owned_paths: paths, + reviewed_at: reviewedAt, + }), + ], + ) +} + +describe('the earnings record, on a real PostgreSQL', () => { + it('records each accepted spec commit once, revokes on take-back, and never forgets', async () => { + if (skipped) return + + // Issue 10: accepted on h1 twice - once in an archived attempt, once live. + // One commit, one earning, dated by the FIRST acceptance. + await archive(10, 'accept', 'h1', 0, ['specs/a.t27'], at(1)) + await dispatch(10, 'accept', 'h1', 0, ['specs/a.t27', 'src/x.ts'], at(5)) + // Issue 11: accepted, but its boundary names no .t27 file. + await dispatch(11, 'accept', 'h2', 1, ['src/only.ts'], at(5)) + // Issue 12: accepted only in the archive. A send-back of the same commit + // that came BEFORE the acceptance does not revoke it. + await archive(12, 'sendBack', 'h3', 2, ['b.t27'], at(0)) + await archive(12, 'accept', 'h3', 2, ['b.t27'], at(2)) + // Issue 13: still waiting; nothing to record. + await dispatch(13, 'wait', 'h9', 3, ['c.t27'], at(5)) + + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 2, + revoked: 0, + }) + // Idempotent: the round calls this every tick. + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 0, + }) + + let rows = await readEarnings(pool!) + const byIssue = (issue: number) => rows.filter((r) => r.issue === issue) + expect(rows.map((r) => r.issue).sort()).toEqual([10, 12]) + const [ten] = byIssue(10) + expect(ten.workId).toBe(workIdOf(10, 'h1')) + expect(ten.commit).toBe('h1') + expect(ten.acceptedAt).toBe(at(1)) + expect(ten.revokedAt).toBeNull() + expect(byIssue(12)[0].specPaths).toEqual(['b.t27']) + + // A CI take-back edits the live row in place: same commit, now refused. + await dispatch(10, 'sendBack', 'h1', 0, ['specs/a.t27'], at(10)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 1, + }) + rows = await readEarnings(pool!) + expect(byIssue(10)[0].revokedReason).toBe( + 'a later verdict on the same commit: sendBack', + ) + // The earning is still there, beside its revocation. + expect(rows).toHaveLength(2) + + // Accepting the same commit again does not resurrect it. + await dispatch(10, 'accept', 'h1', 0, ['specs/a.t27'], at(15)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 0, + }) + expect( + (await readEarnings(pool!)).find((r) => r.issue === 10)?.revokedAt, + ).not.toBeNull() + + // A NEW commit accepted after the send-back is a new earning. + await archive(10, 'accept', 'h1', 0, ['specs/a.t27'], at(15)) + await dispatch(10, 'accept', 'h4', 0, ['specs/a.t27'], at(20)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 1, + revoked: 0, + }) + + const saved = process.env.TRIOS_KEY_OWNERS + process.env.TRIOS_KEY_OWNERS = '0=@dmitrii' + try { + const ledger = await earningsLedger(pool!) + expect(ledger.totals).toEqual({ earned: 2, revoked: 1 }) + expect(ledger.earners).toEqual([ + { + name: '@dmitrii', + claimed: true, + github: 'dmitrii', + keys: [0], + earned: 1, + revoked: 1, + }, + { name: 'key #2', claimed: false, keys: [2], earned: 1, revoked: 0 }, + ]) + // Newest first. + expect(ledger.recent[0].commit).toBe('h4') + } finally { + if (saved === undefined) delete process.env.TRIOS_KEY_OWNERS + else process.env.TRIOS_KEY_OWNERS = saved + } + }) +})