diff --git a/app/admin/operations/operations.css b/app/admin/operations/operations.css index 1d6568d9..04175b99 100644 --- a/app/admin/operations/operations.css +++ b/app/admin/operations/operations.css @@ -14,3 +14,17 @@ .admin-operations-grid dt { color: #b7bbaf; font-size: 0.9rem; } .admin-operations-grid dd { margin: 0.35rem 0 0; font-size: 1.2rem; overflow-wrap: anywhere; } + +.admin-stalled-table { + width: 100%; + margin: 0.75rem 0; + border-collapse: collapse; +} + +.admin-stalled-table caption { text-align: left; color: #b7bbaf; font-size: 0.9rem; padding-bottom: 0.35rem; } +.admin-stalled-table th, .admin-stalled-table td { + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + text-align: left; +} +.admin-stalled-table thead th { color: #b7bbaf; font-size: 0.9rem; } diff --git a/app/admin/operations/page.tsx b/app/admin/operations/page.tsx index 79b2ee66..27506823 100644 --- a/app/admin/operations/page.tsx +++ b/app/admin/operations/page.tsx @@ -3,13 +3,15 @@ import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { ClientRequestError, requestJson } from '@/lib/client-request'; -import type { AdminIntegrity, AdminOperations } from '@/db/admin-operations'; +import type { AdminIntegrity, AdminOperations, AdminStalledDecision } from '@/db/admin-operations'; import '../admin.css'; import './operations.css'; type Account = { id: string; role: 'owner' | 'operator' | 'viewer' }; type Snapshot = AdminOperations & { revision: string }; +type Stalled = { observedAt: number; stallMs: number; stalled: AdminStalledDecision[] }; const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.'; +const idle = (idleMs: number) => `${Math.floor(idleMs / 3_600_000)}h ${Math.floor(idleMs % 3_600_000 / 60_000)}m`; export default function OperationsPage() { const [snapshot, setSnapshot] = useState(null); @@ -20,6 +22,10 @@ export default function OperationsPage() { const [integrityNotice, setIntegrityNotice] = useState(''); const [checking, setChecking] = useState(false); const integrityEpoch = useRef(0); + const [stalled, setStalled] = useState(null); + const [stalledNotice, setStalledNotice] = useState(''); + const [loadingStalled, setLoadingStalled] = useState(false); + const stalledEpoch = useRef(0); useEffect(() => { let canceled = false; const epoch = integrityEpoch; @@ -62,11 +68,34 @@ export default function OperationsPage() { } } + async function checkStalled() { + const epoch = ++stalledEpoch.current; + setLoadingStalled(true); + setStalled(null); + setStalledNotice(''); + try { + const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }); + if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.'); + const result = await requestJson('/api/admin/operations?stalled=1', { + cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id }, + }); + if (epoch === stalledEpoch.current) setStalled(result); + } catch (error) { + if (epoch === stalledEpoch.current) + setStalledNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0) + ? 'Owner access changed or expired. Sign in to administration again.' : message(error)); + } finally { + if (epoch === stalledEpoch.current) setLoadingStalled(false); + } + } + return
Administration

Operations

Owner-only, read-only counts from the current database and the revision baked into this server build. Refresh to take a new sample; no monitoring or automatic repair runs here.

@@ -86,8 +115,30 @@ export default function OperationsPage() {
Room backup snapshots
{snapshot.backupSnapshots}
Backup snapshot payload bytes
{snapshot.backupBytes.toLocaleString()}
Recorded private exports
{snapshot.backupDownloads}
+
Rooms waiting on a stalled interaction
{snapshot.stalledRooms}
+
Oldest stalled room write
{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}
-

These counters do not check SQLite integrity, disk free space, backup recoverability, stalled decisions or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.

+

These counters do not check SQLite integrity, disk free space, backup recoverability or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.

+ +

Read-only sample of up to ten live rooms whose public pending interaction has been idle longest. It excludes paused, closed, removed and archived rooms and shows no private game contents.

+ {loadingStalled && Sampling stalled rooms…} + {stalledNotice &&

{stalledNotice}

} + {stalled &&
+

Sampled {new Date(stalled.observedAt).toLocaleString()}; idle means no room write for {Math.round(stalled.stallMs / 3_600_000)} hours.

+ {stalled.stalled.length === 0 ?

No live room is waiting on a stalled interaction.

+ : + + + {stalled.stalled.map(room => + + + + + + )} +
Stalled rooms, longest idle first
RoomWaiting onTurnPhaseIdle
{room.code}{room.kind}{room.turn ?? '—'}{room.phase ?? '—'}{idle(room.idleMs)}
} +

This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.

+
}

This manual, read-only SQLite quick check and foreign-key check may take time. It reports pass/fail only: not disk capacity, backup recoverability or game-rule validity.

{checking && Checking database structure and references…} diff --git a/app/api/admin/operations/route.ts b/app/api/admin/operations/route.ts index 7ffbf566..211f31e6 100644 --- a/app/api/admin/operations/route.ts +++ b/app/api/admin/operations/route.ts @@ -1,6 +1,6 @@ import { env } from 'cloudflare:workers'; import { AdminError, requireAdmin } from '@/db/admin-access'; -import { readAdminIntegrity, readAdminOperations } from '@/db/admin-operations'; +import { ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '@/db/admin-operations'; import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; import { buildRevision } from '@/lib/build-revision'; @@ -11,10 +11,14 @@ export async function GET(request: Request) { if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload operations.', 401); const query = new URL(request.url).searchParams; - if (query.size && (query.size !== 1 || query.get('integrity') !== '1')) + const reads = ['integrity', 'stalled']; + if (query.size && (query.size !== 1 || !reads.some(read => query.get(read) === '1'))) throw new AdminError('Invalid operations request.', 400); - if (query.has('integrity')) + if (query.get('integrity') === '1') return adminResponse(await readAdminIntegrity(env.DB, identity)); + if (query.get('stalled') === '1') + return adminResponse({ revision: buildRevision, observedAt: Date.now(), + stallMs: ADMIN_STALL_MS, stalled: await readAdminStalledDecisions(env.DB, identity) }); return adminResponse({ revision: buildRevision, ...await readAdminOperations(env.DB, identity) }); } catch (error) { return adminFailure(error); } } diff --git a/db/admin-operations.ts b/db/admin-operations.ts index 9ae02457..e9304e46 100644 --- a/db/admin-operations.ts +++ b/db/admin-operations.ts @@ -13,6 +13,8 @@ export type AdminOperations = { backupSnapshots: number; backupBytes: number; backupDownloads: number; + stalledRooms: number; + oldestStalledChange: number | null; }; export type AdminIntegrity = { @@ -21,10 +23,39 @@ export type AdminIntegrity = { foreignKeys: 'passed' | 'failed'; }; +/** A room whose public pending interaction has been idle for this long. */ +export const ADMIN_STALL_MS = 6 * 60 * 60 * 1000; +/** Bounded diagnosis sample; the page never lists every stalled room. */ +export const ADMIN_STALL_LIMIT = 10; + +/** Public coordination facts only: the room code, the pending interaction kind and its age. */ +export type AdminStalledDecision = { + code: string; + idleMs: number; + kind: string; + turn: number | null; + phase: number | null; +}; + const ownerAuthority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner' AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation`; +/** A readable, started, live room that is waiting on a public interaction. */ +const stalledRoom = `json_valid(r.state)=1 + AND COALESCE(m.removed,0)=0 AND COALESCE(c.closed,0)=0 AND COALESCE(a.archived,0)=0 + AND COALESCE(k.paused,0)=0 + AND json_extract(r.state,'$.status')='playing' + AND (json_extract(r.state,'$.decision') IS NOT NULL + OR json_extract(r.state,'$.response') IS NOT NULL + OR json_extract(r.state,'$.truthtrance') IS NOT NULL + OR json_extract(r.state,'$.phaseOpening') IS NOT NULL)`; +const stalledJoins = `FROM rooms r + LEFT JOIN room_removals m ON m.room_code=r.code + LEFT JOIN room_closures c ON c.room_code=r.code + LEFT JOIN room_archives a ON a.room_code=r.code + LEFT JOIN room_controls k ON k.room_code=r.code`; + type Counts = Omit; /** Aggregate only operational counters, never serialized game or private backup rows. */ @@ -40,13 +71,36 @@ export async function readAdminOperations(database: D1Database, identity: AdminI (SELECT MAX(updated_at) FROM rooms) lastRoomChange, (SELECT COUNT(*) FROM admin_room_backups) backupSnapshots, (SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) backupBytes, - (SELECT COUNT(*) FROM admin_room_backup_downloads) backupDownloads + (SELECT COUNT(*) FROM admin_room_backup_downloads) backupDownloads, + (SELECT COUNT(*) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at(); + .bind(now - ADMIN_STALL_MS, now - ADMIN_STALL_MS, identity.sessionHash, identity.id, now).first(); if (!row) throw new AdminError('Owner sign-in required.', 403); return { observedAt: now, ...row }; } +/** Bounded, read-only stalled-decision sample for the owner operations page. */ +export async function readAdminStalledDecisions(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise { + const result = await database.prepare(`SELECT r.code code, r.updated_at updatedAt, + COALESCE(json_extract(r.state,'$.decision.kind'), json_extract(r.state,'$.response.kind'), + CASE WHEN json_extract(r.state,'$.truthtrance') IS NOT NULL THEN 'truthtrance' + WHEN json_extract(r.state,'$.phaseOpening') IS NOT NULL THEN 'phaseOpening' END) kind, + json_extract(r.state,'$.turn') turn, json_extract(r.state,'$.phase') phase + ${stalledJoins} + WHERE (EXISTS (${ownerAuthority})) AND ${stalledRoom} AND r.updated_at(); + if (!result.results.length) { + const authority = await database.prepare(ownerAuthority) + .bind(identity.sessionHash, identity.id, now).first(); + if (!authority) throw new AdminError('Owner sign-in required.', 403); + } + return result.results.map(row => ({ code: row.code, idleMs: now - row.updatedAt, + kind: row.kind, turn: row.turn, phase: row.phase })); +} + /** Explicit owner request; report only pass/fail, never SQLite diagnostics or row IDs. */ export async function readAdminIntegrity(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise { const results = await database.batch([ diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index cf74e782..63906543 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -15,7 +15,7 @@ recovery and persistence systems where suitable. | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | | Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial | -| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. Failed-attempt records, disk capacity, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial | +| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds a bounded, read-only sample of live rooms whose public pending interaction has been idle longest, with aggregate counters. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. Failed-attempt records, disk capacity and maintenance controls remain. | Prototyped, partial | Track each area through Missing, Prototyped, Integrated, Verified and Polished. As implementation begins, link its controls, server actions, persistence, @@ -82,7 +82,16 @@ owner authority inside the query batch and returns only pass/fail and sample time, never a table name, row ID, private save or SQLite diagnostic text. The check can take time; a failed check calls for a protected snapshot and private diagnosis, not automatic repair. It does not prove disk capacity, -backup recoverability, valid game rules or the absence of stalled decisions. +backup recoverability or valid game rules. + +[Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds **List stalled +decisions**: a bounded, read-only sample of at most ten live rooms whose public +pending interaction — a decision, response, Truthtrance or phase opening — has +been idle longest, with the room code, idle duration, interaction kind, turn +and phase. Paused, closed, removed and archived rooms are excluded, no private +game content is selected, and the sample repairs nothing; the aggregate sample +also reports the stalled count and the oldest stalled write. A stall is inferred +from the room write time, so a slow human turn counts too. The directory uses an explicit field allowlist, independent of any player view. It never returns hands, Traitors, predictions, plans, private messages, recovery diff --git a/docs/ADMIN_STALLED_DECISIONS.md b/docs/ADMIN_STALLED_DECISIONS.md new file mode 100644 index 00000000..828dee00 --- /dev/null +++ b/docs/ADMIN_STALLED_DECISIONS.md @@ -0,0 +1,48 @@ +# Stalled-decision diagnosis + +Added 7 October 2026 as owner-only, read-only administration scope. It samples +rooms that are waiting on a public interaction and have not been written for a +while, so an operator can find a table that stopped moving without opening +anyone's private state. It changes nothing and repairs nothing. + +## Contract + +`GET /api/admin/operations?stalled=1` requires a live owner session, checks the +`X-Dune-Admin-Id` header against the session, and answers `no-store`. Any other +query combination is rejected with `400`; an anonymous or non-owner request is +rejected with `401`/`403` before any room is read. The same request without a +query returns the aggregate sample, and `?integrity=1` keeps its manual SQLite +check. + +A room is included only when **all** of these hold: + +- its saved state is readable JSON (`json_valid(state)=1`); +- `status` is `playing`; +- it has a pending public interaction: `decision`, `response`, `truthtrance` + or `phaseOpening`; +- it is not paused, closed, removed or archived; +- its last room write is older than `ADMIN_STALL_MS` (six hours). + +The sample returns at most `ADMIN_STALL_LIMIT` (ten) rooms, oldest first, with +the room code, the idle duration, the pending interaction kind and the saved +turn and phase. The aggregate operations sample adds `stalledRooms` and +`oldestStalledChange` for the same predicate. + +## Privacy and safety + +Only public coordination facts leave the database: a room code is an +invitation, and a pending interaction kind, turn and phase are already visible +to the players at that table. Hands, spice, orders, private decisions, seat +credentials and recovery material are never selected, so the sample cannot +leak them. The route is read-only: it runs no repair, no automatic close, no +retry and no migration. Pausing, resuming, closing, removing or archiving a +room still happens only through its own audited control. + +## Limits + +A stall is inferred from the room write time, so a genuinely slow human turn, a +long offline pause that was never flagged, or an idle-but-live table all count. +The sample does not diagnose *why* a decision is stuck, does not check disk +capacity or backup recoverability, and does not verify rule-level save validity. +It complements, and does not replace, the manual integrity check and the +[action history](ADMIN_ACTION_HISTORY.md). diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2177b0c6..38d9a32c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -50,6 +50,16 @@ settles, without replaying payment or force transfer. A Terror entry already in flight and every Ambassador combination still reject. Selected decks/cache, shared-Duke/capture, force/payment and pending-effect guards remain. +**In progress — administrator operations.** The owner-only operations page now +also diagnoses [stalled decisions](ADMIN_STALLED_DECISIONS.md): a bounded, +read-only sample of at most ten live rooms whose public pending interaction — a +decision, response, Truthtrance or phase opening — has been idle longest, plus +`stalledRooms` and `oldestStalledChange` counters in the aggregate sample. +Paused, closed, removed and archived rooms are excluded and no private game +content is selected; `GET /api/admin/operations?stalled=1` stays owner-only, +`no-store` and read-only, and it repairs nothing. Failed-attempt records, disk +capacity and maintenance controls remain. + **Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.** Original both-family Ix+CHOAM/union47 or both Ecaz+Moritani/ecaz33/classic diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b759991f..716884df 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 7 October 2026 — administrator stalled-decision diagnosis + +The owner-only operations page gains **List stalled decisions**: a bounded, +read-only sample of at most ten live rooms whose public pending interaction — +`decision`, `response`, `truthtrance` or `phaseOpening` — has been idle longest, +reported as room code, idle duration, interaction kind, turn and phase. The +aggregate sample adds `stalledRooms` and `oldestStalledChange` for the same +predicate. `GET /api/admin/operations?stalled=1` repeats live owner authority, +answers `no-store`, rejects any other query combination with `400` and stays +read-only: it repairs nothing and pause/close/remove/archive keep their own +audited controls. + +Paused, closed, removed and archived rooms are excluded, as are unreadable JSON +states and non-playing rooms. Only public coordination facts are selected — no +hands, orders, private decisions, seat credentials or raw saved state — and the +sample is bounded to ten rows, oldest first. A stall is inferred from the room +write time, so a slow human turn also counts; the sample does not diagnose why a +decision is stuck, disk capacity or backup recoverability. + +Owner-only unit coverage asserts the counters, the oldest stalled write, the +bounded oldest-first order, every exclusion boundary, idle arithmetic, the +absence of private content and the owner recheck after demotion. The anonymous +HTTP boundary now also denies `?stalled=1`; the full HTTP integration suite +passes 55/55 against a local build of this revision. No game, seat, saved state, +deployment, public start or played-save conversion changes. + ## 7 October 2026 — mixed E3 programme evidence and the deferred Terror entry The withdrawn mixed E3 programme pairs are green: Moritani9/9 and Ecaz14/14 diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 566544c5..198c2bb5 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.ok(cookie); const room = (await created.json() as { code: string }).code; for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record[]) { - for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { + for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/operations?stalled=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) }); assert.equal(result.status, 401, path); assert.equal(result.headers.get('cache-control'), 'no-store'); diff --git a/tests/admin-operations.test.ts b/tests/admin-operations.test.ts index fd8f3ca9..18ee71d9 100644 --- a/tests/admin-operations.test.ts +++ b/tests/admin-operations.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import test from 'node:test'; import { adminLogin, requireAdmin, AdminError } from '../db/admin-access'; -import { readAdminIntegrity, readAdminOperations } from '../db/admin-operations'; +import { ADMIN_STALL_LIMIT, ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '../db/admin-operations'; import { adminStore } from './admin-access-fixture'; const now = 10_000; @@ -32,6 +32,7 @@ void test('owner operations sample counts saved states and backup provenance wit observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1, pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1, lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1, + stalledRooms: 0, oldestStalledChange: null, }); assert.equal(JSON.stringify(result).includes('secret'), false); assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before); @@ -57,6 +58,65 @@ void test('stale owner identity, demotion and revoked session cannot sample oper } finally { store.sqlite.close(); } }); +void test('stalled-decision diagnosis samples only live public interactions within its bound', async () => { + const store = adminStore(); + try { + const owner = store.provision('owner'); + const login = await adminLogin(store.database, owner.key, now); + const identity = await requireAdmin(store.database, login.token, ['owner'], now); + const hours = (value: number) => now - value * 3_600_000; + const add = (code: string, state: unknown, updatedAt: number) => + store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)') + .run(code, typeof state === 'string' ? state : JSON.stringify(state), 4, updatedAt); + // Twelve genuine stalled rooms with distinct idle ages plus three other + // public interaction kinds, oldest first. + for (let index = 0; index < 12; index++) + add(`STALL${String(index).padStart(3, '0')}`, + { status: 'playing', turn: 3, phase: 5, decision: { kind: 'battlePlan', player: 'a' }, + private: { secret: 'sealed card' } }, hours(7 + index)); + add('EXCLTRUT', { status: 'playing', truthtrance: { stage: 'priority' } }, hours(30)); + add('EXCLPHAS', { status: 'playing', phaseOpening: { passed: [] } }, hours(31)); + add('EXCLRESP', { status: 'playing', response: { kind: 'guildIncome' } }, hours(32)); + // Each exclusion boundary keeps its room out of the diagnosis. + add('EXCLPASD', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(40)); + store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('EXCLPASD', now); + add('EXCLREMV', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(41)); + store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLREMV', now, now); + add('EXCLCLOS', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(42)); + store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLCLOS', now, now); + add('EXCLARCH', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(43)); + store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLARCH', now, now); + add('EXCLLOBB', { status: 'lobby', decision: { kind: 'battlePlan' } }, hours(44)); + add('EXCLFRES', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(5)); + add('EXCLIDLE', { status: 'playing', turn: 1, phase: 1 }, hours(45)); + add('EXCLBRKN', '{broken private state', hours(46)); + const before = store.sqlite.prepare('SELECT code,state,version,updated_at FROM rooms ORDER BY code').all(); + const counters = await readAdminOperations(store.database, identity, now); + assert.equal(counters.stalledRooms, 15); + assert.equal(counters.oldestStalledChange, hours(32)); + const sample = await readAdminStalledDecisions(store.database, identity, now); + assert.equal(sample.length, ADMIN_STALL_LIMIT); + assert.deepEqual(sample.map(row => row.code), ['EXCLRESP', 'EXCLPHAS', 'EXCLTRUT', + 'STALL011', 'STALL010', 'STALL009', 'STALL008', 'STALL007', 'STALL006', 'STALL005']); + assert.equal(sample[0].kind, 'guildIncome'); + assert.equal(sample[1].kind, 'phaseOpening'); + assert.equal(sample[2].kind, 'truthtrance'); + assert.equal(sample[0].turn, null); + assert.equal(sample[0].phase, null); + assert.equal(sample[3].kind, 'battlePlan'); + assert.equal(sample[3].turn, 3); + assert.equal(sample[3].phase, 5); + assert.equal(sample[3].idleMs, 18 * 3_600_000); + assert.ok(sample.every(row => row.idleMs >= ADMIN_STALL_MS)); + assert.equal(JSON.stringify(sample).includes('secret'), false); + assert.deepEqual(store.sqlite.prepare('SELECT code,state,version,updated_at FROM rooms ORDER BY code').all(), before); + store.provision('owner', 'Remaining owner'); + store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id); + await assert.rejects(readAdminStalledDecisions(store.database, identity, now), + (error: unknown) => error instanceof AdminError && error.status === 403); + } finally { store.sqlite.close(); } +}); + void test('manual integrity check detects orphaned foreign keys without exposing their rows', async () => { const store = adminStore(); try {