diff --git a/frontend/README.md b/frontend/README.md
index b1100d5..e543726 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -15,10 +15,11 @@ frontend/
│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing
│ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view)
+│ ├── queue.js # Pure admission-queue formatting helpers (ordinals, ETA, status line, Exit queue label)
│ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover
-├── vendor/ # Pinned vendor libraries & licenses (locally served)
├── assets/
│ └── book.svg # Favicon (same book shape as the CSS logo)
+├── vendor/ # Pinned vendor libraries & licenses (locally served)
│ ├── marked.min.js
│ ├── marked.LICENSE
│ ├── purify.min.js
@@ -30,10 +31,11 @@ frontend/
│ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts)
│ └── scenario-toolbar.css # External dev toolbar styling (CSP compliant, no inline styles)
├── tests/
-│ ├── contract.test.js # Wire format, status code, header, & scenario tests (14 tests)
+│ ├── contract.test.js # Wire format, status code, header, & scenario tests (23 tests, incl. the 5 admission queue scenarios)
│ ├── api.test.js # UTF-8 byte boundary and credential validation tests (6 tests)
+│ ├── queue.test.js # Admission queue formatting helpers: ordinals, ETA, status line, Exit queue label (12 tests)
│ ├── render.test.js # Markdown section partitioning and fallback tests (10 tests)
-│ └── e2e_runner.js # End-to-end browser test runner connecting to Chrome (9444) via CDP (16 tests)
+│ └── e2e_runner.js # End-to-end browser test runner connecting to Chrome (9444) via CDP (19 tests)
├── package.json
├── package-lock.json
├── .gitignore
@@ -62,6 +64,11 @@ frontend/
- Pathological blocks fall back to a bounded plain-text preview with full export available.
- "Export to MD" always exports the complete, untouched raw Markdown client-side via Blob.
- Tool call results in history are rendered lazily with bounded serialization buffers (`serializeBounded`).
+5. **Admission Queue** (`docs/QUEUE_SPECIFICATION.md` §7):
+ - The backend runs one query at a time; submitting calls `POST /api/v1/queue/join` first. A `ready` answer sends the query immediately. A `queued` answer shows the loading view's queued sub-state (`shaping` orb, `js/orb.js`'s `setState`) with a status line such as "You're 3rd in line · about 4 min" and polls `GET /api/v1/queue/status` every 2 seconds until `ready`, then sends the query at once.
+ - The queued sub-state's "Exit queue" button (aria-label fixed as "Leave the queue") relabels itself by time waited — "I will try next time" (under 1 min), "Ohhh, it's so long" (1-3 min), "I'm dying in this queue" (over 3 min) — and calls `DELETE /api/v1/queue/ticket` on click, returning to the prompt view with the prompt text preserved.
+ - A `409 busy` right after `ready`, or a `404 ticket_not_found` while polling, triggers exactly one automatic rejoin (`js/api.js`'s `joinQueue`/`queueStatus`/`leaveQueue`); a second failure shows a user-facing message instead of retrying forever.
+ - A best-effort `DELETE /api/v1/queue/ticket` keepalive fetch fires on `pagehide` while the loading view is visible, so closing the tab releases the ticket promptly instead of waiting out the server's heartbeat timeout.
## Development & Testing
@@ -76,11 +83,21 @@ npm run dev
node dev/mock-server.js --port 5173
```
-Open `http://127.0.0.1:5173/` in your browser. A floating dev toolbar in the bottom-right corner allows toggling between all 13 deterministic mock scenarios (e.g. normal shared example, 403 verify, 409 busy, 504 timeout, malicious content, large output, delayed cancellation).
+Open `http://127.0.0.1:5173/` in your browser. A floating dev toolbar in the bottom-right corner allows toggling between all 18 deterministic mock scenarios (e.g. normal shared example, 403 verify, 409 busy, 504 timeout, malicious content, large output, delayed cancellation, and the 5 admission queue scenarios below).
+
+Admission queue scenarios (`docs/QUEUE_SPECIFICATION.md` §10), each with its own per-session poll counter that resets whenever the active scenario changes:
+
+| Scenario | Behaviour |
+| --- | --- |
+| `queued` | join answers position 3 with `eta_seconds` 150; each status poll decrements the position; the third poll answers `ready`; the query then succeeds |
+| `queued_no_estimate` | as `queued` with `eta_seconds: null` throughout |
+| `queue_full` | join answers `503 queue_full` |
+| `reservation_lost` | join always answers `ready`; the first query after `ready` answers `409 busy`; the automatic rejoin then succeeds |
+| `ticket_lost` | join answers `queued`; the second status poll answers `404 ticket_not_found`; the automatic rejoin answers `ready` |
### Running Unit & Contract Tests
-Tests verify API limits, wire contracts, headers, cookies, and markdown partitioning (30 tests):
+Tests verify API limits, wire contracts, headers, cookies, markdown partitioning, and the admission queue formatting/scenarios (51 tests):
```bash
cd frontend
@@ -89,7 +106,7 @@ npm test
### Running E2E Browser Tests
-Runs comprehensive browser tests against Chrome on port 9444 via CDP (16 tests):
+Runs comprehensive browser tests against Chrome on port 9444 via CDP, including the queued → ready → result flow, Exit queue label timing (clock-stubbed), and a reservation-lost rejoin (19 tests):
```bash
cd frontend
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 5c3c222..4660e44 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -414,6 +414,12 @@ body {
border-color: var(--color-border-hover);
}
+/* Queued sub-state "Exit queue" button shares .cancel-btn's look; only one of the two is
+ ever shown at a time (queued vs. running), toggled in js/app.js. */
+.exit-queue-btn {
+ min-width: 200px;
+}
+
/* State 4: Result View */
.view-result {
width: 100%;
diff --git a/frontend/dev/mock-server.js b/frontend/dev/mock-server.js
index cb07929..9d89623 100644
--- a/frontend/dev/mock-server.js
+++ b/frontend/dev/mock-server.js
@@ -35,9 +35,45 @@ export const SCENARIOS = [
'delayed_cancellation',
'unknown_expired_download',
'malicious_content',
- 'large_output'
+ 'large_output',
+ 'queued',
+ 'queued_no_estimate',
+ 'queue_full',
+ 'reservation_lost',
+ 'ticket_lost'
];
+// Admission queue scenarios (docs/QUEUE_SPECIFICATION.md §10) that drive a canned join/status
+// dance instead of the immediate "ready" every other scenario answers with.
+const QUEUE_SCENARIOS = new Set(['queued', 'queued_no_estimate', 'queue_full', 'reservation_lost', 'ticket_lost']);
+const QUEUE_RESERVATION_SECONDS = 45;
+
+// Per-session queue simulation state, keyed by cw_session cookie value. Reset whenever the
+// scenario active for that session changes, so switching scenarios mid-flow never leaks stale
+// poll/join counters into the new scenario.
+const queueSessions = new Map();
+
+/**
+ * Returns (creating or resetting as needed) the per-session queue simulation record.
+ * @param {string} sessionId
+ * @param {string} scenario
+ */
+function getQueueRecord(sessionId, scenario) {
+ let rec = queueSessions.get(sessionId);
+ if (!rec || rec.scenario !== scenario) {
+ rec = { scenario, ticketId: null, joinCount: 0, pollCount: 0, queryAttempt: 0 };
+ queueSessions.set(sessionId, rec);
+ }
+ return rec;
+}
+
+/**
+ * Generates an opaque ticket id in the shape used by the real backend (CONTRACTS §1).
+ */
+function makeTicketId() {
+ return `q_${crypto.randomBytes(6).toString('hex')}`;
+}
+
/**
* Standard Security Headers
*/
@@ -539,9 +575,10 @@ export function createMockServer() {
// Session cookie: cw_session
const cookies = parseCookies(req.headers.cookie);
- if (!cookies.cw_session) {
- const newSession = crypto.randomBytes(16).toString('hex');
- res.setHeader('Set-Cookie', `cw_session=${newSession}; Path=/; HttpOnly; SameSite=Strict`);
+ let sessionId = cookies.cw_session;
+ if (!sessionId) {
+ sessionId = crypto.randomBytes(16).toString('hex');
+ res.setHeader('Set-Cookie', `cw_session=${sessionId}; Path=/; HttpOnly; SameSite=Strict`);
}
// Origin check for mutation requests
@@ -597,6 +634,208 @@ export function createMockServer() {
}
}
+ // Admission Queue Endpoint: POST /api/v1/queue/join
+ if (pathname === '/api/v1/queue/join' && method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', () => {
+ let data;
+ try {
+ data = body ? JSON.parse(body) : {};
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON body' } }));
+ return;
+ }
+ if (data && typeof data === 'object' && ('credentials' in data || 'pat' in data)) {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Queue endpoints do not accept credentials' } }));
+ return;
+ }
+
+ const scenario = getScenarioForRequest(req, urlObj);
+
+ if (scenario === 'queue_full') {
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'queue_full', message: 'The queue is full, please try later.' } }));
+ return;
+ }
+
+ if (!QUEUE_SCENARIOS.has(scenario)) {
+ // Every other scenario answers join with an immediate reservation (spec §10).
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({
+ ticket_id: makeTicketId(),
+ status: 'ready',
+ position: 0,
+ ahead: 0,
+ eta_seconds: null,
+ reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
+ runner: 'reserved'
+ }));
+ return;
+ }
+
+ const rec = getQueueRecord(sessionId, scenario);
+ rec.joinCount += 1;
+
+ if (scenario === 'reservation_lost') {
+ rec.ticketId = rec.ticketId || makeTicketId();
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({
+ ticket_id: rec.ticketId,
+ status: 'ready',
+ position: 0,
+ ahead: 0,
+ eta_seconds: null,
+ reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
+ runner: 'reserved'
+ }));
+ return;
+ }
+
+ if (scenario === 'ticket_lost') {
+ rec.ticketId = makeTicketId();
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ if (rec.joinCount === 1) {
+ rec.pollCount = 0;
+ res.end(JSON.stringify({
+ ticket_id: rec.ticketId,
+ status: 'queued',
+ position: 2,
+ ahead: 1,
+ eta_seconds: 60,
+ reservation_expires_in_seconds: null,
+ runner: 'running'
+ }));
+ } else {
+ // The automatic rejoin after the ticket was lost (spec §10, §7.1 step 6).
+ res.end(JSON.stringify({
+ ticket_id: rec.ticketId,
+ status: 'ready',
+ position: 0,
+ ahead: 0,
+ eta_seconds: null,
+ reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
+ runner: 'reserved'
+ }));
+ }
+ return;
+ }
+
+ // 'queued' / 'queued_no_estimate': idempotent join, position decrements only on
+ // status polls (see GET /api/v1/queue/status below).
+ const noEstimate = scenario === 'queued_no_estimate';
+ rec.ticketId = rec.ticketId || makeTicketId();
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({
+ ticket_id: rec.ticketId,
+ status: 'queued',
+ position: 3,
+ ahead: 2,
+ eta_seconds: noEstimate ? null : 150,
+ reservation_expires_in_seconds: null,
+ runner: 'running'
+ }));
+ });
+ return;
+ }
+
+ // Admission Queue Endpoint: GET /api/v1/queue/status
+ if (pathname === '/api/v1/queue/status' && method === 'GET') {
+ const scenario = getScenarioForRequest(req, urlObj);
+
+ if (!QUEUE_SCENARIOS.has(scenario) || scenario === 'queue_full' || scenario === 'reservation_lost') {
+ // These scenarios never leave a session polling: non-queue scenarios resolve at join,
+ // queue_full never creates a ticket, and reservation_lost resolves at join too.
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } }));
+ return;
+ }
+
+ const rec = queueSessions.get(sessionId);
+ if (!rec || rec.scenario !== scenario || !rec.ticketId) {
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } }));
+ return;
+ }
+
+ rec.pollCount += 1;
+
+ // Build the response body first so exactly one writeHead/end pair is ever sent,
+ // regardless of which branch (200 queued/ready vs. 404 lost) fires.
+ let statusCode = 200;
+ let payload;
+
+ if (scenario === 'ticket_lost') {
+ if (rec.pollCount === 1) {
+ payload = {
+ ticket_id: rec.ticketId,
+ status: 'queued',
+ position: 1,
+ ahead: 0,
+ eta_seconds: 30,
+ reservation_expires_in_seconds: null,
+ runner: 'running'
+ };
+ } else {
+ // Second poll: the ticket has expired server-side (spec §10).
+ rec.ticketId = null;
+ statusCode = 404;
+ payload = { error: { code: 'ticket_not_found', message: 'Ticket expired.' } };
+ }
+ } else {
+ // 'queued' / 'queued_no_estimate': decrement position on each poll; ready on the third.
+ const noEstimate = scenario === 'queued_no_estimate';
+ if (rec.pollCount === 1) {
+ payload = {
+ ticket_id: rec.ticketId,
+ status: 'queued',
+ position: 2,
+ ahead: 1,
+ eta_seconds: noEstimate ? null : 95,
+ reservation_expires_in_seconds: null,
+ runner: 'running'
+ };
+ } else if (rec.pollCount === 2) {
+ payload = {
+ ticket_id: rec.ticketId,
+ status: 'queued',
+ position: 1,
+ ahead: 0,
+ eta_seconds: noEstimate ? null : 40,
+ reservation_expires_in_seconds: null,
+ runner: 'running'
+ };
+ } else {
+ payload = {
+ ticket_id: rec.ticketId,
+ status: 'ready',
+ position: 0,
+ ahead: 0,
+ eta_seconds: null,
+ reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
+ runner: 'reserved'
+ };
+ }
+ }
+
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(payload));
+ return;
+ }
+
+ // Admission Queue Endpoint: DELETE /api/v1/queue/ticket
+ if (pathname === '/api/v1/queue/ticket' && method === 'DELETE') {
+ const rec = queueSessions.get(sessionId);
+ if (rec) {
+ rec.ticketId = null;
+ }
+ res.writeHead(204, { 'Cache-Control': 'no-store' });
+ res.end();
+ return;
+ }
+
// API Endpoint 1: POST /api/v1/auth/verify
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
let body = '';
@@ -654,6 +893,19 @@ export function createMockServer() {
const scenario = getScenarioForRequest(req, urlObj);
+ // Admission queue scenario: the first query after "ready" loses the reservation
+ // (simulating network trouble); the client's automatic rejoin-then-retry succeeds
+ // on the second attempt (spec §10).
+ if (scenario === 'reservation_lost') {
+ const rec = getQueueRecord(sessionId, scenario);
+ rec.queryAttempt += 1;
+ if (rec.queryAttempt === 1) {
+ res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'busy', message: 'Reservation lost; rejoin the queue.' } }));
+ return;
+ }
+ }
+
// 409 Busy check
if (scenario === '409_busy' || isQueryBusy) {
res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
diff --git a/frontend/index.html b/frontend/index.html
index 1efd141..e8eefb4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -64,8 +64,9 @@
- Agent researching Confluence...
+ Agent researching Confluence...
+
diff --git a/frontend/js/api.js b/frontend/js/api.js
index deb710b..c134aba 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -62,9 +62,11 @@ export function validateCredentials({ url, pat }) {
/**
* Parses and normalizes API error responses.
* @param {Response} response
+ * @param {{ notFoundCode?: string }} [opts] Overrides the fallback code used for a bodyless/non-JSON 404
+ * (queue endpoints use `ticket_not_found`; artifacts use `artifact_not_found`).
* @returns {Promise}
*/
-async function parseApiError(response) {
+async function parseApiError(response, { notFoundCode = 'artifact_not_found' } = {}) {
let code = 'execution_failed';
let message = `Request failed with status ${response.status}`;
@@ -111,13 +113,15 @@ async function parseApiError(response) {
message = 'Request forbidden by server policy.';
}
} else if (response.status === 404) {
- code = 'artifact_not_found';
+ code = notFoundCode;
} else if (response.status === 409) {
code = 'busy';
} else if (response.status === 413) {
code = 'request_too_large';
} else if (response.status === 502) {
code = 'upstream_failed';
+ } else if (response.status === 503) {
+ code = 'queue_full';
} else if (response.status === 504) {
code = 'query_timeout';
}
@@ -200,6 +204,72 @@ export async function submitQuery({ prompt, credentials }, { signal } = {}) {
return await response.json();
}
+/**
+ * Joins the admission queue for the current session. Idempotent: if the session already
+ * holds a ticket, the same ticket is returned unchanged. Carries no credentials.
+ * @param {{ signal?: AbortSignal }} [options]
+ * @returns {Promise<{ ticket_id: string, status: 'ready'|'queued', position: number, ahead: number,
+ * eta_seconds: number|null, reservation_expires_in_seconds: number|null, runner: 'reserved'|'running' }>}
+ */
+export async function joinQueue({ signal } = {}) {
+ const response = await fetch('/api/v1/queue/join', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'same-origin',
+ cache: 'no-store',
+ body: JSON.stringify({}),
+ signal
+ });
+
+ if (!response.ok) {
+ throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
+ }
+
+ return await response.json();
+}
+
+/**
+ * Polls the current session's ticket status. Every call refreshes the server-side heartbeat.
+ * @param {{ signal?: AbortSignal }} [options]
+ * @returns {Promise