From 58b56a1ed83a490ce0e69398aa5b177c3b8910ae Mon Sep 17 00:00:00 2001 From: dmitrii-f-t27 Date: Sat, 3 Oct 2026 06:50:50 -0300 Subject: [PATCH] feat(queen): /queen/public-credits - whose lane carried each accepted issue The spec-authors board credits a commit to its GitHub author, and a swarm commit reaches master as the squash of a queen- 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. For each accepted issue: the GitHub login that claimed the lane of its latest accepted turn. A lane is a slot, not a key, so TRIOS_KEY_OWNERS_SINCE (login=YYYY-MM-DD) keeps turns from before a login lent that lane uncredited. --- .../src/api/routes/queen-public-credits.ts | 29 ++++ .../apps/server/src/api/server.ts | 3 + .../src/api/services/queen-lane-credits.ts | 127 ++++++++++++++++++ .../tests/api/queen-lane-credits.test.ts | 71 ++++++++++ .../tests/api/routes/route-guard.test.ts | 17 ++- 5 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 trios/agent-server/apps/server/src/api/routes/queen-public-credits.ts create mode 100644 trios/agent-server/apps/server/src/api/services/queen-lane-credits.ts create mode 100644 trios/agent-server/apps/server/tests/api/queen-lane-credits.test.ts diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-credits.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-credits.ts new file mode 100644 index 0000000000..d872b238fb --- /dev/null +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-credits.ts @@ -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) + } + }) +} diff --git a/trios/agent-server/apps/server/src/api/server.ts b/trios/agent-server/apps/server/src/api/server.ts index 291477cdd1..30a13902ab 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 { 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' @@ -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()) @@ -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()) diff --git a/trios/agent-server/apps/server/src/api/services/queen-lane-credits.ts b/trios/agent-server/apps/server/src/api/services/queen-lane-credits.ts new file mode 100644 index 0000000000..4ee90ede03 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-lane-credits.ts @@ -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-` 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 { + const since: Record = {} + 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, + since: Record, +): LaneCredit[] { + const latest = new Map() + 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 { + 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), + ), + } +} diff --git a/trios/agent-server/apps/server/tests/api/queen-lane-credits.test.ts b/trios/agent-server/apps/server/tests/api/queen-lane-credits.test.ts new file mode 100644 index 0000000000..f00f0d0d1a --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-lane-credits.test.ts @@ -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', + ) + }) +}) 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 e06f356506..7499f80a3c 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 @@ -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', () => { @@ -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/'), ) @@ -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 = { 'public-read': 0, @@ -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,