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

@@ -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);

View File

@@ -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) : '')
}))
};
}

View File

@@ -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;
}

View File

@@ -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) {