import type { Choice } from '../api'; /** One picker-local, session-bound choice. Identity also guards stale completions. */ export interface PendingChoice { sessionId: string; scope: object; choice: Choice; closeAfter: boolean; } export interface ChoiceQueue { sessionId: string; scope: object; busy: boolean; pending: PendingChoice | null; inflight: PendingChoice | null; ready: boolean; retryOnIdle: boolean; problem: string; } export function choiceQueue(sessionId: string, busy: boolean): ChoiceQueue { return { sessionId, scope: {}, busy, pending: null, inflight: null, ready: false, retryOnIdle: false, problem: '' }; } /** A busy rejection cannot retry until a later observed busy -> idle transition. */ export function observeChoice(queue: ChoiceQueue, sessionId: string, busy: boolean): ChoiceQueue { if (queue.sessionId !== sessionId) { return { ...choiceQueue(sessionId, busy), // An already issued request still belongs to the old session. Serialize // behind it, but never reuse its choice or completion in the new session. inflight: queue.inflight, problem: queue.pending ? 'The unsaved model choice was discarded because the session changed.' : '', }; } if (queue.busy === busy) return queue; return { ...queue, busy, ready: queue.ready || (queue.busy && !busy && queue.retryOnIdle) }; } export function chooseModel(queue: ChoiceQueue, choice: Choice, closeAfter: boolean): ChoiceQueue { return { ...queue, pending: { sessionId: queue.sessionId, scope: queue.scope, choice, closeAfter }, ready: !queue.busy, retryOnIdle: queue.busy, problem: '', }; } export function retryChoice(queue: ChoiceQueue): ChoiceQueue { if (!queue.pending) return queue; return { ...queue, ready: !queue.busy, retryOnIdle: queue.busy, problem: '' }; } /** Never overlap configuration PATCHes, even after a rapid newer selection. */ export function startChoice(queue: ChoiceQueue): ChoiceQueue { if (!queue.pending || queue.inflight || queue.busy || !queue.ready) return queue; return { ...queue, inflight: queue.pending, ready: false, retryOnIdle: false }; } export function finishChoice( queue: ChoiceQueue, request: PendingChoice, failure: { code: string | null; message: string } | null, ): ChoiceQueue { if (queue.inflight !== request) return queue; const released = { ...queue, inflight: null }; // A new session owns the UI now; the old request cannot affect it. if (queue.scope !== request.scope || !queue.pending) return released; if (queue.pending !== request) { // Keep the latest choice, but a failed/unknown earlier write still needs // acknowledgement before reading a fresh ETag and issuing another PATCH. if (failure && failure.code !== 'session_busy') { return { ...released, ready: false, retryOnIdle: false, problem: `The previous model change failed: ${failure.message}. The latest choice is not saved.` }; } return released; } if (!failure) return { ...released, pending: null, ready: false, retryOnIdle: false, problem: '' }; if (failure.code === 'session_busy') { return { ...released, ready: false, retryOnIdle: true, problem: '' }; } // Validation, CAS and unknown-outcome failures need a deliberate retry, not // another automatic PATCH against a freshly read configuration ETag. return { ...released, ready: false, retryOnIdle: false, problem: failure.message }; }