Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* GET /queen/public-credits - for each accepted issue, the GitHub login of the
* person whose lane carried it (queen-lane-credits.ts). Read by the
* spec-authors board in gHashTag/trinity.
*/
import { Hono } from 'hono'
import { createQueenPool } from '../../lib/db/queen-pool'
import { logger } from '../../lib/logger'
import { laneCredits } from '../services/queen-lane-credits'

export function createQueenPublicCreditsRoute() {
return new Hono().get('/', async (c) => {
const url = process.env.DATABASE_URL
if (!url) return c.json({ error: 'No database configured' }, 503)
try {
const body = await laneCredits(createQueenPool(url))
return c.json(body, 200, { 'Cache-Control': 'public, max-age=300' })
} catch (error) {
logger.warn('Queen lane credits could not be read', {
error: error instanceof Error ? error.message : String(error),
})
return c.json({ error: 'The lane credits are unavailable' }, 503)
}
})
}
3 changes: 3 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 { createQueenPublicCreditsRoute } from './routes/queen-public-credits'
import { createQueenPublicEarningsRoute } from './routes/queen-public-earnings'
import { createQueenPublicHardwareRoute } from './routes/queen-public-hardware'
import { createQueenPublicLeaderboardRoute } from './routes/queen-public-leaderboard'
Expand Down Expand Up @@ -374,6 +375,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-credits', publicReadCorsMiddleware())
.use('/queen/public-earnings', publicReadCorsMiddleware())
.use('/queen/scheduler', publicReadCorsMiddleware())
.use('/*', trustedCorsMiddleware())
Expand All @@ -393,6 +395,7 @@ 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())
.route('/queen/public-credits', createQueenPublicCreditsRoute())
// 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())
Expand Down
127 changes: 127 additions & 0 deletions trios/agent-server/apps/server/src/api/services/queen-lane-credits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* WHOSE LANE CARRIED EACH ACCEPTED ISSUE.
*
* The spec-authors board (gHashTag/trinity, apps/website/scripts/spec-authors.mjs)
* credits a commit to its GitHub author. A swarm commit reaches master as the
* squash of a `queen-<issue>` pull request, authored by whoever merged it, so
* every bee's work read as the merger's own. The owner, 2026-10-03: credit it to
* the person whose provider key the bee ran on. This is the record that makes
* that possible: for each accepted issue, the lane that did the accepted turn
* and the GitHub login of the person who claimed that lane.
*
* Public on the same terms as the leaderboard: issue numbers and logins that
* board already shows, no titles, no worker text, no credential.
*
* A LANE IS A SLOT, NOT A KEY. Index N named a different credential before its
* present owner lent theirs (until 2026-09-17 the pool ran on the operator's
* plan). TRIOS_KEY_OWNERS_SINCE (`login=YYYY-MM-DD,...`) says from when a
* login's lanes were theirs; a turn dispatched earlier is credited to nobody
* here, and the board falls back to its old rule for it.
*/
import type { Pool } from 'pg'
import { contributorOwnerNames } from './queen-contributor-keys'
import { githubLoginOf, parseOwners } from './queen-leaderboard'

export interface LaneCredit {
issue: number
github: string
/** When the accepted turn was dispatched, ISO. */
at: string
}

export interface AcceptedTurn {
issue: number
keyIndex: number
dispatchedAt: Date
}

/** `dmitrii-f-t27=2026-09-17` -> { 'dmitrii-f-t27': Date }. Bad entries are skipped. */
export function parseOwnersSince(
raw: string | undefined,
): Record<string, Date> {
const since: Record<string, Date> = {}
for (const entry of (raw ?? '').split(',')) {
const at = entry.indexOf('=')
if (at <= 0) continue
const login = entry.slice(0, at).trim().replace(/^@/, '').toLowerCase()
const day = entry.slice(at + 1).trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue
const date = new Date(`${day}T00:00:00Z`)
if (login && !Number.isNaN(date.getTime())) since[login] = date
}
return since
}

/**
* Pure: one credit per issue, from its latest accepted turn, only when that
* lane is claimed by a GitHub login and the turn is not older than the login's
* start date. An issue accepted on an unclaimed lane is left out, not guessed.
*/
export function creditIssues(
turns: AcceptedTurn[],
owners: Record<number, string>,
since: Record<string, Date>,
): LaneCredit[] {
const latest = new Map<number, AcceptedTurn>()
for (const turn of turns) {
const seen = latest.get(turn.issue)
if (!seen || turn.dispatchedAt > seen.dispatchedAt)
latest.set(turn.issue, turn)
}
const credits: LaneCredit[] = []
for (const turn of latest.values()) {
const name = owners[turn.keyIndex]
const github = name ? githubLoginOf(name) : undefined
if (!github) continue
const from = since[github.toLowerCase()]
if (from && turn.dispatchedAt < from) continue
credits.push({
issue: turn.issue,
github,
at: turn.dispatchedAt.toISOString(),
})
}
return credits.sort((a, b) => a.issue - b.issue)
}

