frontend: queue-aware submit flow and queued loading view
Submit joins the admission queue first and sends the PAT only once the reservation is held. Queued state shows the shaping orb, ordinal position with estimate, and an exit button whose label changes with the wait; running switches the orb to solving. Rejoin once on a lost reservation or ticket, best-effort leave on pagehide. Mock server gains the queue endpoints and five scenarios; contract, unit and e2e tests cover them.
This commit is contained in:
parent
5aad7a0b45
commit
7d6429aca9
@ -15,10 +15,11 @@ frontend/
|
|||||||
│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
|
│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
|
||||||
│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing
|
│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing
|
||||||
│ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view)
|
│ ├── 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
|
│ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover
|
||||||
├── vendor/ # Pinned vendor libraries & licenses (locally served)
|
|
||||||
├── assets/
|
├── assets/
|
||||||
│ └── book.svg # Favicon (same book shape as the CSS logo)
|
│ └── book.svg # Favicon (same book shape as the CSS logo)
|
||||||
|
├── vendor/ # Pinned vendor libraries & licenses (locally served)
|
||||||
│ ├── marked.min.js
|
│ ├── marked.min.js
|
||||||
│ ├── marked.LICENSE
|
│ ├── marked.LICENSE
|
||||||
│ ├── purify.min.js
|
│ ├── purify.min.js
|
||||||
@ -30,10 +31,11 @@ frontend/
|
|||||||
│ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts)
|
│ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts)
|
||||||
│ └── scenario-toolbar.css # External dev toolbar styling (CSP compliant, no inline styles)
|
│ └── scenario-toolbar.css # External dev toolbar styling (CSP compliant, no inline styles)
|
||||||
├── tests/
|
├── 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)
|
│ ├── 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)
|
│ ├── 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.json
|
||||||
├── package-lock.json
|
├── package-lock.json
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
@ -62,6 +64,11 @@ frontend/
|
|||||||
- Pathological blocks fall back to a bounded plain-text preview with full export available.
|
- 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.
|
- "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`).
|
- 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
|
## Development & Testing
|
||||||
|
|
||||||
@ -76,11 +83,21 @@ npm run dev
|
|||||||
node dev/mock-server.js --port 5173
|
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
|
### 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
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@ -89,7 +106,7 @@ npm test
|
|||||||
|
|
||||||
### Running E2E Browser Tests
|
### 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
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|||||||
@ -414,6 +414,12 @@ body {
|
|||||||
border-color: var(--color-border-hover);
|
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 */
|
/* State 4: Result View */
|
||||||
.view-result {
|
.view-result {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -35,9 +35,45 @@ export const SCENARIOS = [
|
|||||||
'delayed_cancellation',
|
'delayed_cancellation',
|
||||||
'unknown_expired_download',
|
'unknown_expired_download',
|
||||||
'malicious_content',
|
'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
|
* Standard Security Headers
|
||||||
*/
|
*/
|
||||||
@ -539,9 +575,10 @@ export function createMockServer() {
|
|||||||
|
|
||||||
// Session cookie: cw_session
|
// Session cookie: cw_session
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
if (!cookies.cw_session) {
|
let sessionId = cookies.cw_session;
|
||||||
const newSession = crypto.randomBytes(16).toString('hex');
|
if (!sessionId) {
|
||||||
res.setHeader('Set-Cookie', `cw_session=${newSession}; Path=/; HttpOnly; SameSite=Strict`);
|
sessionId = crypto.randomBytes(16).toString('hex');
|
||||||
|
res.setHeader('Set-Cookie', `cw_session=${sessionId}; Path=/; HttpOnly; SameSite=Strict`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin check for mutation requests
|
// 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
|
// API Endpoint 1: POST /api/v1/auth/verify
|
||||||
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
|
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
@ -654,6 +893,19 @@ export function createMockServer() {
|
|||||||
|
|
||||||
const scenario = getScenarioForRequest(req, urlObj);
|
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
|
// 409 Busy check
|
||||||
if (scenario === '409_busy' || isQueryBusy) {
|
if (scenario === '409_busy' || isQueryBusy) {
|
||||||
res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
|||||||
@ -64,8 +64,9 @@
|
|||||||
<div class="spinner-wrapper">
|
<div class="spinner-wrapper">
|
||||||
<canvas id="thinking-orb" class="thinking-orb" width="64" height="64" aria-hidden="true"></canvas>
|
<canvas id="thinking-orb" class="thinking-orb" width="64" height="64" aria-hidden="true"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-status" aria-live="polite">Agent researching Confluence...</p>
|
<p id="loading-status" class="loading-status" aria-live="polite">Agent researching Confluence...</p>
|
||||||
<button id="cancel-btn" class="cancel-btn" type="button">Cancel</button>
|
<button id="cancel-btn" class="cancel-btn" type="button">Cancel</button>
|
||||||
|
<button id="exit-queue-btn" class="cancel-btn exit-queue-btn hidden" type="button" aria-label="Leave the queue">I will try next time</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- State 4: Result View -->
|
<!-- State 4: Result View -->
|
||||||
|
|||||||
@ -62,9 +62,11 @@ export function validateCredentials({ url, pat }) {
|
|||||||
/**
|
/**
|
||||||
* Parses and normalizes API error responses.
|
* Parses and normalizes API error responses.
|
||||||
* @param {Response} response
|
* @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<Error>}
|
* @returns {Promise<Error>}
|
||||||
*/
|
*/
|
||||||
async function parseApiError(response) {
|
async function parseApiError(response, { notFoundCode = 'artifact_not_found' } = {}) {
|
||||||
let code = 'execution_failed';
|
let code = 'execution_failed';
|
||||||
let message = `Request failed with status ${response.status}`;
|
let message = `Request failed with status ${response.status}`;
|
||||||
|
|
||||||
@ -111,13 +113,15 @@ async function parseApiError(response) {
|
|||||||
message = 'Request forbidden by server policy.';
|
message = 'Request forbidden by server policy.';
|
||||||
}
|
}
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
code = 'artifact_not_found';
|
code = notFoundCode;
|
||||||
} else if (response.status === 409) {
|
} else if (response.status === 409) {
|
||||||
code = 'busy';
|
code = 'busy';
|
||||||
} else if (response.status === 413) {
|
} else if (response.status === 413) {
|
||||||
code = 'request_too_large';
|
code = 'request_too_large';
|
||||||
} else if (response.status === 502) {
|
} else if (response.status === 502) {
|
||||||
code = 'upstream_failed';
|
code = 'upstream_failed';
|
||||||
|
} else if (response.status === 503) {
|
||||||
|
code = 'queue_full';
|
||||||
} else if (response.status === 504) {
|
} else if (response.status === 504) {
|
||||||
code = 'query_timeout';
|
code = 'query_timeout';
|
||||||
}
|
}
|
||||||
@ -200,6 +204,72 @@ export async function submitQuery({ prompt, credentials }, { signal } = {}) {
|
|||||||
return await response.json();
|
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<object>} Same shape as {@link joinQueue}.
|
||||||
|
*/
|
||||||
|
export async function queueStatus({ signal } = {}) {
|
||||||
|
const response = await fetch('/api/v1/queue/status', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the current session's ticket, if any. Always succeeds (204) whether or not a ticket
|
||||||
|
* existed. Used both for the explicit "Exit queue" action and a best-effort call on `pagehide`.
|
||||||
|
* @param {{ keepalive?: boolean, signal?: AbortSignal }} [options]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function leaveQueue({ keepalive = false, signal } = {}) {
|
||||||
|
const response = await fetch('/api/v1/queue/ticket', {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store',
|
||||||
|
keepalive,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads an artifact by its backend-issued ID.
|
* Downloads an artifact by its backend-issued ID.
|
||||||
* @param {string} artifactId
|
* @param {string} artifactId
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
* Credentials remain strictly in browser memory and are never persisted or logged.
|
* Credentials remain strictly in browser memory and are never persisted or logged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials } from './api.js';
|
import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue } from './api.js';
|
||||||
import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
|
import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
|
||||||
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
|
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
|
||||||
import { mountThinkingOrb } from './orb.js';
|
import { mountThinkingOrb } from './orb.js';
|
||||||
import { initBookLogo } from './logo.js';
|
import { initBookLogo } from './logo.js';
|
||||||
|
import { QUEUE_POLL_INTERVAL_MS, formatQueuedStatus, formatExitQueueLabel } from './queue.js';
|
||||||
|
|
||||||
// Application memory state
|
// Application memory state
|
||||||
let committedCredentials = null; // { url: string, pat: string } | null
|
let committedCredentials = null; // { url: string, pat: string } | null
|
||||||
@ -18,6 +19,13 @@ let activeVerifyAbortController = null;
|
|||||||
let currentVerifyGeneration = 0;
|
let currentVerifyGeneration = 0;
|
||||||
let currentResult = null;
|
let currentResult = null;
|
||||||
let lastSubmittedPrompt = '';
|
let lastSubmittedPrompt = '';
|
||||||
|
let thinkingOrb = null;
|
||||||
|
|
||||||
|
// Admission queue state (docs/QUEUE_SPECIFICATION.md §7)
|
||||||
|
let queuePollTimerId = null;
|
||||||
|
let queueWaitStartedAt = null; // Date.now() at the join response that first returned "queued"
|
||||||
|
let queueRejoinedAfterBusy = false; // rejoin-once guard for 409 busy right after "ready"
|
||||||
|
let queueRejoinedAfterLoss = false; // rejoin-once guard for 404 ticket_not_found while polling
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
let viewPrompt, viewLoading, viewResult;
|
let viewPrompt, viewLoading, viewResult;
|
||||||
@ -26,7 +34,7 @@ let keyBtn, credIndicator, credStatusSr;
|
|||||||
let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm;
|
let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm;
|
||||||
let credUrlInput, credPatInput, togglePatBtn, modalFeedback;
|
let credUrlInput, credPatInput, togglePatBtn, modalFeedback;
|
||||||
let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred;
|
let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred;
|
||||||
let cancelBtn, backBtn, exportBtn;
|
let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn;
|
||||||
let outputContent, sectionNav, resultWarnings;
|
let outputContent, sectionNav, resultWarnings;
|
||||||
let artifactsSection, artifactsList, artifactsError;
|
let artifactsSection, artifactsList, artifactsError;
|
||||||
let historySection, historyToggleBtn, historyToggleTitle, historyContent;
|
let historySection, historyToggleBtn, historyToggleTitle, historyContent;
|
||||||
@ -43,8 +51,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
viewLoading = document.getElementById('view-loading');
|
viewLoading = document.getElementById('view-loading');
|
||||||
viewResult = document.getElementById('view-result');
|
viewResult = document.getElementById('view-result');
|
||||||
|
|
||||||
// Decorative animations (page is a fixed light theme, so the orb ink is pinned to light)
|
// Decorative animations (page is a fixed light theme, so the orb ink is pinned to light).
|
||||||
mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' });
|
// Starts in the "solving" preset; switched to "shaping" while queued (see setLoadingQueuedUI).
|
||||||
|
thinkingOrb = mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' });
|
||||||
initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo'));
|
initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo'));
|
||||||
|
|
||||||
// Prompt View
|
// Prompt View
|
||||||
@ -73,6 +82,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Loading & Result Controls
|
// Loading & Result Controls
|
||||||
cancelBtn = document.getElementById('cancel-btn');
|
cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
exitQueueBtn = document.getElementById('exit-queue-btn');
|
||||||
|
loadingStatus = document.getElementById('loading-status');
|
||||||
backBtn = document.getElementById('back-btn');
|
backBtn = document.getElementById('back-btn');
|
||||||
exportBtn = document.getElementById('export-btn');
|
exportBtn = document.getElementById('export-btn');
|
||||||
|
|
||||||
@ -100,8 +111,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Wire events
|
// Wire events
|
||||||
setupPromptEvents();
|
setupPromptEvents();
|
||||||
setupModalEvents();
|
setupModalEvents();
|
||||||
|
setupLoadingEvents();
|
||||||
setupResultEvents();
|
setupResultEvents();
|
||||||
updateCredentialIndicator();
|
updateCredentialIndicator();
|
||||||
|
|
||||||
|
// Best-effort ticket release on tab close/navigation while a ticket may still be held
|
||||||
|
// (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land,
|
||||||
|
// the server's heartbeat/reservation timeout reclaims the ticket within 15s (spec §7.1).
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
if (viewLoading && !viewLoading.classList.contains('hidden')) {
|
||||||
|
leaveQueue({ keepalive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -140,7 +161,8 @@ function setupPromptEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles query submission.
|
* Handles query submission: validates locally, then runs the admission queue flow
|
||||||
|
* (join → queued/ready → send) per docs/QUEUE_SPECIFICATION.md §7.1.
|
||||||
*/
|
*/
|
||||||
async function handleSubmitQuery() {
|
async function handleSubmitQuery() {
|
||||||
const prompt = promptInput.value;
|
const prompt = promptInput.value;
|
||||||
@ -159,11 +181,138 @@ async function handleSubmitQuery() {
|
|||||||
hidePromptError();
|
hidePromptError();
|
||||||
lastSubmittedPrompt = prompt;
|
lastSubmittedPrompt = prompt;
|
||||||
|
|
||||||
// Transition to loading view
|
// Transition to loading view. Default optimistically to the running sub-state: most
|
||||||
|
// submissions are admitted immediately (join answers "ready"), and this avoids a flash
|
||||||
|
// of queued UI while the join request is still in flight.
|
||||||
switchView('loading');
|
switchView('loading');
|
||||||
|
setLoadingRunningUI();
|
||||||
|
|
||||||
const generation = ++currentRequestGeneration;
|
const generation = ++currentRequestGeneration;
|
||||||
activeAbortController = new AbortController();
|
activeAbortController = new AbortController();
|
||||||
|
queueWaitStartedAt = null;
|
||||||
|
queueRejoinedAfterBusy = false;
|
||||||
|
queueRejoinedAfterLoss = false;
|
||||||
|
|
||||||
|
await beginAdmission(generation, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the admission queue and dispatches to the queued or ready path.
|
||||||
|
* Also used to rejoin once after a lost reservation (409 after ready) or a lost ticket
|
||||||
|
* (404 while polling), per spec §7.1 steps 5-6.
|
||||||
|
* @param {number} generation
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
async function beginAdmission(generation, prompt) {
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = await joinQueue({ signal: activeAbortController.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (generation !== currentRequestGeneration) return;
|
||||||
|
if (generation === currentRequestGeneration) activeAbortController = null;
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
switchView('prompt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchView('prompt');
|
||||||
|
showQueryError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation !== currentRequestGeneration) return;
|
||||||
|
await handleAdmissionStatus(generation, prompt, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reacts to a join/status response: sends the query immediately when "ready", otherwise
|
||||||
|
* enters/updates the queued sub-state and schedules the next poll.
|
||||||
|
* @param {number} generation
|
||||||
|
* @param {string} prompt
|
||||||
|
* @param {{ status: 'ready'|'queued' }} status
|
||||||
|
*/
|
||||||
|
async function handleAdmissionStatus(generation, prompt, status) {
|
||||||
|
if (status.status === 'ready') {
|
||||||
|
stopPolling();
|
||||||
|
setLoadingSendingUI();
|
||||||
|
await sendQuery(generation, prompt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// queued
|
||||||
|
if (queueWaitStartedAt === null) {
|
||||||
|
queueWaitStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
setLoadingQueuedUI(status);
|
||||||
|
schedulePoll(generation, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls ticket status every QUEUE_POLL_INTERVAL_MS while queued.
|
||||||
|
* @param {number} generation
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
function schedulePoll(generation, prompt) {
|
||||||
|
stopPolling();
|
||||||
|
queuePollTimerId = setTimeout(() => {
|
||||||
|
queuePollTimerId = null;
|
||||||
|
pollStatus(generation, prompt);
|
||||||
|
}, QUEUE_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears any pending poll timer. Safe to call when nothing is scheduled.
|
||||||
|
*/
|
||||||
|
function stopPolling() {
|
||||||
|
if (queuePollTimerId !== null) {
|
||||||
|
clearTimeout(queuePollTimerId);
|
||||||
|
queuePollTimerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches ticket status once and reacts to it, including the rejoin-once-then-give-up
|
||||||
|
* handling for a lost ticket (spec §7.1 step 6, §8).
|
||||||
|
* @param {number} generation
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
async function pollStatus(generation, prompt) {
|
||||||
|
if (generation !== currentRequestGeneration) return;
|
||||||
|
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = await queueStatus({ signal: activeAbortController.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (generation !== currentRequestGeneration) return;
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
|
||||||
|
if (err.code === 'ticket_not_found') {
|
||||||
|
if (!queueRejoinedAfterLoss) {
|
||||||
|
queueRejoinedAfterLoss = true;
|
||||||
|
await beginAdmission(generation, prompt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchView('prompt');
|
||||||
|
showPromptError('Your place in the queue was lost. Please submit again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switchView('prompt');
|
||||||
|
showQueryError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation !== currentRequestGeneration) return;
|
||||||
|
await handleAdmissionStatus(generation, prompt, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the actual query once the reservation is held ("ready"). Handles the
|
||||||
|
* rejoin-once-then-give-up path for a reservation lost right after "ready" (§7.1 step 5).
|
||||||
|
* @param {number} generation
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
async function sendQuery(generation, prompt) {
|
||||||
|
setLoadingRunningUI();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await submitQuery(
|
const result = await submitQuery(
|
||||||
@ -191,23 +340,20 @@ async function handleSubmitQuery() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err.code === 'busy') {
|
||||||
|
if (!queueRejoinedAfterBusy) {
|
||||||
|
queueRejoinedAfterBusy = true;
|
||||||
|
await beginAdmission(generation, prompt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchView('prompt');
|
||||||
|
showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Switch back to prompt view and display error
|
// Switch back to prompt view and display error
|
||||||
switchView('prompt');
|
switchView('prompt');
|
||||||
if (err.code === 'confluence_auth_failed') {
|
showQueryError(err);
|
||||||
showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.');
|
|
||||||
} else if (err.code === 'origin_denied') {
|
|
||||||
showPromptError('Request forbidden: Origin denied by server policy.');
|
|
||||||
} else if (err.code === 'destination_denied') {
|
|
||||||
showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.');
|
|
||||||
} else if (err.code === 'busy') {
|
|
||||||
showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.');
|
|
||||||
} else if (err.code === 'query_timeout') {
|
|
||||||
showPromptError('The query timed out. The operation exceeded the allowed execution time.');
|
|
||||||
} else if (err.code === 'request_too_large') {
|
|
||||||
showPromptError('The request exceeds the maximum allowed payload size.');
|
|
||||||
} else {
|
|
||||||
showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === currentRequestGeneration) {
|
if (generation === currentRequestGeneration) {
|
||||||
activeAbortController = null;
|
activeAbortController = null;
|
||||||
@ -215,6 +361,32 @@ async function handleSubmitQuery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a query/queue API error to the prompt-view error message.
|
||||||
|
* @param {Error & { code?: string }} err
|
||||||
|
*/
|
||||||
|
function showQueryError(err) {
|
||||||
|
if (err.code === 'confluence_auth_failed') {
|
||||||
|
showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.');
|
||||||
|
} else if (err.code === 'origin_denied') {
|
||||||
|
showPromptError('Request forbidden: Origin denied by server policy.');
|
||||||
|
} else if (err.code === 'destination_denied') {
|
||||||
|
showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.');
|
||||||
|
} else if (err.code === 'busy') {
|
||||||
|
showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.');
|
||||||
|
} else if (err.code === 'query_timeout') {
|
||||||
|
showPromptError('The query timed out. The operation exceeded the allowed execution time.');
|
||||||
|
} else if (err.code === 'request_too_large') {
|
||||||
|
showPromptError('The request exceeds the maximum allowed payload size.');
|
||||||
|
} else if (err.code === 'queue_full') {
|
||||||
|
showPromptError('The queue is full, please try later.');
|
||||||
|
} else if (err.code === 'ticket_not_found') {
|
||||||
|
showPromptError('Your place in the queue was lost. Please submit again.');
|
||||||
|
} else {
|
||||||
|
showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up credentials modal and actions.
|
* Sets up credentials modal and actions.
|
||||||
*/
|
*/
|
||||||
@ -339,11 +511,13 @@ function setupModalEvents() {
|
|||||||
// Clear credentials
|
// Clear credentials
|
||||||
btnClearCred.addEventListener('click', () => {
|
btnClearCred.addEventListener('click', () => {
|
||||||
abortActiveVerify();
|
abortActiveVerify();
|
||||||
// If a query is active, abort it
|
// If a query (or an admission wait) is active, abort it and release any held ticket
|
||||||
if (activeAbortController) {
|
if (activeAbortController) {
|
||||||
currentRequestGeneration++;
|
currentRequestGeneration++;
|
||||||
|
stopPolling();
|
||||||
activeAbortController.abort();
|
activeAbortController.abort();
|
||||||
activeAbortController = null;
|
activeAbortController = null;
|
||||||
|
leaveQueue().catch(() => {});
|
||||||
switchView('prompt');
|
switchView('prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,19 +595,39 @@ function updateCredentialIndicator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up result view buttons and interactions.
|
* Sets up the loading view's Cancel (running) and Exit queue (queued) buttons.
|
||||||
*/
|
*/
|
||||||
function setupResultEvents() {
|
function setupLoadingEvents() {
|
||||||
// Cancel button during loading
|
// Cancel button: running sub-state only (spec §7.4, unchanged). The server observes the
|
||||||
|
// aborted fetch, ends the run, and promotes the next ticket.
|
||||||
cancelBtn.addEventListener('click', () => {
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
currentRequestGeneration++;
|
||||||
|
stopPolling();
|
||||||
if (activeAbortController) {
|
if (activeAbortController) {
|
||||||
currentRequestGeneration++;
|
|
||||||
activeAbortController.abort();
|
activeAbortController.abort();
|
||||||
activeAbortController = null;
|
activeAbortController = null;
|
||||||
}
|
}
|
||||||
switchView('prompt');
|
switchView('prompt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Exit queue button: queued sub-state only (spec §7.3). Sends leave, stops polling, and
|
||||||
|
// returns to the prompt view with the prompt text preserved (the textarea is never cleared).
|
||||||
|
exitQueueBtn.addEventListener('click', () => {
|
||||||
|
currentRequestGeneration++;
|
||||||
|
stopPolling();
|
||||||
|
if (activeAbortController) {
|
||||||
|
activeAbortController.abort();
|
||||||
|
activeAbortController = null;
|
||||||
|
}
|
||||||
|
leaveQueue().catch(() => {});
|
||||||
|
switchView('prompt');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up result view buttons and interactions.
|
||||||
|
*/
|
||||||
|
function setupResultEvents() {
|
||||||
// Back to prompt button
|
// Back to prompt button
|
||||||
backBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => {
|
||||||
switchView('prompt');
|
switchView('prompt');
|
||||||
@ -508,6 +702,40 @@ function renderResultView(result) {
|
|||||||
historyContent.classList.add('hidden');
|
historyContent.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the queued loading sub-state: shaping orb, position/estimate status line, and the
|
||||||
|
* Exit queue button whose label depends on time waited (spec §7.2, §7.3).
|
||||||
|
* @param {{ position: number, eta_seconds: number|null }} status
|
||||||
|
*/
|
||||||
|
function setLoadingQueuedUI(status) {
|
||||||
|
if (thinkingOrb) thinkingOrb.setState('shaping');
|
||||||
|
loadingStatus.textContent = formatQueuedStatus(status);
|
||||||
|
cancelBtn.classList.add('hidden');
|
||||||
|
exitQueueBtn.classList.remove('hidden');
|
||||||
|
exitQueueBtn.textContent = formatExitQueueLabel(Date.now() - (queueWaitStartedAt ?? Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the brief "ready → sending" loading sub-state: shaping orb, no button (spec §7.2).
|
||||||
|
*/
|
||||||
|
function setLoadingSendingUI() {
|
||||||
|
if (thinkingOrb) thinkingOrb.setState('shaping');
|
||||||
|
loadingStatus.textContent = "Your turn, starting…";
|
||||||
|
cancelBtn.classList.add('hidden');
|
||||||
|
exitQueueBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the running loading sub-state: solving orb, existing status text, Cancel button
|
||||||
|
* (spec §7.2). Also the optimistic default shown while the join request is in flight.
|
||||||
|
*/
|
||||||
|
function setLoadingRunningUI() {
|
||||||
|
if (thinkingOrb) thinkingOrb.setState('solving');
|
||||||
|
loadingStatus.textContent = 'Agent researching Confluence...';
|
||||||
|
exitQueueBtn.classList.add('hidden');
|
||||||
|
cancelBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches the active view.
|
* Switches the active view.
|
||||||
* @param {'prompt'|'loading'|'result'} viewName
|
* @param {'prompt'|'loading'|'result'} viewName
|
||||||
|
|||||||
@ -16,12 +16,12 @@ const REDUCED_MOTION_FRAME = 0.6;
|
|||||||
* Mounts an animated orb on an existing <canvas>.
|
* Mounts an animated orb on an existing <canvas>.
|
||||||
* @param {HTMLCanvasElement} canvas
|
* @param {HTMLCanvasElement} canvas
|
||||||
* @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options]
|
* @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options]
|
||||||
* @returns {{ destroy: () => void } | null}
|
* @returns {{ destroy: () => void, setState: (state: string) => void } | null}
|
||||||
*/
|
*/
|
||||||
export function mountThinkingOrb(canvas, options = {}) {
|
export function mountThinkingOrb(canvas, options = {}) {
|
||||||
if (!canvas || typeof canvas.getContext !== 'function') return null;
|
if (!canvas || typeof canvas.getContext !== 'function') return null;
|
||||||
|
|
||||||
const state = options.state || 'working';
|
let state = options.state || 'working';
|
||||||
const size = options.size === 20 ? 20 : 64;
|
const size = options.size === 20 ? 20 : 64;
|
||||||
const dark = options.theme === 'dark';
|
const dark = options.theme === 'dark';
|
||||||
const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1;
|
const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1;
|
||||||
@ -35,9 +35,26 @@ export function mountThinkingOrb(canvas, options = {}) {
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
||||||
const { mode, speed, opts } = resolvePreset(state, size);
|
let { mode, speed, opts } = resolvePreset(state, size);
|
||||||
const draw = MODE_DRAWS[mode];
|
let draw = MODE_DRAWS[mode];
|
||||||
const clock = speed * speedMultiplier;
|
let clock = speed * speedMultiplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolves the preset for a new state in place, without tearing down the
|
||||||
|
* IntersectionObserver / visibilitychange wiring. A no-op if the state is unchanged.
|
||||||
|
* @param {string} nextState
|
||||||
|
*/
|
||||||
|
const applyState = (nextState) => {
|
||||||
|
if (!nextState || nextState === state) return;
|
||||||
|
state = nextState;
|
||||||
|
({ mode, speed, opts } = resolvePreset(state, size));
|
||||||
|
draw = MODE_DRAWS[mode];
|
||||||
|
clock = speed * speedMultiplier;
|
||||||
|
};
|
||||||
|
// Mirrors the current preset name on the canvas as a plain data attribute (no inline style/
|
||||||
|
// script involved) so tests can assert the orb's sub-state without reaching into this closure.
|
||||||
|
canvas.dataset.orbState = state;
|
||||||
|
const setStateAttr = () => { canvas.dataset.orbState = state; };
|
||||||
|
|
||||||
const paintAt = (t) => {
|
const paintAt = (t) => {
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
@ -48,7 +65,14 @@ export function mountThinkingOrb(canvas, options = {}) {
|
|||||||
const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
paintAt(REDUCED_MOTION_FRAME);
|
paintAt(REDUCED_MOTION_FRAME);
|
||||||
return { destroy() {} };
|
return {
|
||||||
|
destroy() {},
|
||||||
|
setState(nextState) {
|
||||||
|
applyState(nextState);
|
||||||
|
setStateAttr();
|
||||||
|
paintAt(REDUCED_MOTION_FRAME);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
@ -92,6 +116,16 @@ export function mountThinkingOrb(canvas, options = {}) {
|
|||||||
stop();
|
stop();
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Switches the orb to a different preset (e.g. `shaping` while queued, `solving` while
|
||||||
|
* running) without re-creating the visibility/intersection observers.
|
||||||
|
* @param {string} nextState
|
||||||
|
*/
|
||||||
|
setState(nextState) {
|
||||||
|
applyState(nextState);
|
||||||
|
setStateAttr();
|
||||||
|
paintAt((performance.now() / 1000) * clock);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
60
frontend/js/queue.js
Normal file
60
frontend/js/queue.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Pure formatting helpers for the admission queue UI (spec `docs/QUEUE_SPECIFICATION.md` §7.2, §7.3).
|
||||||
|
* Deliberately framework- and DOM-free so they can be unit tested without a browser; the DOM
|
||||||
|
* wiring and polling state machine live in app.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const QUEUE_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* English ordinal suffix for a 1-based rank: 1st, 2nd, 3rd, 4th, 11th-13th, 21st, ...
|
||||||
|
* @param {number} n
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function ordinal(n) {
|
||||||
|
const rem100 = n % 100;
|
||||||
|
if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
|
||||||
|
switch (n % 10) {
|
||||||
|
case 1: return `${n}st`;
|
||||||
|
case 2: return `${n}nd`;
|
||||||
|
case 3: return `${n}rd`;
|
||||||
|
default: return `${n}th`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the estimate clause: "estimating…" when unknown, "under a minute" below 60s,
|
||||||
|
* otherwise "about N min" rounded up (spec §5, §7.2).
|
||||||
|
* @param {number|null|undefined} etaSeconds
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatEta(etaSeconds) {
|
||||||
|
if (etaSeconds === null || etaSeconds === undefined) return 'estimating…';
|
||||||
|
if (etaSeconds < 60) return 'under a minute';
|
||||||
|
const minutes = Math.ceil(etaSeconds / 60);
|
||||||
|
return `about ${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the queued sub-state status line (spec §7.2 table, `queued` row).
|
||||||
|
* Position 1 reads as "You're next" instead of "You're 1st in line".
|
||||||
|
* @param {{ position: number, eta_seconds: number|null }} status
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatQueuedStatus({ position, eta_seconds: etaSeconds }) {
|
||||||
|
const place = position === 1 ? "You're next" : `You're ${ordinal(position)} in line`;
|
||||||
|
return `${place} · ${formatEta(etaSeconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the "Exit queue" button's visible label based on time waited since the join
|
||||||
|
* response (spec §7.3). The button's `aria-label` stays "Leave the queue" regardless.
|
||||||
|
* @param {number} waitedMs
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatExitQueueLabel(waitedMs) {
|
||||||
|
const waitedMinutes = waitedMs / 60000;
|
||||||
|
if (waitedMinutes < 1) return 'I will try next time';
|
||||||
|
if (waitedMinutes <= 3) return "Ohhh, it's so long";
|
||||||
|
return "I'm dying in this queue";
|
||||||
|
}
|
||||||
@ -281,6 +281,211 @@ describe('Mock Server and Wire Contract Tests', () => {
|
|||||||
assert.equal(body.error.code, 'origin_denied');
|
assert.equal(body.error.code, 'origin_denied');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts `cw_session=...` from a Set-Cookie header so subsequent requests in the same
|
||||||
|
* test can carry the session forward (raw fetch has no cookie jar).
|
||||||
|
*/
|
||||||
|
function extractSessionCookie(res, previous) {
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
if (!setCookie) return previous;
|
||||||
|
const match = setCookie.match(/cw_session=[^;]+/);
|
||||||
|
return match ? match[0] : previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Admission queue scenarios (docs/QUEUE_SPECIFICATION.md §10)', () => {
|
||||||
|
test('queue_full: join answers 503 queue_full', async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queue_full`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.error.code, 'queue_full');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued: position 3 with eta, decrements on each poll, ready on the third, query succeeds', async () => {
|
||||||
|
let cookie;
|
||||||
|
const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queued`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
cookie = extractSessionCookie(joinRes, cookie);
|
||||||
|
assert.equal(joinRes.status, 200);
|
||||||
|
assert.equal(joinRes.headers.get('cache-control'), 'no-store');
|
||||||
|
const joinBody = await joinRes.json();
|
||||||
|
assert.equal(joinBody.status, 'queued');
|
||||||
|
assert.equal(joinBody.position, 3);
|
||||||
|
assert.equal(joinBody.ahead, 2);
|
||||||
|
assert.equal(joinBody.eta_seconds, 150);
|
||||||
|
assert.ok(joinBody.ticket_id);
|
||||||
|
|
||||||
|
const poll1 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const poll1Body = await poll1.json();
|
||||||
|
assert.equal(poll1Body.status, 'queued');
|
||||||
|
assert.equal(poll1Body.position, 2);
|
||||||
|
assert.equal(poll1Body.ticket_id, joinBody.ticket_id);
|
||||||
|
|
||||||
|
const poll2 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const poll2Body = await poll2.json();
|
||||||
|
assert.equal(poll2Body.status, 'queued');
|
||||||
|
assert.equal(poll2Body.position, 1);
|
||||||
|
|
||||||
|
const poll3 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const poll3Body = await poll3.json();
|
||||||
|
assert.equal(poll3Body.status, 'ready');
|
||||||
|
assert.ok(poll3Body.reservation_expires_in_seconds > 0);
|
||||||
|
|
||||||
|
const queryRes = await fetch(`${BASE_URL}/api/v1/query?scenario=queued`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie },
|
||||||
|
body: JSON.stringify({ prompt: 'deploy service X', credentials: { url: 'https://example.com', pat: 'dummy' } })
|
||||||
|
});
|
||||||
|
assert.equal(queryRes.status, 200);
|
||||||
|
const queryBody = await queryRes.json();
|
||||||
|
assert.ok(queryBody.markdown.includes('Deployment Guide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued_no_estimate: eta_seconds is null at every step', async () => {
|
||||||
|
let cookie;
|
||||||
|
const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queued_no_estimate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
cookie = extractSessionCookie(joinRes, cookie);
|
||||||
|
const joinBody = await joinRes.json();
|
||||||
|
assert.equal(joinBody.status, 'queued');
|
||||||
|
assert.equal(joinBody.eta_seconds, null);
|
||||||
|
|
||||||
|
const poll1Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json();
|
||||||
|
assert.equal(poll1Body.eta_seconds, null);
|
||||||
|
|
||||||
|
const poll2Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json();
|
||||||
|
assert.equal(poll2Body.eta_seconds, null);
|
||||||
|
|
||||||
|
const poll3Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json();
|
||||||
|
assert.equal(poll3Body.status, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reservation_lost: query 409s once after ready, rejoin succeeds, second query succeeds', async () => {
|
||||||
|
let cookie;
|
||||||
|
const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=reservation_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
cookie = extractSessionCookie(joinRes, cookie);
|
||||||
|
const joinBody = await joinRes.json();
|
||||||
|
assert.equal(joinBody.status, 'ready');
|
||||||
|
|
||||||
|
const firstQuery = await fetch(`${BASE_URL}/api/v1/query?scenario=reservation_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie },
|
||||||
|
body: JSON.stringify({ prompt: 'p', credentials: { url: 'https://example.com', pat: 'dummy' } })
|
||||||
|
});
|
||||||
|
assert.equal(firstQuery.status, 409);
|
||||||
|
const firstQueryBody = await firstQuery.json();
|
||||||
|
assert.equal(firstQueryBody.error.code, 'busy');
|
||||||
|
|
||||||
|
const rejoinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=reservation_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
const rejoinBody = await rejoinRes.json();
|
||||||
|
assert.equal(rejoinBody.status, 'ready');
|
||||||
|
|
||||||
|
const secondQuery = await fetch(`${BASE_URL}/api/v1/query?scenario=reservation_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie },
|
||||||
|
body: JSON.stringify({ prompt: 'p', credentials: { url: 'https://example.com', pat: 'dummy' } })
|
||||||
|
});
|
||||||
|
assert.equal(secondQuery.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticket_lost: second status poll answers 404, rejoin answers ready', async () => {
|
||||||
|
let cookie;
|
||||||
|
const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=ticket_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
cookie = extractSessionCookie(joinRes, cookie);
|
||||||
|
const joinBody = await joinRes.json();
|
||||||
|
assert.equal(joinBody.status, 'queued');
|
||||||
|
|
||||||
|
const poll1Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=ticket_lost`, { headers: { Cookie: cookie } })).json();
|
||||||
|
assert.equal(poll1Body.status, 'queued');
|
||||||
|
|
||||||
|
const poll2 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=ticket_lost`, { headers: { Cookie: cookie } });
|
||||||
|
assert.equal(poll2.status, 404);
|
||||||
|
const poll2Body = await poll2.json();
|
||||||
|
assert.equal(poll2Body.error.code, 'ticket_not_found');
|
||||||
|
|
||||||
|
const rejoinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=ticket_lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
const rejoinBody = await rejoinRes.json();
|
||||||
|
assert.equal(rejoinBody.status, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/queue/status with no ticket answers 404 ticket_not_found', async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=normal`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.error.code, 'ticket_not_found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/v1/queue/ticket always answers 204', async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/queue/ticket`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Origin: BASE_URL }
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 204);
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/v1/queue/join rejects a body containing credentials or pat', async () => {
|
||||||
|
const withCredentials = await fetch(`${BASE_URL}/api/v1/queue/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: BASE_URL },
|
||||||
|
body: JSON.stringify({ credentials: { url: 'https://x', pat: 'y' } })
|
||||||
|
});
|
||||||
|
assert.equal(withCredentials.status, 400);
|
||||||
|
assert.equal((await withCredentials.json()).error.code, 'invalid_input');
|
||||||
|
|
||||||
|
const withPat = await fetch(`${BASE_URL}/api/v1/queue/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: BASE_URL },
|
||||||
|
body: JSON.stringify({ pat: 'y' })
|
||||||
|
});
|
||||||
|
assert.equal(withPat.status, 400);
|
||||||
|
assert.equal((await withPat.json()).error.code, 'invalid_input');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-queue scenarios answer join with ready immediately (unchanged flows)', async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=normal`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: BASE_URL },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.status, 'ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('Mock dev toolbar external assets served with correct headers and zero inline script/style', async () => {
|
test('Mock dev toolbar external assets served with correct headers and zero inline script/style', async () => {
|
||||||
// CSS asset
|
// CSS asset
|
||||||
const cssRes = await fetch(`${BASE_URL}/dev/scenario-toolbar.css`);
|
const cssRes = await fetch(`${BASE_URL}/dev/scenario-toolbar.css`);
|
||||||
|
|||||||
@ -557,7 +557,153 @@ async function runTests() {
|
|||||||
assert.equal(modalReopened, true, 'Modal should open when attempting query without credentials');
|
assert.equal(modalReopened, true, 'Modal should open when attempting query without credentials');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 16: Zero Automatic External Requests Network Assertion
|
// Test 16: Admission Queue - queued -> ready -> result (status text & orb state)
|
||||||
|
await step('Admission Queue: queued -> ready -> result', async () => {
|
||||||
|
// Credentials were cleared in the previous test; reconfigure them.
|
||||||
|
await cdp.eval('document.getElementById("key-btn").click()');
|
||||||
|
await cdp.eval(`
|
||||||
|
document.getElementById("cred-url").value = "https://approved.example.com";
|
||||||
|
document.getElementById("cred-pat").value = "dummy-pat-123";
|
||||||
|
`);
|
||||||
|
await cdp.eval('document.getElementById("btn-save-cred").click()');
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
await cdp.eval(`
|
||||||
|
fetch("/dev/scenario", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scenario: "queued" })
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
|
||||||
|
await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"');
|
||||||
|
await cdp.eval('document.getElementById("submit-btn").click()');
|
||||||
|
|
||||||
|
// Join answers position 3 / eta 150s ("about 3 min") immediately.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const joinState = await cdp.eval(`({
|
||||||
|
status: document.getElementById("loading-status").textContent,
|
||||||
|
orb: document.getElementById("thinking-orb").dataset.orbState,
|
||||||
|
exitVisible: !document.getElementById("exit-queue-btn").classList.contains("hidden"),
|
||||||
|
cancelHidden: document.getElementById("cancel-btn").classList.contains("hidden"),
|
||||||
|
ariaLabel: document.getElementById("exit-queue-btn").getAttribute("aria-label")
|
||||||
|
})`);
|
||||||
|
assert.equal(joinState.status, "You're 3rd in line · about 3 min", `Unexpected queued status: ${joinState.status}`);
|
||||||
|
assert.equal(joinState.orb, 'shaping', 'Orb must be in the "shaping" preset while queued');
|
||||||
|
assert.equal(joinState.exitVisible, true, 'Exit queue button must be visible while queued');
|
||||||
|
assert.equal(joinState.cancelHidden, true, 'Cancel button must be hidden while queued');
|
||||||
|
assert.equal(joinState.ariaLabel, 'Leave the queue', 'Exit queue aria-label must stay stable');
|
||||||
|
|
||||||
|
// First poll (~2s later): position 2 / eta 95s ("about 2 min").
|
||||||
|
await new Promise((r) => setTimeout(r, 2200));
|
||||||
|
const poll1Status = await cdp.eval('document.getElementById("loading-status").textContent');
|
||||||
|
assert.equal(poll1Status, "You're 2nd in line · about 2 min", `Unexpected poll 1 status: ${poll1Status}`);
|
||||||
|
|
||||||
|
// Second poll: position 1 / eta 40s ("under a minute").
|
||||||
|
await new Promise((r) => setTimeout(r, 2200));
|
||||||
|
const poll2Status = await cdp.eval('document.getElementById("loading-status").textContent');
|
||||||
|
assert.equal(poll2Status, "You're next · under a minute", `Unexpected poll 2 status: ${poll2Status}`);
|
||||||
|
|
||||||
|
// Third poll: ready -> query sent -> result.
|
||||||
|
await new Promise((r) => setTimeout(r, 2600));
|
||||||
|
const resultOrbState = await cdp.eval(`({
|
||||||
|
resultVisible: !document.getElementById("view-result").classList.contains("hidden"),
|
||||||
|
orb: document.getElementById("thinking-orb").dataset.orbState
|
||||||
|
})`);
|
||||||
|
assert.equal(resultOrbState.resultVisible, true, 'Result view must be shown once the queue admits the session');
|
||||||
|
assert.equal(resultOrbState.orb, 'solving', 'Orb must switch back to "solving" once the query is sent');
|
||||||
|
|
||||||
|
// Back to prompt for the next tests.
|
||||||
|
await cdp.eval('document.getElementById("back-btn").click()');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 17: Admission Queue - Exit queue button (label timing, clock stubbed) & prompt preserved
|
||||||
|
await step('Admission Queue: Exit queue label timing & prompt preserved', async () => {
|
||||||
|
// Stub the wait-start clock so the 1 min / 3 min label thresholds can be exercised
|
||||||
|
// without a real multi-minute wait (spec §11).
|
||||||
|
await cdp.eval(`
|
||||||
|
window.__realDateNow = Date.now.bind(Date);
|
||||||
|
window.__queueClockOffsetMs = 0;
|
||||||
|
Date.now = () => window.__realDateNow() + window.__queueClockOffsetMs;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// A fresh scenario (independent counters, reset-on-scenario-change per spec §10) so this
|
||||||
|
// flow starts back at "queued" position 3 regardless of the previous test's poll count.
|
||||||
|
await cdp.eval(`
|
||||||
|
fetch("/dev/scenario", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scenario: "queued_no_estimate" })
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
|
||||||
|
await cdp.eval('document.getElementById("prompt-input").value = "Exit queue test prompt"');
|
||||||
|
await cdp.eval('document.getElementById("submit-btn").click()');
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const initialLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent');
|
||||||
|
assert.equal(initialLabel, 'I will try next time', `Expected the under-1-min label, got: ${initialLabel}`);
|
||||||
|
|
||||||
|
// Push the stubbed clock past the 1 minute mark before the next poll refreshes the label.
|
||||||
|
await cdp.eval('window.__queueClockOffsetMs = 65000;');
|
||||||
|
await new Promise((r) => setTimeout(r, 2200));
|
||||||
|
const midLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent');
|
||||||
|
assert.equal(midLabel, "Ohhh, it's so long", `Expected the 1-3 min label, got: ${midLabel}`);
|
||||||
|
|
||||||
|
// Push past the 3 minute mark before the next poll.
|
||||||
|
await cdp.eval('window.__queueClockOffsetMs = 200000;');
|
||||||
|
await new Promise((r) => setTimeout(r, 2200));
|
||||||
|
const lateLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent');
|
||||||
|
assert.equal(lateLabel, "I'm dying in this queue", `Expected the over-3-min label, got: ${lateLabel}`);
|
||||||
|
|
||||||
|
const ariaLabel = await cdp.eval('document.getElementById("exit-queue-btn").getAttribute("aria-label")');
|
||||||
|
assert.equal(ariaLabel, 'Leave the queue', 'aria-label must stay stable while the visible label changes');
|
||||||
|
|
||||||
|
// Restore the real clock before leaving.
|
||||||
|
await cdp.eval('Date.now = window.__realDateNow;');
|
||||||
|
|
||||||
|
// Exit queue: returns to the prompt view with the prompt text preserved, no confirmation.
|
||||||
|
await cdp.eval('document.getElementById("exit-queue-btn").click()');
|
||||||
|
const afterExit = await cdp.eval(`({
|
||||||
|
promptVisible: !document.getElementById("view-prompt").classList.contains("hidden"),
|
||||||
|
loadingHidden: document.getElementById("view-loading").classList.contains("hidden"),
|
||||||
|
promptValue: document.getElementById("prompt-input").value
|
||||||
|
})`);
|
||||||
|
assert.equal(afterExit.promptVisible, true, 'Exiting the queue must return to the prompt view');
|
||||||
|
assert.equal(afterExit.loadingHidden, true, 'Loading view must be hidden after exiting the queue');
|
||||||
|
assert.equal(afterExit.promptValue, 'Exit queue test prompt', 'Prompt text must be preserved after exiting the queue');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 18: Admission Queue - reservation lost right after "ready" rejoins once and succeeds
|
||||||
|
await step('Admission Queue: reservation lost rejoins once', async () => {
|
||||||
|
await cdp.eval(`
|
||||||
|
fetch("/dev/scenario", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scenario: "reservation_lost" })
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
|
||||||
|
await cdp.eval('document.getElementById("submit-btn").click()');
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
|
||||||
|
const isResultVisible = await cdp.eval('!document.getElementById("view-result").classList.contains("hidden")');
|
||||||
|
assert.equal(isResultVisible, true, 'A reservation lost right after "ready" must transparently rejoin once and still succeed');
|
||||||
|
|
||||||
|
const promptErrorHidden = await cdp.eval('document.getElementById("prompt-error").classList.contains("hidden")');
|
||||||
|
assert.equal(promptErrorHidden, true, 'No busy error should surface to the user after the automatic rejoin');
|
||||||
|
|
||||||
|
await cdp.eval('document.getElementById("back-btn").click()');
|
||||||
|
await cdp.eval(`
|
||||||
|
fetch("/dev/scenario", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scenario: "normal" })
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 19: Zero Automatic External Requests Network Assertion
|
||||||
await step('Zero Automatic External Requests Network Assertion', async () => {
|
await step('Zero Automatic External Requests Network Assertion', async () => {
|
||||||
// Filter any requests initiated to destinations other than loopback mock server or internal data/blob URLs
|
// Filter any requests initiated to destinations other than loopback mock server or internal data/blob URLs
|
||||||
const externalRequests = cdp.networkRequests.filter((r) => {
|
const externalRequests = cdp.networkRequests.filter((r) => {
|
||||||
|
|||||||
82
frontend/tests/queue.test.js
Normal file
82
frontend/tests/queue.test.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the pure admission-queue formatting helpers (js/queue.js).
|
||||||
|
* Covers the exact wording required by docs/QUEUE_SPECIFICATION.md §7.2 and §7.3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ordinal, formatEta, formatQueuedStatus, formatExitQueueLabel } from '../js/queue.js';
|
||||||
|
|
||||||
|
describe('ordinal', () => {
|
||||||
|
test('handles 1st, 2nd, 3rd, 4th', () => {
|
||||||
|
assert.equal(ordinal(1), '1st');
|
||||||
|
assert.equal(ordinal(2), '2nd');
|
||||||
|
assert.equal(ordinal(3), '3rd');
|
||||||
|
assert.equal(ordinal(4), '4th');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles the 11th-13th exception to the last-digit rule', () => {
|
||||||
|
assert.equal(ordinal(11), '11th');
|
||||||
|
assert.equal(ordinal(12), '12th');
|
||||||
|
assert.equal(ordinal(13), '13th');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles 21st, 22nd, 23rd and higher tens', () => {
|
||||||
|
assert.equal(ordinal(21), '21st');
|
||||||
|
assert.equal(ordinal(22), '22nd');
|
||||||
|
assert.equal(ordinal(23), '23rd');
|
||||||
|
assert.equal(ordinal(101), '101st');
|
||||||
|
assert.equal(ordinal(111), '111th');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatEta', () => {
|
||||||
|
test('null or undefined reads as "estimating…"', () => {
|
||||||
|
assert.equal(formatEta(null), 'estimating…');
|
||||||
|
assert.equal(formatEta(undefined), 'estimating…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('below 60s reads as "under a minute"', () => {
|
||||||
|
assert.equal(formatEta(0), 'under a minute');
|
||||||
|
assert.equal(formatEta(59), 'under a minute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('60s and above rounds up to "about N min"', () => {
|
||||||
|
assert.equal(formatEta(60), 'about 1 min');
|
||||||
|
assert.equal(formatEta(61), 'about 2 min');
|
||||||
|
assert.equal(formatEta(150), 'about 3 min');
|
||||||
|
assert.equal(formatEta(120), 'about 2 min');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatQueuedStatus', () => {
|
||||||
|
test('position 1 reads as "You\'re next"', () => {
|
||||||
|
assert.equal(formatQueuedStatus({ position: 1, eta_seconds: 40 }), "You're next · under a minute");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position > 1 uses the ordinal', () => {
|
||||||
|
assert.equal(formatQueuedStatus({ position: 3, eta_seconds: 220 }), "You're 3rd in line · about 4 min");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null estimate reads as "estimating…"', () => {
|
||||||
|
assert.equal(formatQueuedStatus({ position: 3, eta_seconds: null }), "You're 3rd in line · estimating…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatExitQueueLabel', () => {
|
||||||
|
test('under 1 minute waited', () => {
|
||||||
|
assert.equal(formatExitQueueLabel(0), 'I will try next time');
|
||||||
|
assert.equal(formatExitQueueLabel(59_000), 'I will try next time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1-3 minutes waited (inclusive)', () => {
|
||||||
|
assert.equal(formatExitQueueLabel(60_000), "Ohhh, it's so long");
|
||||||
|
assert.equal(formatExitQueueLabel(90_000), "Ohhh, it's so long");
|
||||||
|
assert.equal(formatExitQueueLabel(180_000), "Ohhh, it's so long");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('over 3 minutes waited', () => {
|
||||||
|
assert.equal(formatExitQueueLabel(180_001), "I'm dying in this queue");
|
||||||
|
assert.equal(formatExitQueueLabel(600_000), "I'm dying in this queue");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user