Artur Mukhamadiev 7d6429aca9 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.
2026-09-15 13:38:59 +03:00

308 lines
9.1 KiB
JavaScript

/**
* API client boundary for same-origin backend communication.
* Strictly uses relative URLs (/api/v1/...) with no mock toggles or hardcoded origins.
*/
const MAX_PROMPT_BYTES = 16 * 1024 * 1024; // 16 MiB
const MAX_FIELD_BYTES = 8 * 1024; // 8 KiB for Confluence URL / PAT
/**
* Returns the UTF-8 byte length of a string.
* @param {string} str
* @returns {number}
*/
export function getUtf8ByteLength(str) {
if (typeof str !== 'string') return 0;
return new TextEncoder().encode(str).length;
}
/**
* Validates Confluence URL and PAT strings according to contract limits.
* @param {{ url: string, pat: string }} credentials
* @throws {Error} If credentials fail validation
*/
export function validateCredentials({ url, pat }) {
if (!url || typeof url !== 'string' || !url.trim()) {
const err = new Error('Confluence URL is required.');
err.code = 'invalid_input';
throw err;
}
if (!pat || typeof pat !== 'string' || !pat.trim()) {
const err = new Error('Personal Access Token (PAT) is required.');
err.code = 'invalid_input';
throw err;
}
const urlBytes = getUtf8ByteLength(url);
if (urlBytes > MAX_FIELD_BYTES) {
const err = new Error('Confluence URL exceeds the 8 KiB limit.');
err.code = 'invalid_input';
throw err;
}
const patBytes = getUtf8ByteLength(pat);
if (patBytes > MAX_FIELD_BYTES) {
const err = new Error('Personal Access Token exceeds the 8 KiB limit.');
err.code = 'invalid_input';
throw err;
}
try {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error();
}
} catch {
const err = new Error('Confluence URL must be a valid HTTP or HTTPS URL.');
err.code = 'invalid_input';
throw err;
}
}
/**
* 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<Error>}
*/
async function parseApiError(response, { notFoundCode = 'artifact_not_found' } = {}) {
let code = 'execution_failed';
let message = `Request failed with status ${response.status}`;
let rawText = '';
try {
rawText = await response.text();
if (rawText) {
const data = JSON.parse(rawText);
if (data && data.error) {
if (typeof data.error.code === 'string') {
code = data.error.code;
}
if (typeof data.error.message === 'string') {
message = data.error.message;
}
const err = new Error(message);
err.code = code;
err.status = response.status;
return err;
}
}
} catch {
// Non-JSON body fallback
}
// Non-JSON or empty error body fallback based on HTTP status
if (response.status === 400) {
code = 'invalid_input';
} else if (response.status === 403) {
// Differentiate origin / destination denials from PAT failures per CONTRACTS §2
const lower = (rawText || response.statusText || '').toLowerCase();
if (lower.includes('origin')) {
code = 'origin_denied';
message = 'Request forbidden: origin denied.';
} else if (lower.includes('destination') || lower.includes('host')) {
code = 'destination_denied';
message = 'Request forbidden: destination URL not permitted.';
} else if (lower.includes('auth') || lower.includes('pat') || lower.includes('token') || lower.includes('credential')) {
code = 'confluence_auth_failed';
message = 'Confluence authentication failed.';
} else {
// Ingress / reverse proxy 403 rejection
code = 'origin_denied';
message = 'Request forbidden by server policy.';
}
} else if (response.status === 404) {
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';
}
const err = new Error(message);
err.code = code;
err.status = response.status;
return err;
}
/**
* Verifies credentials against the backend.
* @param {{ url: string, pat: string }} credentials
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<{ valid: boolean }>}
*/
export async function verifyCredentials(credentials, { signal } = {}) {
validateCredentials(credentials);
const response = await fetch('/api/v1/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: credentials.url.trim(),
pat: credentials.pat.trim()
}),
signal
});
if (!response.ok) {
throw await parseApiError(response);
}
return await response.json();
}
/**
* Submits a research prompt with credentials.
* @param {{ prompt: string, credentials: { url: string, pat: string } }} params
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<object>} Query result object
*/
export async function submitQuery({ prompt, credentials }, { signal } = {}) {
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
const err = new Error('Prompt cannot be empty.');
err.code = 'invalid_input';
throw err;
}
const promptBytes = getUtf8ByteLength(prompt);
if (promptBytes > MAX_PROMPT_BYTES) {
const err = new Error(`Prompt exceeds the 16 MiB UTF-8 limit (${promptBytes} bytes).`);
err.code = 'invalid_input';
throw err;
}
validateCredentials(credentials);
const response = await fetch('/api/v1/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
prompt: prompt, // Preserve original untrimmed text inside payload while verifying trimmed was non-empty
credentials: {
url: credentials.url.trim(),
pat: credentials.pat.trim()
}
}),
signal
});
if (!response.ok) {
throw await parseApiError(response);
}
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.
* @param {string} artifactId
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<{ blob: Blob, filename: string }>}
*/
export async function downloadArtifact(artifactId, { signal } = {}) {
if (!artifactId || typeof artifactId !== 'string') {
const err = new Error('Invalid artifact ID.');
err.code = 'invalid_input';
throw err;
}
const response = await fetch(`/api/v1/artifacts/${encodeURIComponent(artifactId)}`, {
method: 'GET',
signal
});
if (!response.ok) {
throw await parseApiError(response);
}
// Extract filename from Content-Disposition if present
let filename = 'download';
const disposition = response.headers.get('Content-Disposition');
if (disposition) {
const match = disposition.match(/filename=["']?([^"';]+)["']?/i);
if (match && match[1]) {
filename = match[1].trim();
}
}
const blob = await response.blob();
return { blob, filename };
}