mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
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:
93
ccw/src/config/litellm-static-models.ts
Normal file
93
ccw/src/config/litellm-static-models.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* LiteLLM Static Model Lists (Fallback)
|
||||
*
|
||||
* Sourced from LiteLLM's internal model lists.
|
||||
* Used as fallback when user config has no availableModels defined.
|
||||
*
|
||||
* Last updated: 2026-02-27
|
||||
* Source: Python litellm module static lists
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from CLI tool names to LiteLLM provider model lists
|
||||
*/
|
||||
export const LITELLM_STATIC_MODELS: Record<string, ModelInfo[]> = {
|
||||
// Gemini models (from litellm.gemini_models)
|
||||
gemini: [
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
|
||||
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||
{ id: 'gemini-2.0-pro-exp-02-05', name: 'Gemini 2.0 Pro Exp' },
|
||||
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' },
|
||||
{ id: 'gemini-1.5-pro-latest', name: 'Gemini 1.5 Pro Latest' },
|
||||
{ id: 'gemini-embedding-001', name: 'Gemini Embedding 001' }
|
||||
],
|
||||
|
||||
// OpenAI models (from litellm.open_ai_chat_completion_models)
|
||||
codex: [
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2' },
|
||||
{ id: 'gpt-5.1-chat-latest', name: 'GPT-5.1 Chat Latest' },
|
||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||
{ id: 'o4-mini-2025-04-16', name: 'O4 Mini' },
|
||||
{ id: 'o3', name: 'O3' },
|
||||
{ id: 'o1-mini', name: 'O1 Mini' },
|
||||
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }
|
||||
],
|
||||
|
||||
// Anthropic models (from litellm.anthropic_models)
|
||||
claude: [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
||||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
|
||||
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
|
||||
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
|
||||
],
|
||||
|
||||
// OpenAI models for opencode (via LiteLLM proxy)
|
||||
opencode: [
|
||||
{ id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free' },
|
||||
{ id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano' },
|
||||
{ id: 'opencode/grok-code', name: 'Grok Code' },
|
||||
{ id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free' }
|
||||
],
|
||||
|
||||
// Qwen models
|
||||
qwen: [
|
||||
{ id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B' },
|
||||
{ id: 'qwen2.5-coder', name: 'Qwen 2.5 Coder' },
|
||||
{ id: 'qwen2.5-72b', name: 'Qwen 2.5 72B' },
|
||||
{ id: 'qwen2-72b', name: 'Qwen 2 72B' },
|
||||
{ id: 'coder-model', name: 'Qwen Coder' },
|
||||
{ id: 'vision-model', name: 'Qwen Vision' }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Get fallback models for a tool
|
||||
* @param toolId - Tool identifier (e.g., 'gemini', 'claude', 'codex')
|
||||
* @returns Array of model info, or empty array if not found
|
||||
*/
|
||||
export function getFallbackModels(toolId: string): ModelInfo[] {
|
||||
return LITELLM_STATIC_MODELS[toolId] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool has fallback models defined
|
||||
* @param toolId - Tool identifier
|
||||
* @returns true if fallback models exist
|
||||
*/
|
||||
export function hasFallbackModels(toolId: string): boolean {
|
||||
return toolId in LITELLM_STATIC_MODELS;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* CLI Tool Model Reference Library
|
||||
* CLI Tool Model Type Definitions
|
||||
*
|
||||
* System reference for available models per CLI tool provider.
|
||||
* This is a read-only reference, NOT user configuration.
|
||||
* User configuration is managed via tools.{tool}.primaryModel/secondaryModel in cli-tools.json
|
||||
* Type definitions for CLI tool models.
|
||||
* Model lists are now read from user configuration (cli-tools.json).
|
||||
* Each tool can define availableModels in its configuration.
|
||||
*/
|
||||
|
||||
export interface ProviderModelInfo {
|
||||
@@ -19,105 +19,5 @@ export interface ProviderInfo {
|
||||
models: ProviderModelInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* System reference for CLI tool models
|
||||
* Maps provider names to their available models
|
||||
*/
|
||||
export const PROVIDER_MODELS: Record<string, ProviderInfo> = {
|
||||
google: {
|
||||
name: 'Google AI',
|
||||
models: [
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', capabilities: ['text', 'vision', 'code'], contextWindow: 1000000 },
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', capabilities: ['text', 'code'], contextWindow: 1000000 },
|
||||
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', capabilities: ['text'], contextWindow: 1000000 },
|
||||
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', capabilities: ['text', 'vision'], contextWindow: 2000000 },
|
||||
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', capabilities: ['text'], contextWindow: 1000000 }
|
||||
]
|
||||
},
|
||||
qwen: {
|
||||
name: 'Qwen',
|
||||
models: [
|
||||
{ id: 'coder-model', name: 'Qwen Coder', capabilities: ['code'] },
|
||||
{ id: 'vision-model', name: 'Qwen Vision', capabilities: ['vision'] },
|
||||
{ id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B', capabilities: ['code'] }
|
||||
]
|
||||
},
|
||||
openai: {
|
||||
name: 'OpenAI',
|
||||
models: [
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2', capabilities: ['text', 'code'] },
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1', capabilities: ['text', 'code'] },
|
||||
{ id: 'o4-mini', name: 'O4 Mini', capabilities: ['text'] },
|
||||
{ id: 'o3', name: 'O3', capabilities: ['text'] }
|
||||
]
|
||||
},
|
||||
anthropic: {
|
||||
name: 'Anthropic',
|
||||
models: [
|
||||
{ id: 'sonnet', name: 'Claude Sonnet', capabilities: ['text', 'code'] },
|
||||
{ id: 'opus', name: 'Claude Opus', capabilities: ['text', 'code', 'vision'] },
|
||||
{ id: 'haiku', name: 'Claude Haiku', capabilities: ['text'] },
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude 4.5 Sonnet (2025-09-29)', capabilities: ['text', 'code'] },
|
||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude 4.5 Opus (2025-11-01)', capabilities: ['text', 'code', 'vision'] }
|
||||
]
|
||||
},
|
||||
litellm: {
|
||||
name: 'LiteLLM Aggregator',
|
||||
models: [
|
||||
{ id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free', capabilities: ['text'] },
|
||||
{ id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano', capabilities: ['text'] },
|
||||
{ id: 'opencode/grok-code', name: 'Grok Code', capabilities: ['code'] },
|
||||
{ id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free', capabilities: ['text'] },
|
||||
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (via LiteLLM)', capabilities: ['text'] },
|
||||
{ id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4 (via LiteLLM)', capabilities: ['text'] },
|
||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1 (via LiteLLM)', capabilities: ['text'] },
|
||||
{ id: 'openai/o3', name: 'O3 (via LiteLLM)', capabilities: ['text'] },
|
||||
{ id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (via LiteLLM)', capabilities: ['text'] },
|
||||
{ id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (via LiteLLM)', capabilities: ['text'] }
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get models for a specific provider
|
||||
* @param provider - Provider name (e.g., 'google', 'qwen', 'openai', 'anthropic', 'litellm')
|
||||
* @returns Array of model information
|
||||
*/
|
||||
export function getProviderModels(provider: string): ProviderModelInfo[] {
|
||||
return PROVIDER_MODELS[provider]?.models || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider names
|
||||
* @returns Array of provider names
|
||||
*/
|
||||
export function getAllProviders(): string[] {
|
||||
return Object.keys(PROVIDER_MODELS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find model information across all providers
|
||||
* @param modelId - Model identifier to search for
|
||||
* @returns Model information or undefined if not found
|
||||
*/
|
||||
export function findModelInfo(modelId: string): ProviderModelInfo | undefined {
|
||||
for (const provider of Object.values(PROVIDER_MODELS)) {
|
||||
const model = provider.models.find(m => m.id === modelId);
|
||||
if (model) return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider name for a model ID
|
||||
* @param modelId - Model identifier
|
||||
* @returns Provider name or undefined if not found
|
||||
*/
|
||||
export function getProviderForModel(modelId: string): string | undefined {
|
||||
for (const [providerId, provider] of Object.entries(PROVIDER_MODELS)) {
|
||||
if (provider.models.some(m => m.id === modelId)) {
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// Re-export from claude-cli-tools for convenience
|
||||
export type { ClaudeCliTool, ClaudeCliToolsConfig, CliToolName } from '../tools/claude-cli-tools.js';
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getEnrichedConversation,
|
||||
getHistoryWithNativeInfo
|
||||
} from '../../tools/cli-executor.js';
|
||||
import { listAllNativeSessions } from '../../tools/native-session-discovery.js';
|
||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
|
||||
import {
|
||||
@@ -851,6 +852,35 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: List Native CLI Sessions
|
||||
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || null;
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
|
||||
|
||||
try {
|
||||
const sessions = listAllNativeSessions({
|
||||
workingDir: projectPath || undefined,
|
||||
limit
|
||||
});
|
||||
|
||||
// Group sessions by tool
|
||||
const byTool: Record<string, typeof sessions> = {};
|
||||
for (const session of sessions) {
|
||||
if (!byTool[session.tool]) {
|
||||
byTool[session.tool] = [];
|
||||
}
|
||||
byTool[session.tool].push(session);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sessions, byTool }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Execute CLI Tool
|
||||
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
/**
|
||||
* Provider Reference Routes Module
|
||||
* Handles read-only provider model reference API endpoints
|
||||
*
|
||||
* Model source priority:
|
||||
* 1. User configuration (cli-tools.json availableModels)
|
||||
* 2. LiteLLM static model lists (fallback)
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import {
|
||||
PROVIDER_MODELS,
|
||||
getAllProviders,
|
||||
getProviderModels
|
||||
} from '../../config/provider-models.js';
|
||||
import { loadClaudeCliTools, type ClaudeCliToolsConfig } from '../../tools/claude-cli-tools.js';
|
||||
import { getFallbackModels, hasFallbackModels, type ModelInfo } from '../../config/litellm-static-models.js';
|
||||
|
||||
/**
|
||||
* Get models for a tool, using config or fallback
|
||||
*/
|
||||
function getToolModels(toolId: string, configModels?: string[]): ModelInfo[] {
|
||||
// Priority 1: User config
|
||||
if (configModels && configModels.length > 0) {
|
||||
return configModels.map(id => ({ id, name: id }));
|
||||
}
|
||||
|
||||
// Priority 2: LiteLLM static fallback
|
||||
return getFallbackModels(toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Provider Reference routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res } = ctx;
|
||||
const { pathname, req, res, initialPath } = ctx;
|
||||
|
||||
// ========== GET ALL PROVIDERS ==========
|
||||
// GET /api/providers
|
||||
if (pathname === '/api/providers' && req.method === 'GET') {
|
||||
try {
|
||||
const providers = getAllProviders().map(id => ({
|
||||
id,
|
||||
name: PROVIDER_MODELS[id].name,
|
||||
modelCount: PROVIDER_MODELS[id].models.length
|
||||
}));
|
||||
const config = loadClaudeCliTools(initialPath);
|
||||
const providers = Object.entries(config.tools)
|
||||
.filter(([, tool]) => tool.enabled)
|
||||
.map(([id, tool]) => {
|
||||
// Use config models or fallback count
|
||||
const models = getToolModels(id, tool.availableModels);
|
||||
return {
|
||||
id,
|
||||
name: id.charAt(0).toUpperCase() + id.slice(1),
|
||||
modelCount: models.length,
|
||||
primaryModel: tool.primaryModel ?? '',
|
||||
secondaryModel: tool.secondaryModel ?? '',
|
||||
type: tool.type ?? 'builtin',
|
||||
hasCustomModels: !!(tool.availableModels && tool.availableModels.length > 0)
|
||||
};
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, providers }));
|
||||
@@ -46,9 +71,10 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean>
|
||||
const provider = decodeURIComponent(providerMatch[1]);
|
||||
|
||||
try {
|
||||
const models = getProviderModels(provider);
|
||||
const config = loadClaudeCliTools(initialPath);
|
||||
const tool = config.tools[provider];
|
||||
|
||||
if (models.length === 0) {
|
||||
if (!tool || !tool.enabled) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
@@ -57,12 +83,19 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get models from config or fallback
|
||||
const models = getToolModels(provider, tool.availableModels);
|
||||
const usingFallback = !tool.availableModels || tool.availableModels.length === 0;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
provider,
|
||||
providerName: PROVIDER_MODELS[provider].name,
|
||||
models
|
||||
providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
models,
|
||||
primaryModel: tool.primaryModel ?? '',
|
||||
secondaryModel: tool.secondaryModel ?? '',
|
||||
source: usingFallback ? 'fallback' : 'config'
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
|
||||
157
ccw/src/core/routes/queue-routes.ts
Normal file
157
ccw/src/core/routes/queue-routes.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Queue Scheduler Routes Module
|
||||
*
|
||||
* HTTP API endpoints for the Queue Scheduler Service.
|
||||
* Delegates all business logic to QueueSchedulerService.
|
||||
*
|
||||
* API Endpoints:
|
||||
* - POST /api/queue/execute - Submit items to the scheduler and start
|
||||
* - GET /api/queue/scheduler/state - Get 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
|
||||
* - POST /api/queue/scheduler/config - Update scheduler configuration
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import type { QueueSchedulerService } from '../services/queue-scheduler-service.js';
|
||||
import type { QueueItem, QueueSchedulerConfig } from '../../types/queue-types.js';
|
||||
|
||||
/**
|
||||
* Handle queue scheduler routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleQueueSchedulerRoutes(
|
||||
ctx: RouteContext,
|
||||
schedulerService: QueueSchedulerService,
|
||||
): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest } = ctx;
|
||||
|
||||
// POST /api/queue/execute - Submit items and start the scheduler
|
||||
if (pathname === '/api/queue/execute' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { items } = body as { items?: QueueItem[] };
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return { error: 'items array is required and must not be empty', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const state = schedulerService.getState();
|
||||
|
||||
// If idle, start with items; otherwise add items to running scheduler
|
||||
if (state.status === 'idle') {
|
||||
schedulerService.start(items);
|
||||
} else if (state.status === 'running' || state.status === 'paused') {
|
||||
for (const item of items) {
|
||||
schedulerService.addItem(item);
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
error: `Cannot add items when scheduler is in '${state.status}' state`,
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
state: schedulerService.getState(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/queue/scheduler/state - Return full scheduler state
|
||||
if (pathname === '/api/queue/scheduler/state' && req.method === 'GET') {
|
||||
try {
|
||||
const state = schedulerService.getState();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, state }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/scheduler/start - Start scheduling loop with items
|
||||
if (pathname === '/api/queue/scheduler/start' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { items } = body as { items?: QueueItem[] };
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return { error: 'items array is required and must not be empty', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
schedulerService.start(items);
|
||||
return {
|
||||
success: true,
|
||||
state: schedulerService.getState(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 409 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/scheduler/pause - Pause scheduling
|
||||
if (pathname === '/api/queue/scheduler/pause' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
schedulerService.pause();
|
||||
return {
|
||||
success: true,
|
||||
state: schedulerService.getState(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 409 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/scheduler/stop - Graceful stop
|
||||
if (pathname === '/api/queue/scheduler/stop' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
await schedulerService.stop();
|
||||
return {
|
||||
success: true,
|
||||
state: schedulerService.getState(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 409 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/scheduler/config - Update scheduler configuration
|
||||
if (pathname === '/api/queue/scheduler/config' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const config = body as Partial<QueueSchedulerConfig>;
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { error: 'Configuration object is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
schedulerService.updateConfig(config);
|
||||
return {
|
||||
success: true,
|
||||
config: schedulerService.getState().config,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { handleSkillsRoutes } from './routes/skills-routes.js';
|
||||
import { handleSkillHubRoutes } from './routes/skill-hub-routes.js';
|
||||
import { handleCommandsRoutes } from './routes/commands-routes.js';
|
||||
import { handleIssueRoutes } from './routes/issue-routes.js';
|
||||
import { handleQueueSchedulerRoutes } from './routes/queue-routes.js';
|
||||
import { handleDiscoveryRoutes } from './routes/discovery-routes.js';
|
||||
import { handleRulesRoutes } from './routes/rules-routes.js';
|
||||
import { handleSessionRoutes } from './routes/session-routes.js';
|
||||
@@ -56,6 +57,8 @@ import { randomBytes } from 'crypto';
|
||||
// Import health check service
|
||||
import { getHealthCheckService } from './services/health-check-service.js';
|
||||
import { getCliSessionShareManager } from './services/cli-session-share.js';
|
||||
import { getCliSessionManager } from './services/cli-session-manager.js';
|
||||
import { QueueSchedulerService } from './services/queue-scheduler-service.js';
|
||||
|
||||
// Import status check functions for warmup
|
||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||
@@ -294,6 +297,10 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
||||
const cliSessionShareManager = getCliSessionShareManager();
|
||||
|
||||
// Initialize Queue Scheduler Service (needs broadcastToClients and cliSessionManager)
|
||||
const cliSessionManager = getCliSessionManager(initialPath);
|
||||
const queueSchedulerService = new QueueSchedulerService(broadcastToClients, cliSessionManager);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
||||
const pathname = url.pathname;
|
||||
@@ -589,7 +596,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleCommandsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Queue routes (/api/queue*) - top-level queue API
|
||||
// Queue Scheduler routes (/api/queue/execute, /api/queue/scheduler/*)
|
||||
if (pathname === '/api/queue/execute' || pathname.startsWith('/api/queue/scheduler')) {
|
||||
if (await handleQueueSchedulerRoutes(routeContext, queueSchedulerService)) return;
|
||||
}
|
||||
|
||||
// Queue routes (/api/queue*) - top-level queue API (issue-based)
|
||||
if (pathname.startsWith('/api/queue')) {
|
||||
if (await handleIssueRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
691
ccw/src/core/services/queue-scheduler-service.ts
Normal file
691
ccw/src/core/services/queue-scheduler-service.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* Queue Scheduler Service
|
||||
*
|
||||
* Core scheduling engine managing task queue lifecycle with state machine,
|
||||
* dependency resolution, session pool management, and concurrency control.
|
||||
*
|
||||
* Integrates with:
|
||||
* - cli-session-manager.ts for PTY session creation and command execution
|
||||
* - websocket.ts for real-time state broadcasts via broadcastToClients
|
||||
* - queue-types.ts for type definitions
|
||||
*
|
||||
* Design decisions:
|
||||
* - In-memory state (no persistence) for simplicity; crash recovery deferred.
|
||||
* - processQueue() selection phase runs synchronously to avoid race conditions
|
||||
* in session allocation; only execution is async.
|
||||
* - Session pool uses 3-tier allocation: resumeKey affinity -> idle reuse -> new creation.
|
||||
*/
|
||||
|
||||
import type { CliSessionManager } from './cli-session-manager.js';
|
||||
import type {
|
||||
QueueItem,
|
||||
QueueItemStatus,
|
||||
QueueSchedulerConfig,
|
||||
QueueSchedulerState,
|
||||
QueueSchedulerStatus,
|
||||
QueueWSMessage,
|
||||
SessionBinding,
|
||||
} from '../../types/queue-types.js';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CONFIG: QueueSchedulerConfig = {
|
||||
maxConcurrentSessions: 2,
|
||||
sessionIdleTimeoutMs: 5 * 60 * 1000, // 5 minutes
|
||||
resumeKeySessionBindingTimeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid state machine transitions.
|
||||
* Key = current status, Value = set of allowed target statuses.
|
||||
*/
|
||||
const VALID_TRANSITIONS: Record<QueueSchedulerStatus, Set<QueueSchedulerStatus>> = {
|
||||
idle: new Set(['running']),
|
||||
running: new Set(['paused', 'stopping']),
|
||||
paused: new Set(['running', 'stopping']),
|
||||
stopping: new Set(['completed', 'failed']),
|
||||
completed: new Set(['idle']),
|
||||
failed: new Set(['idle']),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// QueueSchedulerService
|
||||
// ============================================================================
|
||||
|
||||
export class QueueSchedulerService {
|
||||
private state: QueueSchedulerState;
|
||||
private broadcastFn: (data: unknown) => void;
|
||||
private cliSessionManager: CliSessionManager;
|
||||
|
||||
/** Tracks in-flight execution promises by item_id. */
|
||||
private executingTasks = new Map<string, Promise<void>>();
|
||||
|
||||
/** Interval handle for session idle cleanup. */
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Guard to prevent re-entrant processQueue calls. */
|
||||
private processingLock = false;
|
||||
|
||||
constructor(
|
||||
broadcastToClients: (data: unknown) => void,
|
||||
cliSessionManager: CliSessionManager,
|
||||
config?: Partial<QueueSchedulerConfig>,
|
||||
) {
|
||||
this.broadcastFn = broadcastToClients;
|
||||
this.cliSessionManager = cliSessionManager;
|
||||
|
||||
const mergedConfig: QueueSchedulerConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
this.state = {
|
||||
status: 'idle',
|
||||
items: [],
|
||||
sessionPool: {},
|
||||
config: mergedConfig,
|
||||
currentConcurrency: 0,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Start the scheduler with an initial set of items.
|
||||
* Transitions: idle -> running.
|
||||
*/
|
||||
start(items: QueueItem[]): void {
|
||||
this.validateTransition('running');
|
||||
this.state.status = 'running';
|
||||
this.state.error = undefined;
|
||||
this.touchActivity();
|
||||
|
||||
// Resolve initial statuses based on dependency graph
|
||||
for (const item of items) {
|
||||
const resolved = this.resolveInitialStatus(item, items);
|
||||
this.state.items.push({ ...item, status: resolved });
|
||||
}
|
||||
|
||||
this.startCleanupInterval();
|
||||
this.broadcastStateUpdate();
|
||||
|
||||
// Kick off the scheduling loop (non-blocking)
|
||||
void this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the scheduler. Running tasks continue to completion but no new tasks start.
|
||||
* Transitions: running -> paused.
|
||||
*/
|
||||
pause(): void {
|
||||
this.validateTransition('paused');
|
||||
this.state.status = 'paused';
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume from paused state.
|
||||
* Transitions: paused -> running.
|
||||
*/
|
||||
resume(): void {
|
||||
this.validateTransition('running');
|
||||
this.state.status = 'running';
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
void this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request graceful stop. Waits for executing tasks to finish.
|
||||
* Transitions: running|paused -> stopping -> completed|failed.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.validateTransition('stopping');
|
||||
this.state.status = 'stopping';
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
|
||||
// Wait for all in-flight executions
|
||||
if (this.executingTasks.size > 0) {
|
||||
await Promise.allSettled(Array.from(this.executingTasks.values()));
|
||||
}
|
||||
|
||||
// Determine final status
|
||||
const hasFailures = this.state.items.some(i => i.status === 'failed');
|
||||
const finalStatus: QueueSchedulerStatus = hasFailures ? 'failed' : 'completed';
|
||||
this.state.status = finalStatus;
|
||||
|
||||
// Cancel any remaining pending/queued/blocked items
|
||||
for (const item of this.state.items) {
|
||||
if (item.status === 'pending' || item.status === 'queued' || item.status === 'ready' || item.status === 'blocked') {
|
||||
item.status = 'cancelled';
|
||||
item.completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
this.stopCleanupInterval();
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the scheduler back to idle, clearing all items and session pool.
|
||||
* Transitions: completed|failed -> idle.
|
||||
*/
|
||||
reset(): void {
|
||||
this.validateTransition('idle');
|
||||
this.state.status = 'idle';
|
||||
this.state.items = [];
|
||||
this.state.sessionPool = {};
|
||||
this.state.currentConcurrency = 0;
|
||||
this.state.error = undefined;
|
||||
this.executingTasks.clear();
|
||||
this.stopCleanupInterval();
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item to the queue while the scheduler is running.
|
||||
*/
|
||||
addItem(item: QueueItem): void {
|
||||
const resolved = this.resolveInitialStatus(item, this.state.items);
|
||||
const newItem = { ...item, status: resolved };
|
||||
this.state.items.push(newItem);
|
||||
this.touchActivity();
|
||||
|
||||
this.broadcast({
|
||||
type: 'QUEUE_ITEM_ADDED',
|
||||
item: newItem,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Trigger scheduling if running
|
||||
if (this.state.status === 'running') {
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the queue. Only non-executing items can be removed.
|
||||
*/
|
||||
removeItem(itemId: string): boolean {
|
||||
const idx = this.state.items.findIndex(i => i.item_id === itemId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const item = this.state.items[idx];
|
||||
if (item.status === 'executing') return false;
|
||||
|
||||
this.state.items.splice(idx, 1);
|
||||
this.touchActivity();
|
||||
|
||||
this.broadcast({
|
||||
type: 'QUEUE_ITEM_REMOVED',
|
||||
item_id: itemId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scheduler configuration at runtime.
|
||||
*/
|
||||
updateConfig(partial: Partial<QueueSchedulerConfig>): void {
|
||||
Object.assign(this.state.config, partial);
|
||||
this.touchActivity();
|
||||
|
||||
this.broadcast({
|
||||
type: 'QUEUE_SCHEDULER_CONFIG_UPDATED',
|
||||
config: { ...this.state.config },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If maxConcurrentSessions increased, try to schedule more
|
||||
if (partial.maxConcurrentSessions !== undefined && this.state.status === 'running') {
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current scheduler state.
|
||||
*/
|
||||
getState(): QueueSchedulerState {
|
||||
return {
|
||||
...this.state,
|
||||
items: this.state.items.map(i => ({ ...i })),
|
||||
sessionPool: { ...this.state.sessionPool },
|
||||
config: { ...this.state.config },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific item by ID.
|
||||
*/
|
||||
getItem(itemId: string): QueueItem | undefined {
|
||||
return this.state.items.find(i => i.item_id === itemId);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Core Scheduling Loop
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Main scheduling loop. Resolves dependencies, selects ready tasks,
|
||||
* allocates sessions, and triggers execution.
|
||||
*
|
||||
* The selection phase is synchronous (guarded by processingLock) to prevent
|
||||
* race conditions in session allocation. Only execution is async.
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
// Guard: prevent re-entrant calls
|
||||
if (this.processingLock) return;
|
||||
this.processingLock = true;
|
||||
|
||||
try {
|
||||
while (this.state.status === 'running') {
|
||||
// Step 1: Check preconditions
|
||||
if (this.state.currentConcurrency >= this.state.config.maxConcurrentSessions) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 2: Resolve blocked items whose dependencies are now completed
|
||||
this.resolveDependencies();
|
||||
|
||||
// Step 3: Select next task to execute
|
||||
const candidate = this.selectNextTask();
|
||||
if (!candidate) {
|
||||
// Check if everything is done
|
||||
this.checkCompletion();
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 4: Allocate a session
|
||||
const sessionKey = this.allocateSession(candidate);
|
||||
if (!sessionKey) {
|
||||
// Could not allocate a session (all slots busy)
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 5: Mark as executing and launch
|
||||
candidate.status = 'executing';
|
||||
candidate.sessionKey = sessionKey;
|
||||
candidate.startedAt = new Date().toISOString();
|
||||
this.state.currentConcurrency++;
|
||||
this.touchActivity();
|
||||
|
||||
this.broadcastItemUpdate(candidate);
|
||||
|
||||
// Step 6: Execute asynchronously
|
||||
const execPromise = this.executeTask(candidate, sessionKey);
|
||||
this.executingTasks.set(candidate.item_id, execPromise);
|
||||
|
||||
// Chain cleanup and re-trigger
|
||||
void execPromise.then(() => {
|
||||
this.executingTasks.delete(candidate.item_id);
|
||||
// Re-trigger scheduling on completion
|
||||
if (this.state.status === 'running') {
|
||||
void this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.processingLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve blocked items whose depends_on are all completed.
|
||||
*/
|
||||
private resolveDependencies(): void {
|
||||
const completedIds = new Set(
|
||||
this.state.items
|
||||
.filter(i => i.status === 'completed')
|
||||
.map(i => i.item_id),
|
||||
);
|
||||
|
||||
for (const item of this.state.items) {
|
||||
if (item.status !== 'blocked' && item.status !== 'pending') continue;
|
||||
|
||||
if (item.depends_on.length === 0) {
|
||||
if (item.status === 'pending') {
|
||||
item.status = 'queued';
|
||||
this.broadcastItemUpdate(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any dependency failed
|
||||
const anyDepFailed = item.depends_on.some(depId => {
|
||||
const dep = this.state.items.find(i => i.item_id === depId);
|
||||
return dep && (dep.status === 'failed' || dep.status === 'cancelled');
|
||||
});
|
||||
if (anyDepFailed) {
|
||||
item.status = 'cancelled';
|
||||
item.completedAt = new Date().toISOString();
|
||||
item.error = 'Dependency failed or was cancelled';
|
||||
this.broadcastItemUpdate(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDepsComplete = item.depends_on.every(depId => completedIds.has(depId));
|
||||
if (allDepsComplete) {
|
||||
item.status = 'queued';
|
||||
this.broadcastItemUpdate(item);
|
||||
} else if (item.status === 'pending') {
|
||||
item.status = 'blocked';
|
||||
this.broadcastItemUpdate(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the next queued task by execution_order, then createdAt.
|
||||
*/
|
||||
private selectNextTask(): QueueItem | undefined {
|
||||
const queued = this.state.items.filter(i => i.status === 'queued');
|
||||
if (queued.length === 0) return undefined;
|
||||
|
||||
queued.sort((a, b) => {
|
||||
if (a.execution_order !== b.execution_order) {
|
||||
return a.execution_order - b.execution_order;
|
||||
}
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
|
||||
return queued[0];
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Session Pool Management
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 3-tier session allocation strategy:
|
||||
* 1. ResumeKey affinity: if the item has a resumeKey and we have a bound session, reuse it.
|
||||
* 2. Idle session reuse: find any session in the pool not currently executing.
|
||||
* 3. New session creation: create a new session via CliSessionManager if under the limit.
|
||||
*
|
||||
* Returns sessionKey or null if no session available.
|
||||
*/
|
||||
private allocateSession(item: QueueItem): string | null {
|
||||
const now = new Date();
|
||||
|
||||
// Tier 1: ResumeKey affinity
|
||||
if (item.resumeKey) {
|
||||
const binding = this.state.sessionPool[item.resumeKey];
|
||||
if (binding) {
|
||||
const bindingAge = now.getTime() - new Date(binding.lastUsed).getTime();
|
||||
if (bindingAge < this.state.config.resumeKeySessionBindingTimeoutMs) {
|
||||
// Verify the session still exists in CliSessionManager
|
||||
if (this.cliSessionManager.hasSession(binding.sessionKey)) {
|
||||
binding.lastUsed = now.toISOString();
|
||||
return binding.sessionKey;
|
||||
}
|
||||
// Session gone, remove stale binding
|
||||
delete this.state.sessionPool[item.resumeKey];
|
||||
} else {
|
||||
// Binding expired
|
||||
delete this.state.sessionPool[item.resumeKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Idle session reuse
|
||||
const executingSessionKeys = new Set(
|
||||
this.state.items
|
||||
.filter(i => i.status === 'executing' && i.sessionKey)
|
||||
.map(i => i.sessionKey!),
|
||||
);
|
||||
|
||||
for (const [resumeKey, binding] of Object.entries(this.state.sessionPool)) {
|
||||
if (!executingSessionKeys.has(binding.sessionKey)) {
|
||||
// This session is idle in the pool
|
||||
if (this.cliSessionManager.hasSession(binding.sessionKey)) {
|
||||
binding.lastUsed = now.toISOString();
|
||||
// Rebind to new resumeKey if different
|
||||
if (item.resumeKey && item.resumeKey !== resumeKey) {
|
||||
this.state.sessionPool[item.resumeKey] = binding;
|
||||
}
|
||||
return binding.sessionKey;
|
||||
}
|
||||
// Stale session, clean up
|
||||
delete this.state.sessionPool[resumeKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: New session creation
|
||||
const activeSessions = this.cliSessionManager.listSessions();
|
||||
// Count sessions managed by our pool (not all sessions globally)
|
||||
const poolSessionKeys = new Set(
|
||||
Object.values(this.state.sessionPool).map(b => b.sessionKey),
|
||||
);
|
||||
const ourActiveCount = activeSessions.filter(s => poolSessionKeys.has(s.sessionKey)).length;
|
||||
|
||||
if (ourActiveCount < this.state.config.maxConcurrentSessions) {
|
||||
try {
|
||||
const newSession = this.cliSessionManager.createSession({
|
||||
workingDir: this.cliSessionManager.getProjectRoot(),
|
||||
tool: item.tool,
|
||||
resumeKey: item.resumeKey,
|
||||
});
|
||||
|
||||
const binding: SessionBinding = {
|
||||
sessionKey: newSession.sessionKey,
|
||||
lastUsed: now.toISOString(),
|
||||
};
|
||||
|
||||
// Bind to resumeKey if available, otherwise use item_id as key
|
||||
const poolKey = item.resumeKey || item.item_id;
|
||||
this.state.sessionPool[poolKey] = binding;
|
||||
|
||||
return newSession.sessionKey;
|
||||
} catch (err) {
|
||||
console.error('[QueueScheduler] Failed to create session:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a session back to the pool after task completion.
|
||||
*/
|
||||
private releaseSession(item: QueueItem): void {
|
||||
if (!item.sessionKey) return;
|
||||
|
||||
// Update the binding's lastUsed timestamp
|
||||
const poolKey = item.resumeKey || item.item_id;
|
||||
const binding = this.state.sessionPool[poolKey];
|
||||
if (binding && binding.sessionKey === item.sessionKey) {
|
||||
binding.lastUsed = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Task Execution
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Execute a single queue item via CliSessionManager.
|
||||
*/
|
||||
private async executeTask(item: QueueItem, sessionKey: string): Promise<void> {
|
||||
try {
|
||||
this.cliSessionManager.execute(sessionKey, {
|
||||
tool: item.tool,
|
||||
prompt: item.prompt,
|
||||
mode: item.mode,
|
||||
resumeKey: item.resumeKey,
|
||||
resumeStrategy: item.resumeStrategy,
|
||||
});
|
||||
|
||||
// Mark as completed (fire-and-forget execution model for PTY sessions)
|
||||
// The actual CLI execution is async in the PTY; we mark completion
|
||||
// after the command is sent. Real completion tracking requires
|
||||
// hook callbacks or output parsing (future enhancement).
|
||||
item.status = 'completed';
|
||||
item.completedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
item.status = 'failed';
|
||||
item.completedAt = new Date().toISOString();
|
||||
item.error = (err as Error).message;
|
||||
}
|
||||
|
||||
// Update concurrency and release session
|
||||
this.state.currentConcurrency = Math.max(0, this.state.currentConcurrency - 1);
|
||||
this.releaseSession(item);
|
||||
this.touchActivity();
|
||||
this.broadcastItemUpdate(item);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// State Machine
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Validate that the requested transition is allowed.
|
||||
* Throws if the transition is invalid.
|
||||
*/
|
||||
private validateTransition(target: QueueSchedulerStatus): void {
|
||||
const allowed = VALID_TRANSITIONS[this.state.status];
|
||||
if (!allowed || !allowed.has(target)) {
|
||||
throw new Error(
|
||||
`Invalid state transition: ${this.state.status} -> ${target}. ` +
|
||||
`Allowed transitions from '${this.state.status}': [${Array.from(allowed || []).join(', ')}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine initial status for an item based on its dependencies.
|
||||
*/
|
||||
private resolveInitialStatus(item: QueueItem, allItems: QueueItem[]): QueueItemStatus {
|
||||
if (item.depends_on.length === 0) {
|
||||
return 'queued';
|
||||
}
|
||||
// Check if all dependencies are already completed
|
||||
const completedIds = new Set(
|
||||
allItems.filter(i => i.status === 'completed').map(i => i.item_id),
|
||||
);
|
||||
const allResolved = item.depends_on.every(id => completedIds.has(id));
|
||||
return allResolved ? 'queued' : 'blocked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all items are in a terminal state, and transition scheduler accordingly.
|
||||
*/
|
||||
private checkCompletion(): void {
|
||||
if (this.state.status !== 'running') return;
|
||||
if (this.executingTasks.size > 0) return;
|
||||
|
||||
const allTerminal = this.state.items.every(
|
||||
i => i.status === 'completed' || i.status === 'failed' || i.status === 'cancelled',
|
||||
);
|
||||
|
||||
if (!allTerminal) return;
|
||||
|
||||
const hasFailures = this.state.items.some(i => i.status === 'failed');
|
||||
// Transition through stopping to final state
|
||||
this.state.status = 'stopping';
|
||||
this.state.status = hasFailures ? 'failed' : 'completed';
|
||||
this.stopCleanupInterval();
|
||||
this.touchActivity();
|
||||
this.broadcastStateUpdate();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Session Cleanup
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of idle sessions from the pool.
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
this.stopCleanupInterval();
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupIdleSessions();
|
||||
}, 60_000);
|
||||
|
||||
// Prevent the timer from keeping the process alive
|
||||
if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private stopCleanupInterval(): void {
|
||||
if (this.cleanupTimer !== null) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sessions from the pool that have been idle beyond the timeout.
|
||||
*/
|
||||
private cleanupIdleSessions(): void {
|
||||
const now = Date.now();
|
||||
const timeoutMs = this.state.config.sessionIdleTimeoutMs;
|
||||
|
||||
const executingSessionKeys = new Set(
|
||||
this.state.items
|
||||
.filter(i => i.status === 'executing' && i.sessionKey)
|
||||
.map(i => i.sessionKey!),
|
||||
);
|
||||
|
||||
for (const [key, binding] of Object.entries(this.state.sessionPool)) {
|
||||
// Skip sessions currently in use
|
||||
if (executingSessionKeys.has(binding.sessionKey)) continue;
|
||||
|
||||
const idleMs = now - new Date(binding.lastUsed).getTime();
|
||||
if (idleMs >= timeoutMs) {
|
||||
// Close the session in CliSessionManager
|
||||
try {
|
||||
this.cliSessionManager.close(binding.sessionKey);
|
||||
} catch {
|
||||
// Session may already be gone
|
||||
}
|
||||
delete this.state.sessionPool[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Broadcasting
|
||||
// ==========================================================================
|
||||
|
||||
private broadcast(message: QueueWSMessage): void {
|
||||
try {
|
||||
this.broadcastFn(message);
|
||||
} catch {
|
||||
// Ignore broadcast errors
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastStateUpdate(): void {
|
||||
this.broadcast({
|
||||
type: 'QUEUE_SCHEDULER_STATE_UPDATE',
|
||||
state: this.getState(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastItemUpdate(item: QueueItem): void {
|
||||
this.broadcast({
|
||||
type: 'QUEUE_ITEM_UPDATED',
|
||||
item: { ...item },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Utilities
|
||||
// ==========================================================================
|
||||
|
||||
private touchActivity(): void {
|
||||
this.state.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import { a2uiWebSocketHandler, handleA2UIMessage } from './a2ui/A2UIWebSocketHandler.js';
|
||||
import { handleAnswer } from '../tools/ask-question.js';
|
||||
import type {
|
||||
QueueWSMessageType,
|
||||
QueueWSMessage,
|
||||
QueueSchedulerStateUpdateMessage,
|
||||
QueueItemAddedMessage,
|
||||
QueueItemUpdatedMessage,
|
||||
QueueItemRemovedMessage,
|
||||
QueueSchedulerConfigUpdatedMessage,
|
||||
} from '../types/queue-types.js';
|
||||
|
||||
// WebSocket clients for real-time notifications
|
||||
export const wsClients = new Set<Duplex>();
|
||||
@@ -622,3 +631,53 @@ export function broadcastCoordinatorLog(
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export Queue WebSocket types from queue-types.ts
|
||||
export type {
|
||||
QueueWSMessageType as QueueMessageType,
|
||||
QueueSchedulerStateUpdateMessage,
|
||||
QueueItemAddedMessage,
|
||||
QueueItemUpdatedMessage,
|
||||
QueueItemRemovedMessage,
|
||||
QueueSchedulerConfigUpdatedMessage,
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type for Queue messages (without timestamp - added automatically)
|
||||
*/
|
||||
export type QueueMessage =
|
||||
| Omit<QueueSchedulerStateUpdateMessage, 'timestamp'>
|
||||
| Omit<QueueItemAddedMessage, 'timestamp'>
|
||||
| Omit<QueueItemUpdatedMessage, 'timestamp'>
|
||||
| Omit<QueueItemRemovedMessage, 'timestamp'>
|
||||
| Omit<QueueSchedulerConfigUpdatedMessage, 'timestamp'>;
|
||||
|
||||
/**
|
||||
* Queue-specific broadcast with throttling
|
||||
* Throttles QUEUE_SCHEDULER_STATE_UPDATE messages to avoid flooding clients
|
||||
*/
|
||||
let lastQueueBroadcast = 0;
|
||||
const QUEUE_BROADCAST_THROTTLE = 1000; // 1 second
|
||||
|
||||
/**
|
||||
* Broadcast queue update with throttling
|
||||
* STATE_UPDATE messages are throttled to 1 per second
|
||||
* Other message types are sent immediately
|
||||
*/
|
||||
export function broadcastQueueUpdate(message: QueueMessage): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle QUEUE_SCHEDULER_STATE_UPDATE to reduce WebSocket traffic
|
||||
if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE' && now - lastQueueBroadcast < QUEUE_BROADCAST_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE') {
|
||||
lastQueueBroadcast = now;
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
...message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,9 +390,13 @@ export function generateTransactionId(conversationId: string): TransactionId {
|
||||
* Inject transaction ID into user prompt
|
||||
* @param prompt - Original user prompt
|
||||
* @param txId - Transaction ID to inject
|
||||
* @returns Prompt with transaction ID injected at the start
|
||||
* @returns Prompt with transaction ID injected at the start, or empty string if prompt is empty
|
||||
*/
|
||||
export function injectTransactionId(prompt: string, txId: TransactionId): string {
|
||||
// Don't inject TX ID for empty prompts (e.g., review mode with target flags)
|
||||
if (!prompt || !prompt.trim()) {
|
||||
return '';
|
||||
}
|
||||
return `[CCW-TX-ID: ${txId}]\n\n${prompt}`;
|
||||
}
|
||||
|
||||
@@ -844,8 +848,15 @@ async function executeCliTool(
|
||||
|
||||
// Inject transaction ID at the start of the final prompt for session tracking
|
||||
// This enables exact session matching during parallel execution scenarios
|
||||
finalPrompt = injectTransactionId(finalPrompt, transactionId);
|
||||
debugLog('TX_ID', `Injected transaction ID into prompt`, { transactionId, promptLength: finalPrompt.length });
|
||||
// Skip injection for review mode with target flags (uncommitted/base/commit) as these
|
||||
// modes don't accept prompt arguments in codex CLI
|
||||
const isReviewWithTarget = mode === 'review' && (uncommitted || base || commit);
|
||||
if (!isReviewWithTarget) {
|
||||
finalPrompt = injectTransactionId(finalPrompt, transactionId);
|
||||
debugLog('TX_ID', `Injected transaction ID into prompt`, { transactionId, promptLength: finalPrompt.length });
|
||||
} else {
|
||||
debugLog('TX_ID', `Skipped transaction ID injection for review mode with target flag`);
|
||||
}
|
||||
|
||||
// Check tool availability
|
||||
const toolStatus = await checkToolAvailability(tool);
|
||||
|
||||
@@ -502,9 +502,11 @@ export class CliHistoryStore {
|
||||
* Save or update a conversation
|
||||
*/
|
||||
saveConversation(conversation: ConversationRecord): void {
|
||||
const promptPreview = conversation.turns.length > 0
|
||||
? conversation.turns[conversation.turns.length - 1].prompt.substring(0, 100)
|
||||
: '';
|
||||
// Ensure prompt is a string before calling substring
|
||||
const lastTurn = conversation.turns.length > 0 ? conversation.turns[conversation.turns.length - 1] : null;
|
||||
const rawPrompt = lastTurn?.prompt ?? '';
|
||||
const promptStr = typeof rawPrompt === 'string' ? rawPrompt : JSON.stringify(rawPrompt);
|
||||
const promptPreview = promptStr.substring(0, 100);
|
||||
|
||||
const upsertConversation = this.db.prepare(`
|
||||
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id, project_root, relative_path)
|
||||
@@ -609,7 +611,8 @@ export class CliHistoryStore {
|
||||
turns: turns.map(t => ({
|
||||
turn: t.turn_number,
|
||||
timestamp: t.timestamp,
|
||||
prompt: t.prompt,
|
||||
// Ensure prompt is always a string (handle legacy object data)
|
||||
prompt: typeof t.prompt === 'string' ? t.prompt : JSON.stringify(t.prompt),
|
||||
duration_ms: t.duration_ms,
|
||||
status: t.status,
|
||||
exit_code: t.exit_code,
|
||||
@@ -840,7 +843,10 @@ export class CliHistoryStore {
|
||||
category: r.category || 'user',
|
||||
duration_ms: r.total_duration_ms,
|
||||
turn_count: r.turn_count,
|
||||
prompt_preview: r.prompt_preview || ''
|
||||
// Ensure prompt_preview is always a string (handle legacy object data)
|
||||
prompt_preview: typeof r.prompt_preview === 'string'
|
||||
? r.prompt_preview
|
||||
: (r.prompt_preview ? JSON.stringify(r.prompt_preview) : '')
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1197,3 +1197,30 @@ export function getToolSessionPath(tool: string): string | null {
|
||||
const discoverer = discoverers[tool];
|
||||
return discoverer?.basePath || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all native sessions from all supported CLI tools
|
||||
* Aggregates sessions from Gemini, Qwen, Codex, Claude, and OpenCode
|
||||
* @param options - Optional filtering (workingDir, limit, afterTimestamp)
|
||||
* @returns Combined sessions sorted by updatedAt descending
|
||||
*/
|
||||
export function listAllNativeSessions(options?: SessionDiscoveryOptions): NativeSession[] {
|
||||
const allSessions: NativeSession[] = [];
|
||||
|
||||
// Collect sessions from all discoverers
|
||||
for (const tool of Object.keys(discoverers)) {
|
||||
const discoverer = discoverers[tool];
|
||||
const sessions = discoverer.getSessions(options);
|
||||
allSessions.push(...sessions);
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
allSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
|
||||
// Apply limit if provided
|
||||
if (options?.limit) {
|
||||
return allSessions.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return allSessions;
|
||||
}
|
||||
|
||||
@@ -236,13 +236,18 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
|
||||
let model: string | undefined;
|
||||
|
||||
for (const msg of session.messages) {
|
||||
// Ensure content is always a string (handle legacy object data like {text: "..."})
|
||||
const contentStr = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
|
||||
if (msg.type === 'user') {
|
||||
turnNumber++;
|
||||
turns.push({
|
||||
turnNumber,
|
||||
timestamp: msg.timestamp,
|
||||
role: 'user',
|
||||
content: msg.content
|
||||
content: contentStr
|
||||
});
|
||||
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
|
||||
// Find the corresponding user turn
|
||||
@@ -255,7 +260,7 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
|
||||
turnNumber,
|
||||
timestamp: msg.timestamp,
|
||||
role: 'assistant',
|
||||
content: msg.content,
|
||||
content: contentStr,
|
||||
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
||||
tokens: msg.tokens ? {
|
||||
input: msg.tokens.input,
|
||||
@@ -428,7 +433,11 @@ function parseCodexSession(content: string): ParsedSession {
|
||||
currentTurn++;
|
||||
const textContent = item.payload.content
|
||||
?.filter(c => c.type === 'input_text')
|
||||
.map(c => c.text)
|
||||
.map(c => {
|
||||
// Ensure text is a string (handle legacy object data like {text: "..."})
|
||||
const txt = c.text;
|
||||
return typeof txt === 'string' ? txt : JSON.stringify(txt);
|
||||
})
|
||||
.join('\n') || '';
|
||||
|
||||
turns.push({
|
||||
@@ -461,7 +470,11 @@ function parseCodexSession(content: string): ParsedSession {
|
||||
// Assistant message (final response)
|
||||
const textContent = item.payload.content
|
||||
?.filter(c => c.type === 'output_text' || c.type === 'text')
|
||||
.map(c => c.text)
|
||||
.map(c => {
|
||||
// Ensure text is a string (handle legacy object data like {text: "..."})
|
||||
const txt = c.text;
|
||||
return typeof txt === 'string' ? txt : JSON.stringify(txt);
|
||||
})
|
||||
.join('\n') || '';
|
||||
|
||||
if (textContent) {
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createDefaultSettings(provider: CliProvider = 'claude'): CliSett
|
||||
env: {
|
||||
DISABLE_AUTOUPDATER: '1'
|
||||
},
|
||||
model: 'sonnet',
|
||||
model: '',
|
||||
tags: [],
|
||||
availableModels: []
|
||||
} satisfies ClaudeCliSettings;
|
||||
|
||||
209
ccw/src/types/queue-types.ts
Normal file
209
ccw/src/types/queue-types.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Queue Scheduler Type Definitions
|
||||
* TypeScript types for queue scheduling, dependency resolution, and session management.
|
||||
*/
|
||||
|
||||
import type { CliSessionResumeStrategy } from '../core/services/cli-session-command-builder.js';
|
||||
|
||||
// ============================================================================
|
||||
// Status Enums
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Status of a single queue item through its lifecycle.
|
||||
*
|
||||
* Transitions:
|
||||
* pending -> queued -> ready -> executing -> completed | failed
|
||||
* pending -> blocked (has unresolved depends_on)
|
||||
* blocked -> queued (all depends_on completed)
|
||||
* any -> cancelled (user cancellation)
|
||||
*/
|
||||
export type QueueItemStatus =
|
||||
| 'pending'
|
||||
| 'queued'
|
||||
| 'ready'
|
||||
| 'executing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Status of the scheduler state machine.
|
||||
*
|
||||
* Transitions:
|
||||
* idle -> running (start)
|
||||
* running -> paused (pause)
|
||||
* running -> stopping (stop requested, waiting for executing tasks)
|
||||
* paused -> running (resume)
|
||||
* stopping -> completed (all executing tasks finished successfully)
|
||||
* stopping -> failed (executing task failure during stop)
|
||||
*/
|
||||
export type QueueSchedulerStatus =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'stopping'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for the queue scheduler.
|
||||
*/
|
||||
export interface QueueSchedulerConfig {
|
||||
/** Maximum number of concurrent CLI sessions executing tasks. */
|
||||
maxConcurrentSessions: number;
|
||||
/** Idle timeout (ms) before releasing a session from the pool. */
|
||||
sessionIdleTimeoutMs: number;
|
||||
/** Timeout (ms) for resumeKey-to-session binding affinity. */
|
||||
resumeKeySessionBindingTimeoutMs: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Entities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A single task item in the execution queue.
|
||||
*/
|
||||
export interface QueueItem {
|
||||
/** Unique identifier for this queue item. */
|
||||
item_id: string;
|
||||
/** Reference to the parent issue (if applicable). */
|
||||
issue_id?: string;
|
||||
/** Current status of the item. */
|
||||
status: QueueItemStatus;
|
||||
/** CLI tool to use for execution (e.g., 'gemini', 'claude'). */
|
||||
tool: string;
|
||||
/** Prompt/instruction to send to the CLI tool. */
|
||||
prompt: string;
|
||||
/** Execution mode. */
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
/** Resume key for session affinity and conversation continuity. */
|
||||
resumeKey?: string;
|
||||
/** Strategy for resuming a previous CLI session. */
|
||||
resumeStrategy?: CliSessionResumeStrategy;
|
||||
/** Item IDs that must complete before this item can execute. */
|
||||
depends_on: string[];
|
||||
/** Numeric order for scheduling priority within a group. Lower = earlier. */
|
||||
execution_order: number;
|
||||
/** Logical grouping for related items (e.g., same issue). */
|
||||
execution_group?: string;
|
||||
/** Session key assigned when executing. */
|
||||
sessionKey?: string;
|
||||
/** Timestamp when item was added to the queue. */
|
||||
createdAt: string;
|
||||
/** Timestamp when execution started. */
|
||||
startedAt?: string;
|
||||
/** Timestamp when execution completed (success or failure). */
|
||||
completedAt?: string;
|
||||
/** Error message if status is 'failed'. */
|
||||
error?: string;
|
||||
/** Output from CLI execution. */
|
||||
output?: string;
|
||||
/** Arbitrary metadata for extensibility. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a session bound to a resumeKey for affinity-based allocation.
|
||||
*/
|
||||
export interface SessionBinding {
|
||||
/** The CLI session key from CliSessionManager. */
|
||||
sessionKey: string;
|
||||
/** Timestamp of last activity on this binding. */
|
||||
lastUsed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete snapshot of the scheduler state, used for WS broadcast and API responses.
|
||||
*/
|
||||
export interface QueueSchedulerState {
|
||||
/** Current scheduler status. */
|
||||
status: QueueSchedulerStatus;
|
||||
/** All items in the queue (pending, executing, completed, etc.). */
|
||||
items: QueueItem[];
|
||||
/** Session pool: resumeKey -> SessionBinding. */
|
||||
sessionPool: Record<string, SessionBinding>;
|
||||
/** Active scheduler configuration. */
|
||||
config: QueueSchedulerConfig;
|
||||
/** Number of currently executing tasks. */
|
||||
currentConcurrency: number;
|
||||
/** Timestamp of last scheduler activity. */
|
||||
lastActivityAt: string;
|
||||
/** Error message if scheduler status is 'failed'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Message Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Discriminator values for queue-related WebSocket messages.
|
||||
*/
|
||||
export type QueueWSMessageType =
|
||||
| 'QUEUE_SCHEDULER_STATE_UPDATE'
|
||||
| 'QUEUE_ITEM_ADDED'
|
||||
| 'QUEUE_ITEM_UPDATED'
|
||||
| 'QUEUE_ITEM_REMOVED'
|
||||
| 'QUEUE_SCHEDULER_CONFIG_UPDATED';
|
||||
|
||||
/**
|
||||
* Full scheduler state broadcast (sent on start/pause/stop/complete).
|
||||
*/
|
||||
export interface QueueSchedulerStateUpdateMessage {
|
||||
type: 'QUEUE_SCHEDULER_STATE_UPDATE';
|
||||
state: QueueSchedulerState;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast when a new item is added to the queue.
|
||||
*/
|
||||
export interface QueueItemAddedMessage {
|
||||
type: 'QUEUE_ITEM_ADDED';
|
||||
item: QueueItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast when an item's status or data changes.
|
||||
*/
|
||||
export interface QueueItemUpdatedMessage {
|
||||
type: 'QUEUE_ITEM_UPDATED';
|
||||
item: QueueItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast when an item is removed from the queue.
|
||||
*/
|
||||
export interface QueueItemRemovedMessage {
|
||||
type: 'QUEUE_ITEM_REMOVED';
|
||||
item_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast when scheduler configuration is updated.
|
||||
*/
|
||||
export interface QueueSchedulerConfigUpdatedMessage {
|
||||
type: 'QUEUE_SCHEDULER_CONFIG_UPDATED';
|
||||
config: QueueSchedulerConfig;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all queue WebSocket messages.
|
||||
*/
|
||||
export type QueueWSMessage =
|
||||
| QueueSchedulerStateUpdateMessage
|
||||
| QueueItemAddedMessage
|
||||
| QueueItemUpdatedMessage
|
||||
| QueueItemRemovedMessage
|
||||
| QueueSchedulerConfigUpdatedMessage;
|
||||
Reference in New Issue
Block a user