export async function acceptedTurns(pool: Pool): Promise<AcceptedTurn[]> {
const { rows } = await pool.query(
`SELECT issue, key_index, dispatched_at
FROM queen_dispatch
WHERE review_state = 'accept' AND key_index IS NOT NULL
UNION ALL
SELECT issue, (snapshot->>'key_index')::integer,
(snapshot->>'dispatched_at')::timestamptz
FROM queen_dispatch_history
WHERE snapshot->>'review_state' = 'accept'
AND snapshot->>'key_index' ~ '^-?[0-9]+$'
AND snapshot->>'dispatched_at' IS NOT NULL`,
)
return rows.map((row) => ({
issue: Number(row.issue),
keyIndex: Number(row.key_index),
dispatchedAt: new Date(row.dispatched_at),
}))
}

export async function laneCredits(
pool: Pool,
): Promise<{ measuredAt: string; repo: string; credits: LaneCredit[] }> {
const owners = {
...parseOwners(process.env.TRIOS_KEY_OWNERS),
...(await contributorOwnerNames(pool)),
}
return {
measuredAt: new Date().toISOString(),
repo: 'gHashTag/t27',
credits: creditIssues(
await acceptedTurns(pool),
owners,
parseOwnersSince(process.env.TRIOS_KEY_OWNERS_SINCE),
),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'bun:test'
import {
type AcceptedTurn,
creditIssues,
parseOwnersSince,
} from '../../src/api/services/queen-lane-credits'

const turn = (issue: number, keyIndex: number, day: string): AcceptedTurn => ({
issue,
keyIndex,
dispatchedAt: new Date(`${day}T12:00:00Z`),
})

describe('whose lane carried an accepted issue', () => {
const owners = {
0: '@dmitrii-f-t27',
1: '@dmitrii-f-t27',
2: 'Trinity community',
10000: '@alex',
}

it('credits the GitHub login that claimed the lane of the latest accepted turn', () => {
expect(
creditIssues(
[
turn(5, 2, '2026-09-20'),
turn(5, 0, '2026-09-25'),
turn(7, 10000, '2026-09-21'),
],
owners,
{},
),
).toEqual([
{ issue: 5, github: 'dmitrii-f-t27', at: '2026-09-25T12:00:00.000Z' },
{ issue: 7, github: 'alex', at: '2026-09-21T12:00:00.000Z' },
])
})

it('leaves out a lane nobody claimed and a name that is not a login', () => {
expect(
creditIssues(
[turn(1, 9, '2026-09-20'), turn(2, 2, '2026-09-20')],
owners,
{},
),
).toEqual([])
})

it('does not credit a turn from before the login lent that lane', () => {
const since = parseOwnersSince('dmitrii-f-t27=2026-09-17')
expect(
creditIssues(
[turn(1, 0, '2026-09-16'), turn(2, 1, '2026-09-17')],
owners,
since,
),
).toEqual([
{ issue: 2, github: 'dmitrii-f-t27', at: '2026-09-17T12:00:00.000Z' },
])
})

it('reads start dates by login and skips what it cannot read', () => {
const since = parseOwnersSince(
' @Dmitrii-F-T27=2026-09-17, alex=soon, =2026-01-01, bob=2026-10-01',
)
expect(Object.keys(since).sort()).toEqual(['bob', 'dmitrii-f-t27'])
expect(since['dmitrii-f-t27'].toISOString()).toBe(
'2026-09-17T00:00:00.000Z',
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ describe('route-guard audit over src/api/server.ts', () => {
// 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(47)
// RE-MEASURED 2026-10-03: 47 became 48 with /queen/public-credits, an
// explicit publicReadCorsMiddleware() on which login's lane carried each
// accepted issue (issue numbers and logins the leaderboard already shows).
expect(report.totalMounts).toBe(48)
expect(report.prefixGuardCount).toBe(18)
expect(report.guardedSubAppCount).toBe(15)
expect(report.publicReadCount).toBe(9)
expect(report.publicReadCount).toBe(10)
})

it('reports zero unguarded mounts once the reasoned allowlist is applied', () => {
Expand All @@ -113,7 +116,7 @@ describe('route-guard audit over src/api/server.ts', () => {
)
})

it('splits the twenty-four /queen mounts into 9 public-read, 8 wrapper-guarded and 7 allowlisted', () => {
it('splits the twenty-five /queen mounts into 10 public-read, 8 wrapper-guarded and 7 allowlisted', () => {
const queenMounts = classifyMounts(source).filter(
(mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'),
)
Expand Down Expand Up @@ -145,7 +148,11 @@ describe('route-guard audit over src/api/server.ts', () => {
// 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(24)
// RE-MEASURED 2026-10-03: twenty-four became twenty-five. The tenth
// public-read is /queen/public-credits - for each accepted issue, the
// GitHub login that claimed the lane it ran on. No title, no worker text,
// no credential.
expect(queenMounts.length).toBe(25)

const counts: Record<string, number> = {
'public-read': 0,
Expand All @@ -159,7 +166,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': 9,
'public-read': 10,
'prefix-guard': 0,
wrapper: 8,
unguarded: 7,
Expand Down
Loading