feat(queue): implement queue scheduler service and API routes

- Added QueueSchedulerService to manage task queue lifecycle, including state machine, dependency resolution, and session management.
- Implemented HTTP API endpoints for queue scheduling:
  - POST /api/queue/execute: Submit items to the scheduler.
  - GET /api/queue/scheduler/state: Retrieve full scheduler state.
  - POST /api/queue/scheduler/start: Start scheduling loop with items.
  - POST /api/queue/scheduler/pause: Pause scheduling.
  - POST /api/queue/scheduler/stop: Graceful stop of the scheduler.
  - POST /api/queue/scheduler/config: Update scheduler configuration.
- Introduced types for queue items, scheduler state, and WebSocket messages to ensure type safety and compatibility with the backend.
- Added static model lists for LiteLLM as a fallback for available models.
This commit is contained in:
catlog22
2026-02-27 20:53:46 +08:00
parent 5b54f38aa3
commit 75173312c1
47 changed files with 3813 additions and 307 deletions

View File

@@ -0,0 +1,351 @@
// ========================================
// Queue Scheduler Store
// ========================================
// Zustand store for queue scheduler state management.
// Handles WebSocket message dispatch, API actions, and provides
// granular selectors for efficient React re-renders.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
QueueSchedulerStatus,
QueueSchedulerConfig,
QueueItem,
QueueSchedulerState,
QueueWSMessage,
SessionBinding,
} from '../types/queue-frontend-types';
// ========== Default Config ==========
const DEFAULT_CONFIG: QueueSchedulerConfig = {
maxConcurrentSessions: 2,
sessionIdleTimeoutMs: 60_000,
resumeKeySessionBindingTimeoutMs: 300_000,
};
// ========== Store State Interface ==========
/**
* Store state extends the wire format QueueSchedulerState with
* nullable fields for "not yet loaded" state.
*/
interface QueueSchedulerStoreState {
status: QueueSchedulerStatus;
items: QueueItem[];
sessionPool: Record<string, SessionBinding>;
config: QueueSchedulerConfig;
currentConcurrency: number;
lastActivityAt: string | null;
error: string | null;
}
// ========== Actions Interface ==========
interface QueueSchedulerActions {
/** Dispatch a WebSocket message to update store state */
handleSchedulerMessage: (msg: QueueWSMessage) => void;
/** Fetch initial state from GET /api/queue/scheduler/state */
loadInitialState: () => Promise<void>;
/** Submit items to the queue via POST /api/queue/execute (auto-starts if idle) */
submitItems: (items: QueueItem[]) => Promise<void>;
/** Start the queue scheduler via POST /api/queue/scheduler/start */
startQueue: (items?: QueueItem[]) => Promise<void>;
/** Pause the queue scheduler via POST /api/queue/scheduler/pause */
pauseQueue: () => Promise<void>;
/** Stop the queue scheduler via POST /api/queue/scheduler/stop */
stopQueue: () => Promise<void>;
/** Update scheduler config via POST /api/queue/scheduler/config */
updateConfig: (config: Partial<QueueSchedulerConfig>) => Promise<void>;
}
export type QueueSchedulerStore = QueueSchedulerStoreState & QueueSchedulerActions;
// ========== Initial State ==========
const initialState: QueueSchedulerStoreState = {
status: 'idle',
items: [],
sessionPool: {},
config: DEFAULT_CONFIG,
currentConcurrency: 0,
lastActivityAt: null,
error: null,
};
// ========== Store ==========
export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
devtools(
(set) => ({
...initialState,
// ========== WebSocket Message Handler ==========
handleSchedulerMessage: (msg: QueueWSMessage) => {
switch (msg.type) {
case 'QUEUE_SCHEDULER_STATE_UPDATE':
// Backend sends full state snapshot
set(
{
status: msg.state.status,
items: msg.state.items,
sessionPool: msg.state.sessionPool,
config: msg.state.config,
currentConcurrency: msg.state.currentConcurrency,
lastActivityAt: msg.state.lastActivityAt,
error: msg.state.error ?? null,
},
false,
'handleSchedulerMessage/QUEUE_SCHEDULER_STATE_UPDATE'
);
break;
case 'QUEUE_ITEM_ADDED':
set(
(state) => ({
items: [...state.items, msg.item],
}),
false,
'handleSchedulerMessage/QUEUE_ITEM_ADDED'
);
break;
case 'QUEUE_ITEM_UPDATED':
set(
(state) => ({
items: state.items.map((item) =>
item.item_id === msg.item.item_id ? msg.item : item
),
}),
false,
'handleSchedulerMessage/QUEUE_ITEM_UPDATED'
);
break;
case 'QUEUE_ITEM_REMOVED':
set(
(state) => ({
items: state.items.filter((item) => item.item_id !== msg.item_id),
}),
false,
'handleSchedulerMessage/QUEUE_ITEM_REMOVED'
);
break;
case 'QUEUE_SCHEDULER_CONFIG_UPDATED':
set(
{
config: msg.config,
},
false,
'handleSchedulerMessage/QUEUE_SCHEDULER_CONFIG_UPDATED'
);
break;
// No default - all 5 message types are handled exhaustively
}
},
// ========== API Actions ==========
submitItems: async (items: QueueItem[]) => {
try {
const response = await fetch('/api/queue/execute', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
// State will be updated via WebSocket broadcast from backend
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] submitItems error:', message);
set({ error: message }, false, 'submitItems/error');
throw error;
}
},
loadInitialState: async () => {
try {
const response = await fetch('/api/queue/scheduler/state', {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to load scheduler state: ${response.statusText}`);
}
const data: QueueSchedulerState = await response.json();
set(
{
status: data.status,
items: data.items,
sessionPool: data.sessionPool,
config: data.config,
currentConcurrency: data.currentConcurrency,
lastActivityAt: data.lastActivityAt,
error: data.error ?? null,
},
false,
'loadInitialState'
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] loadInitialState error:', message);
set({ error: message }, false, 'loadInitialState/error');
}
},
startQueue: async (items?: QueueItem[]) => {
try {
const body = items ? { items } : {};
const response = await fetch('/api/queue/scheduler/start', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] startQueue error:', message);
set({ error: message }, false, 'startQueue/error');
}
},
pauseQueue: async () => {
try {
const response = await fetch('/api/queue/scheduler/pause', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] pauseQueue error:', message);
set({ error: message }, false, 'pauseQueue/error');
}
},
stopQueue: async () => {
try {
const response = await fetch('/api/queue/scheduler/stop', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] stopQueue error:', message);
set({ error: message }, false, 'stopQueue/error');
}
},
updateConfig: async (config: Partial<QueueSchedulerConfig>) => {
try {
const response = await fetch('/api/queue/scheduler/config', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] updateConfig error:', message);
set({ error: message }, false, 'updateConfig/error');
}
},
}),
{ name: 'QueueSchedulerStore' }
)
);
// ========== Selectors ==========
/** Stable empty array to avoid new references on each call */
const EMPTY_ITEMS: QueueItem[] = [];
/** Select current scheduler status */
export const selectQueueSchedulerStatus = (state: QueueSchedulerStore): QueueSchedulerStatus =>
state.status;
/** Select all queue items */
export const selectQueueItems = (state: QueueSchedulerStore): QueueItem[] =>
state.items;
/**
* Select items that are ready to execute (status 'queued' or 'pending').
* WARNING: Returns new array each call - use with useMemo in components.
*/
export const selectReadyItems = (state: QueueSchedulerStore): QueueItem[] => {
const ready = state.items.filter(
(item) => item.status === 'queued' || item.status === 'pending'
);
return ready.length === 0 ? EMPTY_ITEMS : ready;
};
/**
* Select items that are blocked (status 'blocked').
* WARNING: Returns new array each call - use with useMemo in components.
*/
export const selectBlockedItems = (state: QueueSchedulerStore): QueueItem[] => {
const blocked = state.items.filter((item) => item.status === 'blocked');
return blocked.length === 0 ? EMPTY_ITEMS : blocked;
};
/**
* Select items currently executing (status 'executing').
* WARNING: Returns new array each call - use with useMemo in components.
*/
export const selectExecutingItems = (state: QueueSchedulerStore): QueueItem[] => {
const executing = state.items.filter((item) => item.status === 'executing');
return executing.length === 0 ? EMPTY_ITEMS : executing;
};
/**
* Calculate overall scheduler progress as a percentage (0-100).
* Progress = (completed + failed) / total * 100.
* Returns 0 when there are no items.
*/
export const selectSchedulerProgress = (state: QueueSchedulerStore): number => {
const total = state.items.length;
if (total === 0) return 0;
const terminal = state.items.filter(
(item) => item.status === 'completed' || item.status === 'failed'
).length;
return Math.round((terminal / total) * 100);
};
/** Select scheduler config */
export const selectSchedulerConfig = (state: QueueSchedulerStore): QueueSchedulerConfig =>
state.config;
/** Select session pool */
export const selectSessionPool = (state: QueueSchedulerStore): Record<string, SessionBinding> =>
state.sessionPool;
/** Select current concurrency */
export const selectCurrentConcurrency = (state: QueueSchedulerStore): number =>
state.currentConcurrency;
/** Select scheduler error */
export const selectSchedulerError = (state: QueueSchedulerStore): string | null =>
state.error;