Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts
Original file line number Diff line number Diff line change
@@ -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(() => {})
}
})
)
}
5 changes: 5 additions & 0 deletions trios/agent-server/apps/server/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions trios/agent-server/apps/server/src/api/services/queen-tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
Loading
Loading