Diagnose stalled administrator decisions from the operations page
Add an owner-only, read-only sample of at most ten live rooms whose public pending interaction — decision, response, Truthtrance or phase opening — has been idle longest, with the room code, idle duration, interaction kind, turn and phase. GET /api/admin/operations?stalled=1 repeats live owner authority, answers no-store, rejects any other query combination and repairs nothing; paused, closed, removed and archived rooms are excluded and no private game content is selected. The aggregate sample adds stalledRooms and oldestStalledChange for the same predicate. 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 denies the new read, and the full HTTP integration suite passes 55/55 against a local build of this revision. The panel was verified in the real browser against six hours of genuine idle rooms, which caught and fixed a missing threshold field in the response. No game, seat, saved state, deployment, public start or played-save conversion changes.
This commit is contained in:
1 parent
e05a205a47
commit
07c20ae806
10 files changed
+287
-11
No files matched your search
@@ -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; }
|
||||
@@ -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<Snapshot | null>(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<Stalled | null>(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<Stalled>('/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 <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => {
|
||||
integrityEpoch.current++;
|
||||
stalledEpoch.current++;
|
||||
setIntegrity(null); setIntegrityNotice(''); setChecking(false);
|
||||
setStalled(null); setStalledNotice(''); setLoadingStalled(false);
|
||||
setSnapshot(null); setLoading(true); setRefresh(value => value + 1);
|
||||
}}>Refresh status</Button></header>
|
||||
<p>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.</p>
|
||||
@@ -86,8 +115,30 @@ export default function OperationsPage() {
|
||||
<div><dt>Room backup snapshots</dt><dd>{snapshot.backupSnapshots}</dd></div>
|
||||
<div><dt>Backup snapshot payload bytes</dt><dd>{snapshot.backupBytes.toLocaleString()}</dd></div>
|
||||
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
||||
<div><dt>Rooms waiting on a stalled interaction</dt><dd>{snapshot.stalledRooms}</dd></div>
|
||||
<div><dt>Oldest stalled room write</dt><dd>{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<Button variant="outline" disabled={loadingStalled} onClick={() => void checkStalled()}>List stalled decisions</Button>
|
||||
<p>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.</p>
|
||||
{loadingStalled && <output>Sampling stalled rooms…</output>}
|
||||
{stalledNotice && <p role="alert" className="admin-notice">{stalledNotice}</p>}
|
||||
{stalled && <section aria-label="Stalled decisions">
|
||||
<p>Sampled {new Date(stalled.observedAt).toLocaleString()}; idle means no room write for {Math.round(stalled.stallMs / 3_600_000)} hours.</p>
|
||||
{stalled.stalled.length === 0 ? <p>No live room is waiting on a stalled interaction.</p>
|
||||
: <table className="admin-stalled-table">
|
||||
<caption>Stalled rooms, longest idle first</caption>
|
||||
<thead><tr><th scope="col">Room</th><th scope="col">Waiting on</th><th scope="col">Turn</th><th scope="col">Phase</th><th scope="col">Idle</th></tr></thead>
|
||||
<tbody>{stalled.stalled.map(room => <tr key={room.code}>
|
||||
<th scope="row">{room.code}</th>
|
||||
<td>{room.kind}</td>
|
||||
<td>{room.turn ?? '—'}</td>
|
||||
<td>{room.phase ?? '—'}</td>
|
||||
<td>{idle(room.idleMs)}</td>
|
||||
</tr>)}</tbody>
|
||||
</table>}
|
||||
<p>This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.</p>
|
||||
</section>}
|
||||
<Button variant="outline" disabled={checking} onClick={() => void checkIntegrity()}>Run integrity check</Button>
|
||||
<p>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.</p>
|
||||
{checking && <output>Checking database structure and references…</output>}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
+56
-2
@@ -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<AdminOperations, 'observedAt'>;
|
||||
|
||||
/** 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<?) stalledRooms,
|
||||
(SELECT MIN(r.updated_at) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at<?) oldestStalledChange
|
||||
WHERE EXISTS (${ownerAuthority})`)
|
||||
.bind(identity.sessionHash, identity.id, now).first<Counts>();
|
||||
.bind(now - ADMIN_STALL_MS, now - ADMIN_STALL_MS, identity.sessionHash, identity.id, now).first<Counts>();
|
||||
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<AdminStalledDecision[]> {
|
||||
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<?
|
||||
ORDER BY r.updated_at ASC LIMIT ?`)
|
||||
.bind(identity.sessionHash, identity.id, now, now - ADMIN_STALL_MS, ADMIN_STALL_LIMIT)
|
||||
.all<{ code: string; updatedAt: number; kind: string; turn: number | null; phase: number | null }>();
|
||||
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<AdminIntegrity> {
|
||||
const results = await database.batch([
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>[]) {
|
||||
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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user