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

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

View File

@@ -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<DateGroup, NativeSessionListItem[]> {
const groups = new Map<DateGroup, NativeSessionListItem[]>([
['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<DateGroup, string> = {
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<Set<string>>(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' })}
</Button>
<Button
variant="ghost"
className={cn(
"border-b-2 rounded-none h-11 px-4",
currentTab === 'native-sessions'
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setCurrentTab('native-sessions')}
>
<FileJson className="h-4 w-4 mr-2" />
{formatMessage({ id: 'history.tabs.nativeSessions' })}
</Button>
</div>
{/* Tab Content */}
@@ -354,6 +453,183 @@ export function HistoryPage() {
</div>
)}
{currentTab === 'native-sessions' && (
<div className="space-y-4">
{/* Header with refresh */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{formatMessage(
{ id: 'history.nativeSessions.count' },
{ count: nativeSessions.length }
)}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetchNativeSessions()}
disabled={isFetchingNativeSessions}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetchingNativeSessions && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
{/* Error alert */}
{nativeSessionsError && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<Terminal className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{nativeSessionsError.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetchNativeSessions()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
)}
{/* Loading state */}
{isLoadingNativeSessions ? (
<div className="grid gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 rounded-lg bg-muted animate-pulse" />
))}
</div>
) : nativeSessions.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 px-4">
<FileJson className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'history.nativeSessions.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground text-center">
{formatMessage({ id: 'history.nativeSessions.empty.message' })}
</p>
</div>
) : (
/* Sessions grouped by tool */
<div className="space-y-2">
{toolOrder
.filter((tool) => nativeSessionsByTool[tool]?.length > 0)
.map((tool) => {
const sessions = nativeSessionsByTool[tool];
const isExpanded = expandedTools.has(tool);
return (
<div key={tool} className="border rounded-lg">
{/* Tool header - clickable to expand/collapse */}
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
onClick={() => toggleToolExpand(tool)}
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Badge variant={getToolVariant(tool)}>
{tool.toUpperCase()}
</Badge>
<span className="text-sm text-muted-foreground">
{sessions.length} {formatMessage({ id: 'history.nativeSessions.sessions' })}
</span>
</div>
</button>
{/* Sessions list */}
{isExpanded && (
<div className="border-t divide-y">
{sessions.map((session) => (
<button
key={session.id}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
onClick={() => handleNativeSessionClick(session)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-sm truncate max-w-48" title={session.id}>
{session.id.length > 24 ? session.id.slice(0, 24) + '...' : session.id}
</span>
{session.title && (
<span className="text-sm text-muted-foreground truncate max-w-64">
{session.title}
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(session.updatedAt).toLocaleString()}
</span>
</div>
</button>
))}
</div>
)}
</div>
);
})}
{/* 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 (
<div key={tool} className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
onClick={() => toggleToolExpand(tool)}
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Badge variant="secondary">
{tool.toUpperCase()}
</Badge>
<span className="text-sm text-muted-foreground">
{sessions.length} {formatMessage({ id: 'history.nativeSessions.sessions' })}
</span>
</div>
</button>
{isExpanded && (
<div className="border-t divide-y">
{sessions.map((session) => (
<button
key={session.id}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
onClick={() => handleNativeSessionClick(session)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-sm truncate max-w-48" title={session.id}>
{session.id.length > 24 ? session.id.slice(0, 24) + '...' : session.id}
</span>
{session.title && (
<span className="text-sm text-muted-foreground truncate max-w-64">
{session.title}
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(session.updatedAt).toLocaleString()}
</span>
</div>
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* CLI Stream Panel */}
<CliStreamPanel
executionId={selectedExecution || ''}

View File

@@ -20,6 +20,8 @@ import { FloatingPanel } from '@/components/terminal-dashboard/FloatingPanel';
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
import { QueueListColumn } from '@/components/terminal-dashboard/QueueListColumn';
import { SchedulerPanel } from '@/components/terminal-dashboard/SchedulerPanel';
import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel';
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
@@ -105,9 +107,19 @@ export function TerminalDashboardPage() {
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
side="left"
width={380}
width={700}
>
<IssuePanel />
<div className="flex h-full">
<div className="flex-1 min-w-0 border-r border-border">
<IssuePanel />
</div>
<div className="flex-1 min-w-0 flex flex-col">
<div className="px-3 py-2 border-b border-border shrink-0">
<h3 className="text-sm font-semibold">{formatMessage({ id: 'terminalDashboard.toolbar.queue' })}</h3>
</div>
<QueueListColumn />
</div>
</div>
</FloatingPanel>
{featureFlags.dashboardQueuePanelEnabled && (
@@ -145,6 +157,16 @@ export function TerminalDashboardPage() {
<ExecutionMonitorPanel />
</FloatingPanel>
)}
<FloatingPanel
isOpen={activePanel === 'scheduler'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.scheduler', defaultMessage: 'Scheduler' })}
side="right"
width={340}
>
<SchedulerPanel />
</FloatingPanel>
</AssociationHighlightProvider>
</div>
);