diff --git a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx index bb811d13..f02774c6 100644 --- a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx +++ b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx @@ -164,7 +164,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } setCliProvider(p); setMode('direct'); setProviderId(''); - setModel(p === 'claude' ? 'sonnet' : ''); + setModel(''); setSettingsFile(''); setAuthToken(''); setBaseUrl(''); @@ -291,7 +291,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } } settings = { env, - model: model || 'sonnet', + model: model || undefined, settingsFile: settingsFile.trim() || undefined, availableModels, tags, @@ -505,7 +505,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
- setModel(e.target.value)} placeholder="sonnet" /> + setModel(e.target.value)} placeholder="" />
@@ -545,7 +545,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
- setModel(e.target.value)} placeholder="sonnet" /> + setModel(e.target.value)} placeholder="" />
@@ -644,7 +644,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } id="codex-model" value={model} onChange={(e) => setModel(e.target.value)} - placeholder="gpt-5.2" + placeholder="" />

指定使用的模型,将自动更新到 config.toml 中 @@ -711,7 +711,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } id="codex-configtoml" value={configToml} onChange={(e) => setConfigToml(e.target.value)} - placeholder={'model = "gpt-5.2"\nmodel_reasoning_effort = "xhigh"'} + placeholder="" className="font-mono text-sm" rows={6} /> @@ -778,7 +778,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } id="gemini-model" value={model} onChange={(e) => setModel(e.target.value)} - placeholder="gemini-2.5-flash" + placeholder="" /> @@ -803,7 +803,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider } id="gemini-settingsjson" value={geminiSettingsJson} onChange={(e) => setGeminiSettingsJson(e.target.value)} - placeholder='{"model": "gemini-2.5-flash", ...}' + placeholder="{}" className="font-mono text-sm" rows={8} readOnly diff --git a/ccw/frontend/src/components/api-settings/EndpointModal.tsx b/ccw/frontend/src/components/api-settings/EndpointModal.tsx index 1889f313..c9b96c8e 100644 --- a/ccw/frontend/src/components/api-settings/EndpointModal.tsx +++ b/ccw/frontend/src/components/api-settings/EndpointModal.tsx @@ -295,7 +295,7 @@ export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) { id="model" value={model} onChange={(e) => setModel(e.target.value)} - placeholder="gpt-4o" + placeholder="" className="font-mono" /> ) : ( diff --git a/ccw/frontend/src/components/layout/Header.tsx b/ccw/frontend/src/components/layout/Header.tsx index 4e083057..2523fc19 100644 --- a/ccw/frontend/src/components/layout/Header.tsx +++ b/ccw/frontend/src/components/layout/Header.tsx @@ -16,7 +16,6 @@ import { LogOut, Terminal, Bell, - Clock, Monitor, SquareTerminal, } from 'lucide-react'; @@ -86,19 +85,6 @@ export function Header({ {/* Right side - Actions */}

- {/* History entry */} - - {/* CLI Monitor button */} +
+ {isModelDropdownOpen && ( +
+ + {filteredModelOptions.map((m) => ( + + ))} +
+ )} + diff --git a/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx b/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx index 5e8474c2..38e34d8d 100644 --- a/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx @@ -22,6 +22,7 @@ import { Minimize2, Activity, Plus, + Gauge, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/Badge'; @@ -36,11 +37,12 @@ import { toast } from '@/stores/notificationStore'; import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore'; import { useSessionManagerStore } from '@/stores/sessionManagerStore'; import { useConfigStore } from '@/stores/configStore'; +import { useQueueSchedulerStore, selectQueueSchedulerStatus } from '@/stores/queueSchedulerStore'; import { CliConfigModal, type CliSessionConfig } from './CliConfigModal'; // ========== Types ========== -export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution'; +export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution' | 'scheduler'; interface DashboardToolbarProps { activePanel: PanelId | null; @@ -95,6 +97,10 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen // Execution monitor count const executionCount = useExecutionMonitorStore(selectActiveExecutionCount); + // Scheduler status for badge indicator + const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus); + const isSchedulerActive = schedulerStatus !== 'idle'; + // Feature flags for panel visibility const featureFlags = useConfigStore((s) => s.featureFlags); const showQueue = featureFlags.dashboardQueuePanelEnabled; @@ -244,6 +250,13 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen badge={executionCount > 0 ? executionCount : undefined} /> )} + onTogglePanel('scheduler')} + dot={isSchedulerActive} + /> string> = { 'direct-send': (idStr) => `根据@.workflow/issues/issues.jsonl中的 ${idStr} 需求,进行开发`, }; +// ========== Queue Prompt Builder ========== + +function buildQueuePrompt(issues: Issue[]): string { + const ids = issues.map((i) => i.id).join(' '); + return `根据@.workflow/issues/issues.jsonl中的 ${ids} 需求,进行开发`; +} + // ========== Priority Badge ========== const PRIORITY_STYLES: Record = { @@ -203,6 +212,13 @@ export function IssuePanel() { const [customPrompt, setCustomPrompt] = useState(''); const sentTimerRef = useRef | null>(null); + // Queue state + const [isAddingToQueue, setIsAddingToQueue] = useState(false); + const [justQueued, setJustQueued] = useState(false); + const [queueMode, setQueueMode] = useState<'write' | 'analysis' | 'auto'>('write'); + const queuedTimerRef = useRef | null>(null); + const submitItems = useQueueSchedulerStore((s) => s.submitItems); + // Terminal refs const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId); const panes = useTerminalGridStore(selectTerminalGridPanes); @@ -234,10 +250,11 @@ export function IssuePanel() { } }, [availableMethods, executionMethod]); - // Cleanup sent feedback timer on unmount + // Cleanup feedback timers on unmount useEffect(() => { return () => { if (sentTimerRef.current) clearTimeout(sentTimerRef.current); + if (queuedTimerRef.current) clearTimeout(queuedTimerRef.current); }; }, []); @@ -349,6 +366,43 @@ export function IssuePanel() { } }, [sessionKey, selectedIds, projectPath, executionMethod, sessionCliTool, customPrompt]); + const handleAddToQueue = useCallback(async () => { + if (selectedIds.size === 0) return; + setIsAddingToQueue(true); + try { + const selectedIssues = sortedIssues.filter((i) => selectedIds.has(i.id)); + const now = new Date().toISOString(); + const items = selectedIssues.map((issue, index) => ({ + item_id: `Q-${issue.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + issue_id: issue.id, + status: 'pending' as const, + tool: sessionCliTool || 'gemini', + prompt: buildQueuePrompt([issue]), + mode: queueMode, + depends_on: [] as string[], + execution_order: index + 1, + execution_group: 'default', + createdAt: now, + metadata: { issueTitle: issue.title, issuePriority: issue.priority }, + })); + await submitItems(items); + toast.success( + formatMessage({ id: 'terminalDashboard.issuePanel.addedToQueue', defaultMessage: 'Added to queue' }), + `${items.length} item(s)` + ); + setJustQueued(true); + if (queuedTimerRef.current) clearTimeout(queuedTimerRef.current); + queuedTimerRef.current = setTimeout(() => setJustQueued(false), 2000); + } catch (err) { + toast.error( + formatMessage({ id: 'terminalDashboard.issuePanel.addToQueueFailed', defaultMessage: 'Failed to add to queue' }), + err instanceof Error ? err.message : String(err) + ); + } finally { + setIsAddingToQueue(false); + } + }, [selectedIds, sortedIssues, sessionCliTool, submitItems, formatMessage]); + // Loading state if (isLoading) { return ( @@ -512,22 +566,55 @@ export function IssuePanel() { Clear - +
+ {/* Queue mode selector */} + + {/* Add to Queue button */} + + {/* Send to Terminal button */} + +
)} diff --git a/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx b/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx new file mode 100644 index 00000000..d0723020 --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx @@ -0,0 +1,330 @@ +// ======================================== +// QueueListColumn Component +// ======================================== +// Queue items list for embedding in the Issues dual-column panel. +// Unified data source: queueSchedulerStore only. +// Includes inline scheduler controls at the bottom. + +import { useMemo, useCallback, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { + ListChecks, + Loader2, + CheckCircle, + XCircle, + Zap, + Ban, + Square, + Terminal, + Timer, + Clock, + Play, + Pause, + StopCircle, +} from 'lucide-react'; +import { Badge } from '@/components/ui/Badge'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/AlertDialog'; +import { cn } from '@/lib/utils'; +import { + useIssueQueueIntegrationStore, + selectAssociationChain, +} from '@/stores/issueQueueIntegrationStore'; +import { + useQueueExecutionStore, + selectByQueueItem, +} from '@/stores/queueExecutionStore'; +import { + useQueueSchedulerStore, + selectQueueSchedulerStatus, + selectQueueItems, + selectSchedulerProgress, + selectCurrentConcurrency, + selectSchedulerConfig, +} from '@/stores/queueSchedulerStore'; +import type { QueueItem, QueueItemStatus } from '@/types/queue-frontend-types'; + +// ========== Status Config ========== + +const STATUS_CONFIG: Record = { + pending: { variant: 'secondary', icon: Clock, label: 'Pending' }, + queued: { variant: 'info', icon: Timer, label: 'Queued' }, + ready: { variant: 'info', icon: Zap, label: 'Ready' }, + blocked: { variant: 'outline', icon: Ban, label: 'Blocked' }, + executing: { variant: 'warning', icon: Loader2, label: 'Executing' }, + completed: { variant: 'success', icon: CheckCircle, label: 'Completed' }, + failed: { variant: 'destructive', icon: XCircle, label: 'Failed' }, + cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' }, +}; + +// ========== Scheduler Status Styles ========== + +const SCHEDULER_STATUS_STYLE: Record = { + idle: 'bg-muted text-muted-foreground', + running: 'bg-blue-500/15 text-blue-600', + paused: 'bg-yellow-500/15 text-yellow-600', + stopping: 'bg-orange-500/15 text-orange-600', + completed: 'bg-green-500/15 text-green-600', + failed: 'bg-red-500/15 text-red-600', +}; + +// ========== Item Row ========== + +function QueueItemRow({ + item, + isHighlighted, + onSelect, +}: { + item: QueueItem; + isHighlighted: boolean; + onSelect: () => void; +}) { + const { formatMessage } = useIntl(); + const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending; + const StatusIcon = config.icon; + + const executions = useQueueExecutionStore(selectByQueueItem(item.item_id)); + const activeExec = executions.find((e) => e.status === 'running') ?? executions[0]; + const sessionKey = item.sessionKey ?? activeExec?.sessionKey; + + const isExecuting = item.status === 'executing'; + const isBlocked = item.status === 'blocked'; + + // Show issue_id if available (for items added from IssuePanel) + const displayId = item.issue_id ? `${item.issue_id}` : item.item_id; + + return ( + + ); +} + +// ========== Inline Scheduler Controls ========== + +function SchedulerBar() { + const { formatMessage } = useIntl(); + const status = useQueueSchedulerStore(selectQueueSchedulerStatus); + const progress = useQueueSchedulerStore(selectSchedulerProgress); + const concurrency = useQueueSchedulerStore(selectCurrentConcurrency); + const config = useQueueSchedulerStore(selectSchedulerConfig); + const startQueue = useQueueSchedulerStore((s) => s.startQueue); + const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue); + const stopQueue = useQueueSchedulerStore((s) => s.stopQueue); + const items = useQueueSchedulerStore(selectQueueItems); + + const canStart = status === 'idle' && items.length > 0; + const canPause = status === 'running'; + const canResume = status === 'paused'; + const canStop = status === 'running' || status === 'paused'; + const isActive = status !== 'idle'; + + const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false); + + const handleStart = useCallback(() => { + if (canResume) { + startQueue(); + } else if (canStart) { + startQueue(items); + } + }, [canResume, canStart, startQueue, items]); + + return ( +
+
+ {/* Status badge */} + + {formatMessage({ id: `terminalDashboard.queuePanel.scheduler.status.${status}`, defaultMessage: status })} + + + {/* Progress + Concurrency */} + {isActive && ( + + {progress}% | {concurrency}/{config.maxConcurrentSessions} + + )} + + {/* Controls */} +
+ {(canStart || canResume) && ( + + )} + {canPause && ( + + )} + {canStop && ( + + )} +
+
+ + {/* Progress bar */} + {isActive && ( +
+
+
+ )} + + {/* Stop confirmation dialog */} + + + + + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })} + + + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started.' })} + + + + + {formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })} + + { stopQueue(); setIsStopConfirmOpen(false); }} + > + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })} + + + + +
+ ); +} + +// ========== Main Component ========== + +export function QueueListColumn() { + const { formatMessage } = useIntl(); + + const items = useQueueSchedulerStore(selectQueueItems); + const associationChain = useIssueQueueIntegrationStore(selectAssociationChain); + const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain); + + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.execution_order - b.execution_order), + [items] + ); + + const handleSelect = useCallback( + (queueItemId: string) => { + buildAssociationChain(queueItemId, 'queue'); + }, + [buildAssociationChain] + ); + + return ( +
+ {/* Item list */} + {sortedItems.length === 0 ? ( +
+
+ +

{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}

+

+ {formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc', defaultMessage: 'Select issues and click Queue to add items' })} +

+
+
+ ) : ( +
+ {sortedItems.map((item) => ( + handleSelect(item.item_id)} + /> + ))} +
+ )} + + {/* Inline scheduler controls */} + +
+ ); +} diff --git a/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx b/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx index a089212a..145d5a2f 100644 --- a/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx @@ -2,9 +2,10 @@ // QueuePanel Component // ======================================== // Queue list panel for the terminal dashboard with tab switching. -// Tab 1 (Queue): Issue queue items from useIssueQueue() hook. +// Tab 1 (Queue): Issue queue items from queueSchedulerStore (with useIssueQueue() fallback). // Tab 2 (Orchestrator): Active orchestration plans from orchestratorStore. // Integrates with issueQueueIntegrationStore for association chain. +// Note: Scheduler controls are in the standalone SchedulerPanel. import { useState, useMemo, useCallback, memo } from 'react'; import { useIntl } from 'react-intl'; @@ -27,6 +28,7 @@ import { Square, RotateCcw, AlertCircle, + Timer, } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; @@ -40,6 +42,11 @@ import { useQueueExecutionStore, selectByQueueItem, } from '@/stores/queueExecutionStore'; +import { + useQueueSchedulerStore, + selectQueueSchedulerStatus, + selectQueueItems, +} from '@/stores/queueSchedulerStore'; import { useOrchestratorStore, selectActivePlans, @@ -47,54 +54,77 @@ import { type OrchestrationRunState, } from '@/stores/orchestratorStore'; import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator'; -import type { QueueItem } from '@/lib/api'; +import type { QueueItem as ApiQueueItem } from '@/lib/api'; +import type { QueueItem as SchedulerQueueItem, QueueItemStatus as SchedulerQueueItemStatus } from '@/types/queue-frontend-types'; // ========== Tab Type ========== type QueueTab = 'queue' | 'orchestrator'; +// ========== Queue Tab: Unified Item Type ========== + +/** + * Unified queue item type that works with both the legacy API QueueItem + * and the new scheduler QueueItem. The scheduler type is a superset. + */ +type UnifiedQueueItem = ApiQueueItem | SchedulerQueueItem; + +/** + * Combined status type covering both scheduler statuses and legacy API statuses. + */ +type CombinedQueueItemStatus = SchedulerQueueItemStatus | ApiQueueItem['status']; + // ========== Queue Tab: Status Config ========== -type QueueItemStatus = QueueItem['status']; - -const STATUS_CONFIG: Record = { pending: { variant: 'secondary', icon: Clock, label: 'Pending' }, + queued: { variant: 'info', icon: Timer, label: 'Queued' }, ready: { variant: 'info', icon: Zap, label: 'Ready' }, + blocked: { variant: 'outline', icon: Ban, label: 'Blocked' }, executing: { variant: 'warning', icon: Loader2, label: 'Executing' }, completed: { variant: 'success', icon: CheckCircle, label: 'Completed' }, failed: { variant: 'destructive', icon: XCircle, label: 'Failed' }, - blocked: { variant: 'outline', icon: Ban, label: 'Blocked' }, + cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' }, }; -// ========== Queue Tab: Item Row ========== +// ========== Queue Tab: Content ========== function QueueItemRow({ item, isHighlighted, onSelect, }: { - item: QueueItem; + item: UnifiedQueueItem; isHighlighted: boolean; onSelect: () => void; }) { const { formatMessage } = useIntl(); - const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending; + const statusKey = item.status as CombinedQueueItemStatus; + const config = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.pending; const StatusIcon = config.icon; const executions = useQueueExecutionStore(selectByQueueItem(item.item_id)); const activeExec = executions.find((e) => e.status === 'running') ?? executions[0]; + // Session key: prefer scheduler QueueItem.sessionKey, fallback to execution store + const schedulerItem = item as SchedulerQueueItem; + const sessionKey = schedulerItem.sessionKey ?? activeExec?.sessionKey; + + const isExecuting = item.status === 'executing'; + const isBlocked = item.status === 'blocked'; + return (
- {formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}` })} + {formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
@@ -125,17 +155,25 @@ function QueueItemRow({ | {item.execution_group} - {activeExec?.sessionKey && ( + {sessionKey && ( <> | - {activeExec.sessionKey} + {sessionKey} )}
- {item.depends_on.length > 0 && ( + {isBlocked && item.depends_on.length > 0 && ( +
+ {formatMessage( + { id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' }, + { deps: item.depends_on.join(', ') } + )} +
+ )} + {!isBlocked && item.depends_on.length > 0 && (
{formatMessage( { id: 'terminalDashboard.queuePanel.dependsOn' }, @@ -151,14 +189,24 @@ function QueueItemRow({ function QueueTabContent(_props: { embedded?: boolean }) { const { formatMessage } = useIntl(); + + // Scheduler store data + const schedulerItems = useQueueSchedulerStore(selectQueueItems); + const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus); + + // Legacy API data (fallback) const queueQuery = useIssueQueue(); + const associationChain = useIssueQueueIntegrationStore(selectAssociationChain); const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain); - const allItems = useMemo(() => { + // Use scheduler items when scheduler has data, otherwise fall back to legacy API + const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle'; + + const legacyItems = useMemo(() => { if (!queueQuery.data) return []; const grouped = queueQuery.data.grouped_items ?? {}; - const items: QueueItem[] = []; + const items: ApiQueueItem[] = []; for (const group of Object.values(grouped)) { items.push(...group); } @@ -166,6 +214,13 @@ function QueueTabContent(_props: { embedded?: boolean }) { return items; }, [queueQuery.data]); + const allItems: UnifiedQueueItem[] = useMemo(() => { + if (useSchedulerData) { + return [...schedulerItems].sort((a, b) => a.execution_order - b.execution_order); + } + return legacyItems; + }, [useSchedulerData, schedulerItems, legacyItems]); + const handleSelect = useCallback( (queueItemId: string) => { buildAssociationChain(queueItemId, 'queue'); @@ -173,7 +228,8 @@ function QueueTabContent(_props: { embedded?: boolean }) { [buildAssociationChain] ); - if (queueQuery.isLoading) { + // Show loading only for legacy mode + if (!useSchedulerData && queueQuery.isLoading) { return (
@@ -181,7 +237,7 @@ function QueueTabContent(_props: { embedded?: boolean }) { ); } - if (queueQuery.error) { + if (!useSchedulerData && queueQuery.error) { return (
@@ -446,8 +502,23 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) { const [activeTab, setActiveTab] = useState('queue'); const orchestratorCount = useOrchestratorStore(selectActivePlanCount); + // Scheduler store data for active count + const schedulerItems = useQueueSchedulerStore(selectQueueItems); + const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus); + const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle'; + + // Legacy API data for active count fallback const queueQuery = useIssueQueue(); + const queueActiveCount = useMemo(() => { + if (useSchedulerData) { + return schedulerItems.filter( + (item) => + item.status === 'pending' || + item.status === 'queued' || + item.status === 'executing' + ).length; + } if (!queueQuery.data) return 0; const grouped = queueQuery.data.grouped_items ?? {}; let count = 0; @@ -457,7 +528,7 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) { ).length; } return count; - }, [queueQuery.data]); + }, [useSchedulerData, schedulerItems, queueQuery.data]); return (
diff --git a/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx b/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx new file mode 100644 index 00000000..8ef5a7d1 --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx @@ -0,0 +1,262 @@ +// ======================================== +// SchedulerPanel Component +// ======================================== +// Standalone queue scheduler control panel. +// Shows scheduler status, start/pause/stop controls, concurrency config, +// progress bar, and session pool overview. + +import { useCallback, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { + Play, + Pause, + Square, + Loader2, +} from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Progress } from '@/components/ui/Progress'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/AlertDialog'; +import { cn } from '@/lib/utils'; +import { + useQueueSchedulerStore, + selectQueueSchedulerStatus, + selectSchedulerProgress, + selectSchedulerConfig, + selectSessionPool, + selectCurrentConcurrency, + selectSchedulerError, +} from '@/stores/queueSchedulerStore'; +import type { QueueSchedulerStatus } from '@/types/queue-frontend-types'; + +// ========== Status Badge Config ========== + +const SCHEDULER_STATUS_CLASS: Record = { + idle: 'bg-muted text-muted-foreground border-border', + running: 'bg-primary/10 text-primary border-primary/50', + paused: 'bg-amber-500/10 text-amber-500 border-amber-500/50', + stopping: 'bg-orange-500/10 text-orange-500 border-orange-500/50', + completed: 'bg-green-500/10 text-green-500 border-green-500/50', + failed: 'bg-destructive/10 text-destructive border-destructive/50', +}; + +// ========== Component ========== + +export function SchedulerPanel() { + const { formatMessage } = useIntl(); + + const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus); + const progress = useQueueSchedulerStore(selectSchedulerProgress); + const config = useQueueSchedulerStore(selectSchedulerConfig); + const sessionPool = useQueueSchedulerStore(selectSessionPool); + const concurrency = useQueueSchedulerStore(selectCurrentConcurrency); + const error = useQueueSchedulerStore(selectSchedulerError); + const startQueue = useQueueSchedulerStore((s) => s.startQueue); + const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue); + const stopQueue = useQueueSchedulerStore((s) => s.stopQueue); + const updateConfig = useQueueSchedulerStore((s) => s.updateConfig); + + const isIdle = schedulerStatus === 'idle'; + const isRunning = schedulerStatus === 'running'; + const isPaused = schedulerStatus === 'paused'; + const isStopping = schedulerStatus === 'stopping'; + const isTerminal = schedulerStatus === 'completed' || schedulerStatus === 'failed'; + + const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false); + + const handleConcurrencyChange = useCallback( + (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 10) { + updateConfig({ maxConcurrentSessions: value }); + } + }, + [updateConfig] + ); + + const sessionEntries = Object.entries(sessionPool); + + return ( +
+ {/* Status + Controls */} +
+ {/* Status badge */} +
+ + {formatMessage({ + id: `terminalDashboard.queuePanel.scheduler.status.${schedulerStatus}`, + defaultMessage: schedulerStatus, + })} + + + {concurrency}/{config.maxConcurrentSessions} + +
+ + {/* Control buttons */} +
+ {(isIdle || isTerminal) && ( + + )} + {isPaused && ( + + )} + {isRunning && ( + + )} + {(isRunning || isPaused) && ( + + )} +
+ + {/* Progress bar (visible when not idle) */} + {!isIdle && ( +
+ + + {formatMessage( + { id: 'terminalDashboard.queuePanel.scheduler.progress', defaultMessage: '{percent}%' }, + { percent: progress } + )} + +
+ )} +
+ + {/* Concurrency Config */} +
+
+ + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.concurrency', defaultMessage: 'Concurrency' })} + + +
+
+ + {/* Session Pool */} +
+
+

+ {formatMessage({ id: 'terminalDashboard.schedulerPanel.sessionPool', defaultMessage: 'Session Pool' })} +

+ {sessionEntries.length === 0 ? ( +

+ {formatMessage({ id: 'terminalDashboard.schedulerPanel.noSessions', defaultMessage: 'No active sessions' })} +

+ ) : ( +
+ {sessionEntries.map(([resumeKey, binding]) => ( +
+
+ {binding.sessionKey} + {resumeKey} +
+ + {new Date(binding.lastUsed).toLocaleTimeString()} + +
+ ))} +
+ )} +
+
+ + {/* Error display */} + {error && ( +
+

{error}

+
+ )} + + {/* Stop confirmation dialog */} + + + + + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })} + + + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started. Pending items will remain in the queue.' })} + + + + + {formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })} + + { stopQueue(); setIsStopConfirmOpen(false); }} + > + {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })} + + + + +
+ ); +} diff --git a/ccw/frontend/src/hooks/useHistory.ts b/ccw/frontend/src/hooks/useHistory.ts index 21fc27fc..cc9ede99 100644 --- a/ccw/frontend/src/hooks/useHistory.ts +++ b/ccw/frontend/src/hooks/useHistory.ts @@ -85,9 +85,14 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn { if (filter?.search) { const searchLower = filter.search.toLowerCase(); executions = executions.filter( - (exec) => - exec.prompt_preview.toLowerCase().includes(searchLower) || - exec.tool.toLowerCase().includes(searchLower) + (exec) => { + // Guard against prompt_preview being an object instead of string + const preview = typeof exec.prompt_preview === 'string' + ? exec.prompt_preview + : JSON.stringify(exec.prompt_preview); + return preview.toLowerCase().includes(searchLower) || + exec.tool.toLowerCase().includes(searchLower); + } ); } diff --git a/ccw/frontend/src/hooks/useNativeSessions.ts b/ccw/frontend/src/hooks/useNativeSessions.ts new file mode 100644 index 00000000..28664f8a --- /dev/null +++ b/ccw/frontend/src/hooks/useNativeSessions.ts @@ -0,0 +1,127 @@ +// ======================================== +// useNativeSessions Hook +// ======================================== +// TanStack Query hook for native CLI sessions list + +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + fetchNativeSessions, + type NativeSessionListItem, + type NativeSessionsListResponse, +} from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; +import { workspaceQueryKeys } from '@/lib/queryKeys'; + +// ========== Constants ========== + +const STALE_TIME = 30 * 1000; +const GC_TIME = 5 * 60 * 1000; + +// ========== Types ========== + +export type NativeTool = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode'; + +export interface UseNativeSessionsOptions { + /** Filter by tool type */ + tool?: NativeTool; + /** Override default stale time (ms) */ + staleTime?: number; + /** Override default gc time (ms) */ + gcTime?: number; + /** Enable/disable the query */ + enabled?: boolean; +} + +export interface ByToolRecord { + [tool: string]: NativeSessionListItem[]; +} + +export interface UseNativeSessionsReturn { + /** All sessions data */ + sessions: NativeSessionListItem[]; + /** Sessions grouped by tool */ + byTool: ByToolRecord; + /** Total count from API */ + count: number; + /** Loading state for initial fetch */ + isLoading: boolean; + /** Fetching state (initial or refetch) */ + isFetching: boolean; + /** Error object if query failed */ + error: Error | null; + /** Manually refetch data */ + refetch: () => Promise; +} + +// ========== Helper Functions ========== + +/** + * Group sessions by tool type + */ +function groupByTool(sessions: NativeSessionListItem[]): ByToolRecord { + return sessions.reduce((acc, session) => { + const tool = session.tool; + if (!acc[tool]) { + acc[tool] = []; + } + acc[tool].push(session); + return acc; + }, {}); +} + +// ========== Hook ========== + +/** + * Hook for fetching native CLI sessions list + * + * @example + * ```tsx + * const { sessions, byTool, isLoading } = useNativeSessions(); + * const geminiSessions = byTool['gemini'] ?? []; + * ``` + * + * @example + * ```tsx + * // Filter by tool + * const { sessions } = useNativeSessions({ tool: 'gemini' }); + * ``` + */ +export function useNativeSessions( + options: UseNativeSessionsOptions = {} +): UseNativeSessionsReturn { + const { tool, staleTime = STALE_TIME, gcTime = GC_TIME, enabled = true } = options; + const projectPath = useWorkflowStore(selectProjectPath); + + const query = useQuery({ + queryKey: workspaceQueryKeys.nativeSessionsList(projectPath, tool), + queryFn: () => fetchNativeSessions(tool, projectPath), + staleTime, + gcTime, + enabled, + refetchOnWindowFocus: false, + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), + }); + + // Memoize sessions and byTool calculations + const { sessions, byTool } = React.useMemo(() => { + const sessions = query.data?.sessions ?? []; + const byTool = groupByTool(sessions); + return { sessions, byTool }; + }, [query.data]); + + const refetch = async () => { + await query.refetch(); + }; + + return { + sessions, + byTool, + count: query.data?.count ?? 0, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + refetch, + }; +} diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 1a20912a..7f353aa1 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -2100,7 +2100,8 @@ export interface CliExecution { tool: 'gemini' | 'qwen' | 'codex' | string; mode?: string; status: 'success' | 'error' | 'timeout'; - prompt_preview: string; + // Backend may return string or object {text: string} for legacy data + prompt_preview: string | { text: string } | Record; timestamp: string; duration_ms: number; sourceDir?: string; @@ -2233,7 +2234,8 @@ export interface ConversationRecord { */ export interface ConversationTurn { turn: number; - prompt: string; + // Backend may return string or object {text: string} for legacy data + prompt: string | { text: string } | Record; output: { stdout: string; stderr?: string; @@ -2270,7 +2272,8 @@ export interface NativeSessionTurn { turnNumber: number; timestamp: string; role: 'user' | 'assistant'; - content: string; + // Backend may return string or object {text: string} for legacy data + content: string | { text: string } | Record; thoughts?: string[]; toolCalls?: NativeToolCall[]; tokens?: NativeTokenInfo; diff --git a/ccw/frontend/src/lib/cli-tool-theme.ts b/ccw/frontend/src/lib/cli-tool-theme.ts new file mode 100644 index 00000000..5382688b --- /dev/null +++ b/ccw/frontend/src/lib/cli-tool-theme.ts @@ -0,0 +1,171 @@ +// ======================================== +// CLI Tool Theme Configuration +// ======================================== +// Centralized theme configuration for CLI tools (gemini, codex, qwen, opencode) +// Used for Badge variants, icons, and color theming across components + +import type { LucideIcon } from 'lucide-react'; +import { Sparkles, Code, Brain, Terminal, Cpu } from 'lucide-react'; + +// ========== Types ========== + +export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' | 'destructive'; + +export interface CliToolTheme { + /** Badge variant for UI components */ + variant: BadgeVariant; + /** Lucide icon name */ + icon: LucideIcon; + /** Color theme (used for CSS classes) */ + color: 'blue' | 'green' | 'amber' | 'gray' | 'purple'; + /** Human-readable display name */ + displayName: string; + /** Short label for compact display */ + shortLabel: string; +} + +// ========== Tool Theme Configuration ========== + +/** + * Theme configuration for each supported CLI tool + * Maps tool ID to visual theme properties + */ +export const CLI_TOOL_THEMES: Record = { + gemini: { + variant: 'info', + icon: Sparkles, + color: 'blue', + displayName: 'Gemini', + shortLabel: 'GEM', + }, + codex: { + variant: 'success', + icon: Code, + color: 'green', + displayName: 'Codex', + shortLabel: 'CDX', + }, + qwen: { + variant: 'warning', + icon: Brain, + color: 'amber', + displayName: 'Qwen', + shortLabel: 'QWN', + }, + opencode: { + variant: 'secondary', + icon: Terminal, + color: 'gray', + displayName: 'OpenCode', + shortLabel: 'OPC', + }, + claude: { + variant: 'default', + icon: Cpu, + color: 'purple', + displayName: 'Claude', + shortLabel: 'CLD', + }, +}; + +/** + * Default theme for unknown tools + */ +export const DEFAULT_TOOL_THEME: CliToolTheme = { + variant: 'secondary', + icon: Terminal, + color: 'gray', + displayName: 'CLI', + shortLabel: 'CLI', +}; + +// ========== Helper Functions ========== + +/** + * Get theme configuration for a CLI tool + * Falls back to default theme for unknown tools + * + * @param tool - Tool identifier (e.g., 'gemini', 'codex', 'qwen') + * @returns Theme configuration for the tool + */ +export function getToolTheme(tool: string): CliToolTheme { + const normalizedTool = tool.toLowerCase().trim(); + return CLI_TOOL_THEMES[normalizedTool] || DEFAULT_TOOL_THEME; +} + +/** + * Get Badge variant for a CLI tool + * Used for tool badges in UI components + * + * @param tool - Tool identifier + * @returns Badge variant + */ +export function getToolVariant(tool: string): BadgeVariant { + return getToolTheme(tool).variant; +} + +/** + * Get icon component for a CLI tool + * + * @param tool - Tool identifier + * @returns Lucide icon component + */ +export function getToolIcon(tool: string): LucideIcon { + return getToolTheme(tool).icon; +} + +/** + * Get color class for a CLI tool + * Returns a Tailwind CSS color class prefix + * + * @param tool - Tool identifier + * @returns Color class prefix (e.g., 'text-blue-500') + */ +export function getToolColorClass(tool: string, shade: number = 500): string { + const color = getToolTheme(tool).color; + const colorMap: Record = { + blue: 'blue', + green: 'green', + amber: 'amber', + gray: 'gray', + purple: 'purple', + }; + return `text-${colorMap[color] || 'gray'}-${shade}`; +} + +/** + * Get background color class for a CLI tool + * + * @param tool - Tool identifier + * @returns Background color class (e.g., 'bg-blue-100') + */ +export function getToolBgClass(tool: string, shade: number = 100): string { + const color = getToolTheme(tool).color; + const colorMap: Record = { + blue: 'blue', + green: 'green', + amber: 'amber', + gray: 'gray', + purple: 'purple', + }; + return `bg-${colorMap[color] || 'gray'}-${shade}`; +} + +/** + * Check if a tool is a known CLI tool + * + * @param tool - Tool identifier + * @returns Whether the tool is known + */ +export function isKnownTool(tool: string): boolean { + return tool.toLowerCase().trim() in CLI_TOOL_THEMES; +} + +/** + * Get all known tool identifiers + * + * @returns Array of known tool IDs + */ +export function getKnownTools(): string[] { + return Object.keys(CLI_TOOL_THEMES); +} diff --git a/ccw/frontend/src/lib/queryKeys.ts b/ccw/frontend/src/lib/queryKeys.ts index a5f3cbbb..61f522bb 100644 --- a/ccw/frontend/src/lib/queryKeys.ts +++ b/ccw/frontend/src/lib/queryKeys.ts @@ -118,6 +118,11 @@ export const workspaceQueryKeys = { cliExecutionDetail: (projectPath: string, executionId: string) => [...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const, + // ========== Native Sessions ========== + nativeSessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'nativeSessions'] as const, + nativeSessionsList: (projectPath: string, tool?: string) => + [...workspaceQueryKeys.nativeSessions(projectPath), 'list', tool] as const, + // ========== Audit ========== audit: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'audit'] as const, cliSessionAudit: ( diff --git a/ccw/frontend/src/locales/en/history.json b/ccw/frontend/src/locales/en/history.json index 0747d350..6d13a810 100644 --- a/ccw/frontend/src/locales/en/history.json +++ b/ccw/frontend/src/locales/en/history.json @@ -3,7 +3,8 @@ "description": "View and manage your CLI execution history", "tabs": { "executions": "Executions", - "observability": "Session Audit" + "observability": "Session Audit", + "nativeSessions": "Native Sessions" }, "searchPlaceholder": "Search executions...", "filterAllTools": "All Tools", @@ -33,5 +34,13 @@ "message": "CLI execution history will appear here when you run CLI commands.", "filtered": "No Matching Results", "filteredMessage": "No executions match your current filter. Try adjusting your search or filter." + }, + "nativeSessions": { + "count": "{count} native sessions", + "sessions": "sessions", + "empty": { + "title": "No Native Sessions", + "message": "Native CLI sessions from Gemini, Codex, Qwen, etc. will appear here." + } } } diff --git a/ccw/frontend/src/locales/en/terminal-dashboard.json b/ccw/frontend/src/locales/en/terminal-dashboard.json index 6c1e214a..3fa7cff5 100644 --- a/ccw/frontend/src/locales/en/terminal-dashboard.json +++ b/ccw/frontend/src/locales/en/terminal-dashboard.json @@ -48,6 +48,11 @@ "issuePanel": { "title": "Issues", "sendToQueue": "Send to Queue", + "addToQueue": "Queue", + "addToQueueHint": "Add selected issues to the execution queue", + "addedToQueue": "Queued!", + "addToQueueFailed": "Failed to add to queue", + "queueModeHint": "Execution mode for queued items", "noIssues": "No issues found", "noIssuesDesc": "Issues will appear here when discovered", "error": "Failed to load issues" @@ -59,13 +64,35 @@ "error": "Failed to load queue", "order": "#{order}", "dependsOn": "Depends on: {deps}", + "blockedBy": "Blocked by: {deps}", "status": { "pending": "Pending", + "queued": "Queued", "ready": "Ready", + "blocked": "Blocked", "executing": "Executing", "completed": "Completed", "failed": "Failed", - "blocked": "Blocked" + "cancelled": "Cancelled", + "skipped": "Skipped" + }, + "scheduler": { + "start": "Start", + "pause": "Pause", + "stop": "Stop", + "status": { + "idle": "Idle", + "running": "Running", + "paused": "Paused", + "stopping": "Stopping", + "completed": "Completed", + "failed": "Failed" + }, + "progress": "{percent}%", + "concurrency": "Concurrency", + "concurrencyLabel": "Max", + "stopConfirmTitle": "Stop Queue?", + "stopConfirmMessage": "Executing tasks will finish, but no new tasks will be started. Pending items will remain in the queue." } }, "toolbar": { @@ -81,7 +108,13 @@ "launchCli": "New Session", "launchCliHint": "Click to configure and create a new CLI session", "fullscreen": "Fullscreen", - "orchestrator": "Orchestrator" + "orchestrator": "Orchestrator", + "scheduler": "Scheduler", + "executionMonitor": "Execution Monitor" + }, + "schedulerPanel": { + "sessionPool": "Session Pool", + "noSessions": "No active sessions" }, "orchestratorPanel": { "noPlans": "No active orchestrations", diff --git a/ccw/frontend/src/locales/zh/history.json b/ccw/frontend/src/locales/zh/history.json index e17d7a29..5da35235 100644 --- a/ccw/frontend/src/locales/zh/history.json +++ b/ccw/frontend/src/locales/zh/history.json @@ -3,7 +3,8 @@ "description": "查看和管理 CLI 执行历史", "tabs": { "executions": "执行历史", - "observability": "会话审计" + "observability": "会话审计", + "nativeSessions": "原生会话" }, "searchPlaceholder": "搜索执行记录...", "filterAllTools": "全部工具", @@ -33,5 +34,13 @@ "message": "运行 CLI 命令后,执行历史将显示在这里。", "filtered": "没有匹配结果", "filteredMessage": "没有匹配当前筛选条件的执行记录。请尝试调整搜索或筛选条件。" + }, + "nativeSessions": { + "count": "{count} 个原生会话", + "sessions": "个会话", + "empty": { + "title": "无原生会话", + "message": "来自 Gemini、Codex、Qwen 等的原生 CLI 会话将显示在这里。" + } } } diff --git a/ccw/frontend/src/locales/zh/terminal-dashboard.json b/ccw/frontend/src/locales/zh/terminal-dashboard.json index cf9356ed..b0f54013 100644 --- a/ccw/frontend/src/locales/zh/terminal-dashboard.json +++ b/ccw/frontend/src/locales/zh/terminal-dashboard.json @@ -48,6 +48,11 @@ "issuePanel": { "title": "问题", "sendToQueue": "发送到队列", + "addToQueue": "入队", + "addToQueueHint": "将选中的问题添加到执行队列", + "addedToQueue": "已入队!", + "addToQueueFailed": "添加到队列失败", + "queueModeHint": "队列项的执行模式", "noIssues": "暂无问题", "noIssuesDesc": "发现问题时将在此显示", "error": "加载问题失败" @@ -59,13 +64,35 @@ "error": "加载队列失败", "order": "#{order}", "dependsOn": "依赖: {deps}", + "blockedBy": "阻塞于: {deps}", "status": { "pending": "等待中", + "queued": "排队中", "ready": "就绪", + "blocked": "已阻塞", "executing": "执行中", "completed": "已完成", "failed": "已失败", - "blocked": "已阻塞" + "cancelled": "已取消", + "skipped": "已跳过" + }, + "scheduler": { + "start": "启动", + "pause": "暂停", + "stop": "停止", + "status": { + "idle": "空闲", + "running": "运行中", + "paused": "已暂停", + "stopping": "停止中", + "completed": "已完成", + "failed": "已失败" + }, + "progress": "{percent}%", + "concurrency": "并发数", + "concurrencyLabel": "上限", + "stopConfirmTitle": "停止队列?", + "stopConfirmMessage": "执行中的任务将继续完成,但不会启动新任务。待处理项将保留在队列中。" } }, "toolbar": { @@ -81,7 +108,13 @@ "launchCli": "新建会话", "launchCliHint": "点击配置并创建新的 CLI 会话", "fullscreen": "全屏", - "orchestrator": "编排器" + "orchestrator": "编排器", + "scheduler": "调度器", + "executionMonitor": "执行监控" + }, + "schedulerPanel": { + "sessionPool": "会话池", + "noSessions": "无活跃会话" }, "orchestratorPanel": { "noPlans": "没有活跃的编排任务", diff --git a/ccw/frontend/src/pages/HistoryPage.tsx b/ccw/frontend/src/pages/HistoryPage.tsx index dfaac498..103d705e 100644 --- a/ccw/frontend/src/pages/HistoryPage.tsx +++ b/ccw/frontend/src/pages/HistoryPage.tsx @@ -16,10 +16,16 @@ import { X, Maximize2, Minimize2, + ChevronDown, + ChevronRight, + FileJson, + Clock, + Calendar, } from 'lucide-react'; import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore'; import { cn } from '@/lib/utils'; import { useHistory } from '@/hooks/useHistory'; +import { useNativeSessions } from '@/hooks/useNativeSessions'; import { ConversationCard } from '@/components/shared/ConversationCard'; import { CliStreamPanel } from '@/components/shared/CliStreamPanel'; import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel'; @@ -42,9 +48,55 @@ import { DropdownMenuSeparator, DropdownMenuLabel, } from '@/components/ui/Dropdown'; -import type { CliExecution } from '@/lib/api'; +import { Badge } from '@/components/ui/Badge'; +import type { CliExecution, NativeSessionListItem } from '@/lib/api'; +import { getToolVariant } from '@/lib/cli-tool-theme'; -type HistoryTab = 'executions' | 'observability'; +type HistoryTab = 'executions' | 'observability' | 'native-sessions'; + +// ========== Date Grouping Helpers ========== + +type DateGroup = 'today' | 'yesterday' | 'thisWeek' | 'older'; + +function getDateGroup(date: Date): DateGroup { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + if (date >= today) return 'today'; + if (date >= yesterday) return 'yesterday'; + if (date >= weekAgo) return 'thisWeek'; + return 'older'; +} + +function groupSessionsByDate(sessions: NativeSessionListItem[]): Map { + const groups = new Map([ + ['today', []], + ['yesterday', []], + ['thisWeek', []], + ['older', []], + ]); + + sessions.forEach((session) => { + const date = new Date(session.updatedAt); + const group = getDateGroup(date); + groups.get(group)?.push(session); + }); + + return groups; +} + +const dateGroupOrder: DateGroup[] = ['today', 'yesterday', 'thisWeek', 'older']; + +const dateGroupLabels: Record = { + today: '今天', + yesterday: '昨天', + thisWeek: '本周', + older: '更早', +}; /** * HistoryPage component - Display CLI execution history @@ -78,6 +130,40 @@ export function HistoryPage() { filter: { search: searchQuery || undefined, tool: toolFilter }, }); + // Native sessions hook + const { + sessions: nativeSessions, + byTool: nativeSessionsByTool, + isLoading: isLoadingNativeSessions, + isFetching: isFetchingNativeSessions, + error: nativeSessionsError, + refetch: refetchNativeSessions, + } = useNativeSessions(); + + // Track expanded tool groups in native sessions tab + const [expandedTools, setExpandedTools] = React.useState>(new Set()); + + const toggleToolExpand = (tool: string) => { + setExpandedTools((prev) => { + const next = new Set(prev); + if (next.has(tool)) { + next.delete(tool); + } else { + next.add(tool); + } + return next; + }); + }; + + // Native session click handler - opens NativeSessionPanel + const handleNativeSessionClick = (session: NativeSessionListItem) => { + setNativeExecutionId(session.id); + setIsNativePanelOpen(true); + }; + + // Tool order for display + const toolOrder = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const; + const tools = React.useMemo(() => { const toolSet = new Set(executions.map((e) => e.tool)); return Array.from(toolSet).sort(); @@ -197,6 +283,19 @@ export function HistoryPage() { > {formatMessage({ id: 'history.tabs.observability' })} +
{/* Tab Content */} @@ -354,6 +453,183 @@ export function HistoryPage() {
)} + {currentTab === 'native-sessions' && ( +
+ {/* Header with refresh */} +
+

+ {formatMessage( + { id: 'history.nativeSessions.count' }, + { count: nativeSessions.length } + )} +

+ +
+ + {/* Error alert */} + {nativeSessionsError && ( +
+ +
+

{formatMessage({ id: 'common.errors.loadFailed' })}

+

{nativeSessionsError.message}

+
+ +
+ )} + + {/* Loading state */} + {isLoadingNativeSessions ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : nativeSessions.length === 0 ? ( + /* Empty state */ +
+ +

+ {formatMessage({ id: 'history.nativeSessions.empty.title' })} +

+

+ {formatMessage({ id: 'history.nativeSessions.empty.message' })} +

+
+ ) : ( + /* Sessions grouped by tool */ +
+ {toolOrder + .filter((tool) => nativeSessionsByTool[tool]?.length > 0) + .map((tool) => { + const sessions = nativeSessionsByTool[tool]; + const isExpanded = expandedTools.has(tool); + return ( +
+ {/* Tool header - clickable to expand/collapse */} + + {/* Sessions list */} + {isExpanded && ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+ ); + })} + {/* Other tools not in predefined order */} + {Object.keys(nativeSessionsByTool) + .filter((tool) => !toolOrder.includes(tool as typeof toolOrder[number])) + .sort() + .map((tool) => { + const sessions = nativeSessionsByTool[tool]; + const isExpanded = expandedTools.has(tool); + return ( +
+ + {isExpanded && ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ )} + {/* CLI Stream Panel */} - +
+
+ +
+
+
+

{formatMessage({ id: 'terminalDashboard.toolbar.queue' })}

+
+ +
+
{featureFlags.dashboardQueuePanelEnabled && ( @@ -145,6 +157,16 @@ export function TerminalDashboardPage() { )} + + + +
); diff --git a/ccw/frontend/src/stores/cliStreamStore.ts b/ccw/frontend/src/stores/cliStreamStore.ts index fafca490..a885caa8 100644 --- a/ccw/frontend/src/stores/cliStreamStore.ts +++ b/ccw/frontend/src/stores/cliStreamStore.ts @@ -78,6 +78,7 @@ interface CliStreamState extends BlockCacheState { executions: Record; currentExecutionId: string | null; userClosedExecutions: Set; // Track executions closed by user + deduplicationWindows: Record; // Rolling hash window per execution // Legacy methods addOutput: (executionId: string, line: CliOutputLine) => void; @@ -106,8 +107,26 @@ interface CliStreamState extends BlockCacheState { */ const MAX_OUTPUT_LINES = 5000; +/** + * Size of rolling deduplication window per execution + * Lines with identical content within this window will be skipped + */ +const DEDUPLICATION_WINDOW_SIZE = 100; + // ========== Helper Functions ========== +/** + * Simple hash function for content deduplication + * Uses djb2 algorithm - fast and good enough for deduplication + */ +function simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + } + return hash.toString(36); +} + /** * Parse tool call metadata from content * Expected format: "[Tool] toolName(args)" @@ -325,12 +344,25 @@ export const useCliStreamStore = create()( executions: {}, currentExecutionId: null, userClosedExecutions: new Set(), + deduplicationWindows: {}, // Block cache state blocks: {}, lastUpdate: {}, addOutput: (executionId: string, line: CliOutputLine) => { + // Content-based deduplication using rolling hash window + const contentHash = simpleHash(line.content); + const currentWindow = get().deduplicationWindows[executionId] || []; + + // Skip if duplicate detected (same hash in recent window) + if (currentWindow.includes(contentHash)) { + return; // Skip duplicate content + } + + // Update deduplication window + const newWindow = [...currentWindow, contentHash].slice(-DEDUPLICATION_WINDOW_SIZE); + set((state) => { const current = state.outputs[executionId] || []; const updated = [...current, line]; @@ -342,6 +374,10 @@ export const useCliStreamStore = create()( ...state.outputs, [executionId]: updated.slice(-MAX_OUTPUT_LINES), }, + deduplicationWindows: { + ...state.deduplicationWindows, + [executionId]: newWindow, + }, }; } @@ -350,6 +386,10 @@ export const useCliStreamStore = create()( ...state.outputs, [executionId]: updated, }, + deduplicationWindows: { + ...state.deduplicationWindows, + [executionId]: newWindow, + }, }; }, false, 'cliStream/addOutput'); @@ -425,13 +465,16 @@ export const useCliStreamStore = create()( const newExecutions = { ...state.executions }; const newBlocks = { ...state.blocks }; const newLastUpdate = { ...state.lastUpdate }; + const newDeduplicationWindows = { ...state.deduplicationWindows }; delete newExecutions[executionId]; delete newBlocks[executionId]; delete newLastUpdate[executionId]; + delete newDeduplicationWindows[executionId]; return { executions: newExecutions, blocks: newBlocks, lastUpdate: newLastUpdate, + deduplicationWindows: newDeduplicationWindows, currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId, }; }, false, 'cliStream/removeExecution'); diff --git a/ccw/frontend/src/stores/queueSchedulerStore.ts b/ccw/frontend/src/stores/queueSchedulerStore.ts new file mode 100644 index 00000000..74a6d506 --- /dev/null +++ b/ccw/frontend/src/stores/queueSchedulerStore.ts @@ -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; + 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; + /** Submit items to the queue via POST /api/queue/execute (auto-starts if idle) */ + submitItems: (items: QueueItem[]) => Promise; + /** Start the queue scheduler via POST /api/queue/scheduler/start */ + startQueue: (items?: QueueItem[]) => Promise; + /** Pause the queue scheduler via POST /api/queue/scheduler/pause */ + pauseQueue: () => Promise; + /** Stop the queue scheduler via POST /api/queue/scheduler/stop */ + stopQueue: () => Promise; + /** Update scheduler config via POST /api/queue/scheduler/config */ + updateConfig: (config: Partial) => Promise; +} + +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()( + 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) => { + 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 => + 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; diff --git a/ccw/frontend/src/test/i18n.tsx b/ccw/frontend/src/test/i18n.tsx index 3025ee99..bef3b073 100644 --- a/ccw/frontend/src/test/i18n.tsx +++ b/ccw/frontend/src/test/i18n.tsx @@ -283,6 +283,54 @@ const mockMessages: Record> = { 'codexlens.reranker.selectBackend': 'Select backend...', 'codexlens.reranker.selectModel': 'Select model...', 'codexlens.reranker.selectProvider': 'Select provider...', + // MCP - CCW Tools + 'mcp.ccw.title': 'CCW MCP Server', + 'mcp.ccw.description': 'Configure CCW MCP tools and paths', + 'mcp.ccw.status.installed': 'Installed', + 'mcp.ccw.status.notInstalled': 'Not installed', + 'mcp.ccw.status.special': 'Special', + 'mcp.ccw.actions.enableAll': 'Enable All', + 'mcp.ccw.actions.disableAll': 'Disable All', + 'mcp.ccw.actions.saveConfig': 'Save Configuration', + 'mcp.ccw.actions.saving': 'Saving...', + 'mcp.ccw.actions.installing': 'Installing...', + 'mcp.ccw.actions.uninstall': 'Uninstall', + 'mcp.ccw.actions.uninstalling': 'Uninstalling...', + 'mcp.ccw.actions.uninstallConfirm': 'Are you sure you want to uninstall?', + 'mcp.ccw.actions.uninstallScopeConfirm': 'Are you sure you want to uninstall from this scope?', + 'mcp.ccw.codexNote': 'Codex only supports global installation', + 'mcp.ccw.tools.label': 'Tools', + 'mcp.ccw.tools.hint': 'Install to edit tools', + 'mcp.ccw.tools.core': 'Core', + 'mcp.ccw.tools.write_file.name': 'Write File', + 'mcp.ccw.tools.write_file.desc': 'Write/create files', + 'mcp.ccw.tools.edit_file.name': 'Edit File', + 'mcp.ccw.tools.edit_file.desc': 'Edit/replace content', + 'mcp.ccw.tools.read_file.name': 'Read File', + 'mcp.ccw.tools.read_file.desc': 'Read single file', + 'mcp.ccw.tools.read_many_files.name': 'Read Many Files', + 'mcp.ccw.tools.read_many_files.desc': 'Read multiple files/dirs', + 'mcp.ccw.tools.core_memory.name': 'Core Memory', + 'mcp.ccw.tools.core_memory.desc': 'Core memory management', + 'mcp.ccw.tools.ask_question.name': 'Ask Question', + 'mcp.ccw.tools.ask_question.desc': 'Interactive questions (A2UI)', + 'mcp.ccw.tools.smart_search.name': 'Smart Search', + 'mcp.ccw.tools.smart_search.desc': 'Intelligent code search', + 'mcp.ccw.tools.team_msg.name': 'Team Message', + 'mcp.ccw.tools.team_msg.desc': 'Agent team message bus', + 'mcp.ccw.paths.label': 'Paths', + 'mcp.ccw.paths.projectRoot': 'Project Root', + 'mcp.ccw.paths.projectRootPlaceholder': 'e.g. D:\\path\\to\\project', + 'mcp.ccw.paths.allowedDirs': 'Allowed Directories', + 'mcp.ccw.paths.allowedDirsPlaceholder': 'Comma-separated directories', + 'mcp.ccw.paths.allowedDirsHint': 'Separate multiple directories with commas', + 'mcp.ccw.paths.enableSandbox': 'Enable Sandbox', + 'mcp.ccw.scope.installToGlobal': 'Install to Global', + 'mcp.ccw.scope.installToProject': 'Install to Project', + 'mcp.ccw.scope.uninstallGlobal': 'Uninstall Global', + 'mcp.ccw.scope.uninstallProject': 'Uninstall Project', + 'mcp.ccw.feedback.saveSuccess': 'Configuration saved', + 'mcp.ccw.feedback.saveError': 'Failed to save configuration', 'navigation.codexlens': 'CodexLens', }, zh: { @@ -555,6 +603,54 @@ const mockMessages: Record> = { 'codexlens.reranker.selectBackend': '选择后端...', 'codexlens.reranker.selectModel': '选择模型...', 'codexlens.reranker.selectProvider': '选择提供商...', + // MCP - CCW Tools + 'mcp.ccw.title': 'CCW MCP 服务器', + 'mcp.ccw.description': '配置 CCW MCP 工具与路径', + 'mcp.ccw.status.installed': '已安装', + 'mcp.ccw.status.notInstalled': '未安装', + 'mcp.ccw.status.special': '特殊', + 'mcp.ccw.actions.enableAll': '全选', + 'mcp.ccw.actions.disableAll': '全不选', + 'mcp.ccw.actions.saveConfig': '保存配置', + 'mcp.ccw.actions.saving': '保存中...', + 'mcp.ccw.actions.installing': '安装中...', + 'mcp.ccw.actions.uninstall': '卸载', + 'mcp.ccw.actions.uninstalling': '卸载中...', + 'mcp.ccw.actions.uninstallConfirm': '确定要卸载吗?', + 'mcp.ccw.actions.uninstallScopeConfirm': '确定要从该作用域卸载吗?', + 'mcp.ccw.codexNote': 'Codex 仅支持全局安装', + 'mcp.ccw.tools.label': '工具', + 'mcp.ccw.tools.hint': '安装后可编辑工具', + 'mcp.ccw.tools.core': '核心', + 'mcp.ccw.tools.write_file.name': '写入文件', + 'mcp.ccw.tools.write_file.desc': '写入/创建文件', + 'mcp.ccw.tools.edit_file.name': '编辑文件', + 'mcp.ccw.tools.edit_file.desc': '编辑/替换内容', + 'mcp.ccw.tools.read_file.name': '读取文件', + 'mcp.ccw.tools.read_file.desc': '读取单个文件', + 'mcp.ccw.tools.read_many_files.name': '读取多个文件', + 'mcp.ccw.tools.read_many_files.desc': '读取多个文件/目录', + 'mcp.ccw.tools.core_memory.name': '核心记忆', + 'mcp.ccw.tools.core_memory.desc': '核心记忆管理', + 'mcp.ccw.tools.ask_question.name': '提问', + 'mcp.ccw.tools.ask_question.desc': '交互式问题(A2UI)', + 'mcp.ccw.tools.smart_search.name': '智能搜索', + 'mcp.ccw.tools.smart_search.desc': '智能代码搜索', + 'mcp.ccw.tools.team_msg.name': '团队消息', + 'mcp.ccw.tools.team_msg.desc': '代理团队消息总线', + 'mcp.ccw.paths.label': '路径', + 'mcp.ccw.paths.projectRoot': '项目根目录', + 'mcp.ccw.paths.projectRootPlaceholder': '例如:D:\\path\\to\\project', + 'mcp.ccw.paths.allowedDirs': '允许目录', + 'mcp.ccw.paths.allowedDirsPlaceholder': '用逗号分隔的目录', + 'mcp.ccw.paths.allowedDirsHint': '使用逗号分隔多个目录', + 'mcp.ccw.paths.enableSandbox': '启用沙箱', + 'mcp.ccw.scope.installToGlobal': '安装到全局', + 'mcp.ccw.scope.installToProject': '安装到项目', + 'mcp.ccw.scope.uninstallGlobal': '卸载全局', + 'mcp.ccw.scope.uninstallProject': '卸载项目', + 'mcp.ccw.feedback.saveSuccess': '配置已保存', + 'mcp.ccw.feedback.saveError': '保存配置失败', 'navigation.codexlens': 'CodexLens', }, }; diff --git a/ccw/frontend/src/types/queue-frontend-types.ts b/ccw/frontend/src/types/queue-frontend-types.ts new file mode 100644 index 00000000..0646fa22 --- /dev/null +++ b/ccw/frontend/src/types/queue-frontend-types.ts @@ -0,0 +1,201 @@ +// ======================================== +// Queue Scheduler Frontend Types +// ======================================== +// Frontend type definitions for the queue scheduling system. +// Mirrors backend queue-types.ts for wire format compatibility. + +// ========== Item Status ========== + +/** + * Status of a single queue item through its lifecycle. + * Must match backend QueueItemStatus exactly. + */ +export type QueueItemStatus = + | 'pending' // Submitted, awaiting dependency check + | 'queued' // Dependencies met, waiting for session + | 'ready' // Ready for execution + | 'executing' // Running in an allocated CLI session + | 'completed' // Finished successfully + | 'failed' // Finished with error + | 'blocked' // Waiting on depends_on items to complete + | 'cancelled'; // Cancelled by user + +// ========== Scheduler Status ========== + +/** + * Status of the scheduler service itself. + * Must match backend QueueSchedulerStatus exactly. + */ +export type QueueSchedulerStatus = + | 'idle' // No active queue, waiting for items + | 'running' // Actively processing queue items + | 'paused' // User-paused, no new items dispatched + | 'stopping' // Graceful stop in progress, waiting for executing items + | 'completed' // All items processed successfully + | 'failed'; // Queue terminated due to critical error + +// ========== Scheduler Config ========== + +/** + * Configuration for the queue scheduler. + * Must match backend QueueSchedulerConfig exactly. + */ +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; +} + +// ========== Queue Item ========== + +/** + * A single task item in the execution queue. + * Must match backend QueueItem exactly. + */ +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?: string; + /** 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; +} + +// ========== Session Binding ========== + +/** + * Tracks a session bound to a resumeKey for affinity-based allocation. + * Must match backend SessionBinding exactly. + */ +export interface SessionBinding { + /** The CLI session key from CliSessionManager */ + sessionKey: string; + /** Timestamp of last activity on this binding */ + lastUsed: string; +} + +// ========== Scheduler State ========== + +/** + * Complete snapshot of the scheduler state. + * Must match backend QueueSchedulerState exactly. + */ +export interface QueueSchedulerState { + /** Current scheduler status */ + status: QueueSchedulerStatus; + /** All items in the queue */ + items: QueueItem[]; + /** Session pool: resumeKey -> SessionBinding */ + sessionPool: Record; + /** 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'; + +// ========== WebSocket Messages (Discriminated Union) ========== + +/** + * 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. + * Use `msg.type` as the discriminator in switch statements. + */ +export type QueueWSMessage = + | QueueSchedulerStateUpdateMessage + | QueueItemAddedMessage + | QueueItemUpdatedMessage + | QueueItemRemovedMessage + | QueueSchedulerConfigUpdatedMessage; diff --git a/ccw/src/config/litellm-static-models.ts b/ccw/src/config/litellm-static-models.ts new file mode 100644 index 00000000..6ca75dc6 --- /dev/null +++ b/ccw/src/config/litellm-static-models.ts @@ -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 = { + // 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; +} diff --git a/ccw/src/config/provider-models.ts b/ccw/src/config/provider-models.ts index ca20c6fa..87247f06 100644 --- a/ccw/src/config/provider-models.ts +++ b/ccw/src/config/provider-models.ts @@ -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 = { - 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'; diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index cce4f413..faf9b1e1 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -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 { 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 = {}; + 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) => { diff --git a/ccw/src/core/routes/provider-routes.ts b/ccw/src/core/routes/provider-routes.ts index 5725d12d..81522ff0 100644 --- a/ccw/src/core/routes/provider-routes.ts +++ b/ccw/src/core/routes/provider-routes.ts @@ -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 { - 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 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 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' }); diff --git a/ccw/src/core/routes/queue-routes.ts b/ccw/src/core/routes/queue-routes.ts new file mode 100644 index 00000000..6cd09437 --- /dev/null +++ b/ccw/src/core/routes/queue-routes.ts @@ -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 { + 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; + + 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; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index b1561d1d..4468dd84 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -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(['/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 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> = { + 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>(); + + /** Interval handle for session idle cleanup. */ + private cleanupTimer: ReturnType | null = null; + + /** Guard to prevent re-entrant processQueue calls. */ + private processingLock = false; + + constructor( + broadcastToClients: (data: unknown) => void, + cliSessionManager: CliSessionManager, + config?: Partial, + ) { + 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 { + 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): 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 { + // 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 { + 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(); + } +} diff --git a/ccw/src/core/websocket.ts b/ccw/src/core/websocket.ts index 0216d38b..f695f7e4 100644 --- a/ccw/src/core/websocket.ts +++ b/ccw/src/core/websocket.ts @@ -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(); @@ -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 + | Omit + | Omit + | Omit + | Omit; + +/** + * 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() + }); +} diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 070304b8..75e089a7 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -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); diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index d206c8ab..30c47d3c 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -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) : '') })) }; } diff --git a/ccw/src/tools/native-session-discovery.ts b/ccw/src/tools/native-session-discovery.ts index 61edd1ff..77d7a323 100644 --- a/ccw/src/tools/native-session-discovery.ts +++ b/ccw/src/tools/native-session-discovery.ts @@ -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; +} diff --git a/ccw/src/tools/session-content-parser.ts b/ccw/src/tools/session-content-parser.ts index f942d1a5..4a2e550c 100644 --- a/ccw/src/tools/session-content-parser.ts +++ b/ccw/src/tools/session-content-parser.ts @@ -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) { diff --git a/ccw/src/types/cli-settings.ts b/ccw/src/types/cli-settings.ts index 792e9296..fbcce2eb 100644 --- a/ccw/src/types/cli-settings.ts +++ b/ccw/src/types/cli-settings.ts @@ -187,7 +187,7 @@ export function createDefaultSettings(provider: CliProvider = 'claude'): CliSett env: { DISABLE_AUTOUPDATER: '1' }, - model: 'sonnet', + model: '', tags: [], availableModels: [] } satisfies ClaudeCliSettings; diff --git a/ccw/src/types/queue-types.ts b/ccw/src/types/queue-types.ts new file mode 100644 index 00000000..b8b55d54 --- /dev/null +++ b/ccw/src/types/queue-types.ts @@ -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; +} + +/** + * 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; + /** 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;