mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat(queue): implement queue scheduler service and API routes
- Added QueueSchedulerService to manage task queue lifecycle, including state machine, dependency resolution, and session management. - Implemented HTTP API endpoints for queue scheduling: - POST /api/queue/execute: Submit items to the scheduler. - GET /api/queue/scheduler/state: Retrieve full scheduler state. - POST /api/queue/scheduler/start: Start scheduling loop with items. - POST /api/queue/scheduler/pause: Pause scheduling. - POST /api/queue/scheduler/stop: Graceful stop of the scheduler. - POST /api/queue/scheduler/config: Update scheduler configuration. - Introduced types for queue items, scheduler state, and WebSocket messages to ensure type safety and compatibility with the backend. - Added static model lists for LiteLLM as a fallback for available models.
This commit is contained in:
@@ -164,7 +164,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
setCliProvider(p);
|
setCliProvider(p);
|
||||||
setMode('direct');
|
setMode('direct');
|
||||||
setProviderId('');
|
setProviderId('');
|
||||||
setModel(p === 'claude' ? 'sonnet' : '');
|
setModel('');
|
||||||
setSettingsFile('');
|
setSettingsFile('');
|
||||||
setAuthToken('');
|
setAuthToken('');
|
||||||
setBaseUrl('');
|
setBaseUrl('');
|
||||||
@@ -291,7 +291,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
}
|
}
|
||||||
settings = {
|
settings = {
|
||||||
env,
|
env,
|
||||||
model: model || 'sonnet',
|
model: model || undefined,
|
||||||
settingsFile: settingsFile.trim() || undefined,
|
settingsFile: settingsFile.trim() || undefined,
|
||||||
availableModels,
|
availableModels,
|
||||||
tags,
|
tags,
|
||||||
@@ -505,7 +505,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="model-pb">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
|
<Label htmlFor="model-pb">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
|
||||||
<Input id="model-pb" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
|
<Input id="model-pb" value={model} onChange={(e) => setModel(e.target.value)} placeholder="" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -545,7 +545,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="model-direct">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
|
<Label htmlFor="model-direct">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
|
||||||
<Input id="model-direct" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
|
<Input id="model-direct" value={model} onChange={(e) => setModel(e.target.value)} placeholder="" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -644,7 +644,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
id="codex-model"
|
id="codex-model"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
placeholder="gpt-5.2"
|
placeholder=""
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
指定使用的模型,将自动更新到 config.toml 中
|
指定使用的模型,将自动更新到 config.toml 中
|
||||||
@@ -711,7 +711,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
id="codex-configtoml"
|
id="codex-configtoml"
|
||||||
value={configToml}
|
value={configToml}
|
||||||
onChange={(e) => setConfigToml(e.target.value)}
|
onChange={(e) => setConfigToml(e.target.value)}
|
||||||
placeholder={'model = "gpt-5.2"\nmodel_reasoning_effort = "xhigh"'}
|
placeholder=""
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
rows={6}
|
rows={6}
|
||||||
/>
|
/>
|
||||||
@@ -778,7 +778,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
id="gemini-model"
|
id="gemini-model"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
placeholder="gemini-2.5-flash"
|
placeholder=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -803,7 +803,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
id="gemini-settingsjson"
|
id="gemini-settingsjson"
|
||||||
value={geminiSettingsJson}
|
value={geminiSettingsJson}
|
||||||
onChange={(e) => setGeminiSettingsJson(e.target.value)}
|
onChange={(e) => setGeminiSettingsJson(e.target.value)}
|
||||||
placeholder='{"model": "gemini-2.5-flash", ...}'
|
placeholder="{}"
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
rows={8}
|
rows={8}
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) {
|
|||||||
id="model"
|
id="model"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
placeholder="gpt-4o"
|
placeholder=""
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Terminal,
|
Terminal,
|
||||||
Bell,
|
Bell,
|
||||||
Clock,
|
|
||||||
Monitor,
|
Monitor,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -86,19 +85,6 @@ export function Header({
|
|||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* History entry */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Link to="/history" className="inline-flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.main.history' })}</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* CLI Monitor button */}
|
{/* CLI Monitor button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -78,6 +79,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||||
|
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||||
{ path: '/analysis', labelKey: 'navigation.main.analysis', icon: FileSearch },
|
{ path: '/analysis', labelKey: 'navigation.main.analysis', icon: FileSearch },
|
||||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen, waitFor } from '@/test/i18n';
|
import { render, screen, waitFor } from '@/test/i18n';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { act } from '@testing-library/react';
|
||||||
|
|
||||||
import { CcwToolsMcpCard } from './CcwToolsMcpCard';
|
const apiMock = vi.hoisted(() => ({
|
||||||
import { updateCcwConfig, updateCcwConfigForCodex } from '@/lib/api';
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
installCcwMcp: vi.fn(),
|
installCcwMcp: vi.fn(),
|
||||||
uninstallCcwMcp: vi.fn(),
|
uninstallCcwMcp: vi.fn(),
|
||||||
updateCcwConfig: vi.fn(),
|
updateCcwConfig: vi.fn(),
|
||||||
@@ -18,11 +16,24 @@ vi.mock('@/lib/api', () => ({
|
|||||||
updateCcwConfigForCodex: vi.fn(),
|
updateCcwConfigForCodex: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/hooks/useNotifications', () => ({
|
vi.mock('@/lib/api', () => apiMock);
|
||||||
useNotifications: () => ({
|
|
||||||
|
const notificationsMock = vi.hoisted(() => ({
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
}),
|
}));
|
||||||
|
|
||||||
|
// Avoid importing the full hooks barrel in this component test (it has heavy deps and
|
||||||
|
// side effects that aren't relevant here).
|
||||||
|
vi.mock('@/hooks', () => ({
|
||||||
|
mcpServersKeys: { all: ['mcpServers'] },
|
||||||
|
useNotifications: () => notificationsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/workflowStore', () => ({
|
||||||
|
useWorkflowStore: (selector: (state: { projectPath: string }) => string) =>
|
||||||
|
selector({ projectPath: '' }),
|
||||||
|
selectProjectPath: (state: { projectPath: string }) => state.projectPath,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('CcwToolsMcpCard', () => {
|
describe('CcwToolsMcpCard', () => {
|
||||||
@@ -31,7 +42,8 @@ describe('CcwToolsMcpCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves enabledTools when saving config (Codex)', async () => {
|
it('preserves enabledTools when saving config (Codex)', async () => {
|
||||||
const updateCodexMock = vi.mocked(updateCcwConfigForCodex);
|
const { CcwToolsMcpCard } = await import('./CcwToolsMcpCard');
|
||||||
|
const updateCodexMock = vi.mocked(apiMock.updateCcwConfigForCodex);
|
||||||
updateCodexMock.mockResolvedValue({
|
updateCodexMock.mockResolvedValue({
|
||||||
isInstalled: true,
|
isInstalled: true,
|
||||||
enabledTools: [],
|
enabledTools: [],
|
||||||
@@ -51,22 +63,36 @@ describe('CcwToolsMcpCard', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
await act(async () => {
|
||||||
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
||||||
await user.click(
|
});
|
||||||
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
|
const saveButton = screen.getByRole('button', {
|
||||||
);
|
name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i,
|
||||||
|
});
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
await act(async () => {
|
||||||
|
await user.click(saveButton);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateCodexMock).toHaveBeenCalledWith(
|
expect(updateCodexMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notificationsMock.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [payload] = updateCodexMock.mock.calls[0] ?? [];
|
||||||
|
expect(payload).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
enabledTools: ['write_file', 'read_many_files'],
|
enabledTools: ['write_file', 'read_many_files'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves enabledTools when saving config (Claude)', async () => {
|
it('preserves enabledTools when saving config (Claude)', async () => {
|
||||||
const updateClaudeMock = vi.mocked(updateCcwConfig);
|
const { CcwToolsMcpCard } = await import('./CcwToolsMcpCard');
|
||||||
|
const updateClaudeMock = vi.mocked(apiMock.updateCcwConfig);
|
||||||
updateClaudeMock.mockResolvedValue({
|
updateClaudeMock.mockResolvedValue({
|
||||||
isInstalled: true,
|
isInstalled: true,
|
||||||
enabledTools: [],
|
enabledTools: [],
|
||||||
@@ -85,18 +111,30 @@ describe('CcwToolsMcpCard', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
await act(async () => {
|
||||||
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
||||||
await user.click(
|
});
|
||||||
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
|
const saveButton = screen.getByRole('button', {
|
||||||
);
|
name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i,
|
||||||
|
});
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
await act(async () => {
|
||||||
|
await user.click(saveButton);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateClaudeMock).toHaveBeenCalledWith(
|
expect(updateClaudeMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notificationsMock.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [payload] = updateClaudeMock.mock.calls[0] ?? [];
|
||||||
|
expect(payload).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
enabledTools: ['write_file', 'smart_search'],
|
enabledTools: ['write_file', 'smart_search'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
|
|||||||
{/* Main Panel */}
|
{/* Main Panel */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-0 right-0 h-full w-[640px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
'fixed top-0 right-0 h-full w-[1568px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
)}
|
)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
// AssistantMessage Component
|
// AssistantMessage Component
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Bot, ChevronDown, Copy, Check } from 'lucide-react';
|
import { Bot, ChevronDown, Copy, Check, FileJson } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { JsonCard } from '../components/JsonCard';
|
||||||
|
import { detectJsonInLine } from '../utils/jsonDetector';
|
||||||
|
|
||||||
// Status indicator component
|
// Status indicator component
|
||||||
interface StatusIndicatorProps {
|
interface StatusIndicatorProps {
|
||||||
@@ -94,6 +96,11 @@ export function AssistantMessage({
|
|||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Detect JSON in content
|
||||||
|
const jsonDetection = useMemo(() => {
|
||||||
|
return detectJsonInLine(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (copied) {
|
if (copied) {
|
||||||
const timer = setTimeout(() => setCopied(false), 2000);
|
const timer = setTimeout(() => setCopied(false), 2000);
|
||||||
@@ -126,6 +133,14 @@ export function AssistantMessage({
|
|||||||
{modelName}
|
{modelName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* JSON indicator badge */}
|
||||||
|
{jsonDetection?.isJson && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[9px] text-violet-500 dark:text-violet-400 bg-violet-200/50 dark:bg-violet-800/50 px-1 rounded">
|
||||||
|
<FileJson className="h-2.5 w-2.5" />
|
||||||
|
JSON
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
<StatusIndicator status={status} duration={duration} />
|
<StatusIndicator status={status} duration={duration} />
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -141,11 +156,21 @@ export function AssistantMessage({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
|
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
|
||||||
|
{jsonDetection?.isJson && jsonDetection.parsed ? (
|
||||||
|
/* JSON detected - use collapsible JsonCard */
|
||||||
|
<JsonCard
|
||||||
|
data={jsonDetection.parsed}
|
||||||
|
type="stdout"
|
||||||
|
onCopy={handleCopy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Plain text content */
|
||||||
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
|
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
|
||||||
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
|
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata Footer - simplified */}
|
{/* Metadata Footer - simplified */}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function MessageExample() {
|
|||||||
<SystemMessage
|
<SystemMessage
|
||||||
title="Session Started"
|
title="Session Started"
|
||||||
timestamp={Date.now()}
|
timestamp={Date.now()}
|
||||||
metadata="gemini-2.5-pro | Context: 28 files"
|
metadata="Context: 28 files"
|
||||||
content="CLI execution started: gemini (analysis mode)"
|
content="CLI execution started: gemini (analysis mode)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
|
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
|
||||||
import { useNativeSession } from '@/hooks/useNativeSession';
|
import { useNativeSession } from '@/hooks/useNativeSession';
|
||||||
import type { ConversationRecord, ConversationTurn, NativeSessionTurn, NativeTokenInfo, NativeToolCall } from '@/lib/api';
|
import type { ConversationRecord, ConversationTurn, NativeSessionTurn, NativeTokenInfo, NativeToolCall } from '@/lib/api';
|
||||||
|
import { getToolVariant } from '@/lib/cli-tool-theme';
|
||||||
|
|
||||||
type ViewMode = 'per-turn' | 'concatenated' | 'native';
|
type ViewMode = 'per-turn' | 'concatenated' | 'native';
|
||||||
type ConcatFormat = 'plain' | 'yaml' | 'json';
|
type ConcatFormat = 'plain' | 'yaml' | 'json';
|
||||||
@@ -69,16 +70,12 @@ function getStatusInfo(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get badge variant for tool name
|
* Ensure prompt is a string (handle legacy object data)
|
||||||
*/
|
*/
|
||||||
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
|
function ensureString(value: unknown): string {
|
||||||
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
|
if (typeof value === 'string') return value;
|
||||||
gemini: 'info',
|
if (value && typeof value === 'object') return JSON.stringify(value);
|
||||||
codex: 'success',
|
return String(value ?? '');
|
||||||
qwen: 'warning',
|
|
||||||
opencode: 'secondary',
|
|
||||||
};
|
|
||||||
return variants[tool] || 'secondary';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +92,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
|
|||||||
for (const turn of turns) {
|
for (const turn of turns) {
|
||||||
parts.push(`--- Turn ${turn.turn} ---`);
|
parts.push(`--- Turn ${turn.turn} ---`);
|
||||||
parts.push('USER:');
|
parts.push('USER:');
|
||||||
parts.push(turn.prompt);
|
parts.push(ensureString(turn.prompt));
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push('ASSISTANT:');
|
parts.push('ASSISTANT:');
|
||||||
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
|
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
|
||||||
@@ -118,7 +115,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
|
|||||||
yaml.push(` - turn: ${turn.turn}`);
|
yaml.push(` - turn: ${turn.turn}`);
|
||||||
yaml.push(` timestamp: ${turn.timestamp}`);
|
yaml.push(` timestamp: ${turn.timestamp}`);
|
||||||
yaml.push(` prompt: |`);
|
yaml.push(` prompt: |`);
|
||||||
turn.prompt.split('\n').forEach(line => {
|
ensureString(turn.prompt).split('\n').forEach(line => {
|
||||||
yaml.push(` ${line}`);
|
yaml.push(` ${line}`);
|
||||||
});
|
});
|
||||||
yaml.push(` response: |`);
|
yaml.push(` response: |`);
|
||||||
@@ -140,7 +137,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
|
|||||||
turns.map((t) => ({
|
turns.map((t) => ({
|
||||||
turn: t.turn,
|
turn: t.turn,
|
||||||
timestamp: t.timestamp,
|
timestamp: t.timestamp,
|
||||||
prompt: t.prompt,
|
prompt: ensureString(t.prompt),
|
||||||
response: t.output.stdout || '',
|
response: t.output.stdout || '',
|
||||||
})),
|
})),
|
||||||
null,
|
null,
|
||||||
@@ -212,7 +209,7 @@ function TurnSection({ turn, isLatest, isExpanded, onToggle }: TurnSectionProps)
|
|||||||
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
|
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
|
||||||
</h4>
|
</h4>
|
||||||
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
|
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
|
||||||
{turn.prompt}
|
{ensureString(turn.prompt)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -462,7 +459,7 @@ function NativeTurnCard({ turn, isLatest, isExpanded, onToggle }: NativeTurnCard
|
|||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
{turn.content && (
|
{turn.content && (
|
||||||
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-80 overflow-y-auto">
|
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-80 overflow-y-auto">
|
||||||
{turn.content}
|
{ensureString(turn.content)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,9 @@ export function ConversationCard({
|
|||||||
|
|
||||||
{/* Prompt preview */}
|
{/* Prompt preview */}
|
||||||
<p className="text-sm text-foreground line-clamp-2 mb-2">
|
<p className="text-sm text-foreground line-clamp-2 mb-2">
|
||||||
{execution.prompt_preview}
|
{typeof execution.prompt_preview === 'string'
|
||||||
|
? execution.prompt_preview
|
||||||
|
: JSON.stringify(execution.prompt_preview)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Meta info */}
|
{/* Meta info */}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '@/components/ui/Dialog';
|
} from '@/components/ui/Dialog';
|
||||||
import { useNativeSession } from '@/hooks/useNativeSession';
|
import { useNativeSession } from '@/hooks/useNativeSession';
|
||||||
import { SessionTimeline } from './SessionTimeline';
|
import { SessionTimeline } from './SessionTimeline';
|
||||||
|
import { getToolVariant } from '@/lib/cli-tool-theme';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -35,19 +36,6 @@ export interface NativeSessionPanelProps {
|
|||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Helpers ==========
|
||||||
|
|
||||||
/**
|
|
||||||
* Get badge variant for tool name
|
|
||||||
*/
|
|
||||||
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
|
|
||||||
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
|
|
||||||
gemini: 'info',
|
|
||||||
codex: 'success',
|
|
||||||
qwen: 'warning',
|
|
||||||
opencode: 'secondary',
|
|
||||||
};
|
|
||||||
return variants[tool?.toLowerCase()] || 'secondary';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate a string to a max length with ellipsis
|
* Truncate a string to a max length with ellipsis
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ interface ToolCallPanelProps {
|
|||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure content is a string (handle legacy object data like {text: "..."})
|
||||||
|
*/
|
||||||
|
function ensureString(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value && typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format token number with compact notation
|
* Format token number with compact notation
|
||||||
*/
|
*/
|
||||||
@@ -319,7 +328,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
|
|||||||
{turn.content && (
|
{turn.content && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
|
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
|
||||||
{turn.content}
|
{ensureString(turn.content)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { FolderOpen, RefreshCw } from 'lucide-react';
|
import { ChevronDown, FolderOpen, RefreshCw } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -52,8 +52,6 @@ export interface CliConfigModalProps {
|
|||||||
onCreateSession: (config: CliSessionConfig) => Promise<void>;
|
onCreateSession: (config: CliSessionConfig) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_MODEL_VALUE = '__auto__';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a tag name: {tool}-{HHmmss}
|
* Generate a tag name: {tool}-{HHmmss}
|
||||||
* Example: gemini-143052
|
* Example: gemini-143052
|
||||||
@@ -95,6 +93,12 @@ export function CliConfigModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Model combobox state
|
||||||
|
const [modelInputValue, setModelInputValue] = React.useState('');
|
||||||
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = React.useState(false);
|
||||||
|
const modelContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const modelInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// CLI Settings integration (for all tools)
|
// CLI Settings integration (for all tools)
|
||||||
const { cliSettings } = useCliSettings({ enabled: true });
|
const { cliSettings } = useCliSettings({ enabled: true });
|
||||||
|
|
||||||
@@ -162,13 +166,30 @@ export function CliConfigModal({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when tool changes, reading tag intentionally stale
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when tool changes, reading tag intentionally stale
|
||||||
}, [tool]);
|
}, [tool]);
|
||||||
|
|
||||||
// Sync initial model when tool/modelOptions change
|
// Sync model input display when model state changes (e.g., tool change)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (modelOptions.length > 0 && (!model || !modelOptions.includes(model))) {
|
setModelInputValue(model ?? '');
|
||||||
setModel(modelOptions[0]);
|
}, [model]);
|
||||||
|
|
||||||
|
// Filter model suggestions based on typed input
|
||||||
|
const filteredModelOptions = React.useMemo(() => {
|
||||||
|
const query = modelInputValue.toLowerCase();
|
||||||
|
if (!query) return modelOptions;
|
||||||
|
return modelOptions.filter((m) => m.toLowerCase().includes(query));
|
||||||
|
}, [modelOptions, modelInputValue]);
|
||||||
|
|
||||||
|
// Close model dropdown on outside click
|
||||||
|
React.useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (modelContainerRef.current && !modelContainerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when modelOptions changes
|
}
|
||||||
}, [modelOptions]);
|
if (isModelDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isModelDropdownOpen]);
|
||||||
|
|
||||||
const handleToolChange = (nextTool: string) => {
|
const handleToolChange = (nextTool: string) => {
|
||||||
setTool(nextTool as CliTool);
|
setTool(nextTool as CliTool);
|
||||||
@@ -276,30 +297,80 @@ export function CliConfigModal({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model */}
|
{/* Model - Combobox (input + dropdown suggestions) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cli-config-model">
|
<Label htmlFor="cli-config-model">
|
||||||
{formatMessage({ id: 'terminalDashboard.cliConfig.model' })}
|
{formatMessage({ id: 'terminalDashboard.cliConfig.model' })}
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<div ref={modelContainerRef} className="relative">
|
||||||
value={model ?? AUTO_MODEL_VALUE}
|
<div className="flex">
|
||||||
onValueChange={(v) => setModel(v === AUTO_MODEL_VALUE ? undefined : v)}
|
<input
|
||||||
|
ref={modelInputRef}
|
||||||
|
id="cli-config-model"
|
||||||
|
value={modelInputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setModelInputValue(v);
|
||||||
|
setModel(v || undefined);
|
||||||
|
if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsModelDropdownOpen(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setIsModelDropdownOpen(false);
|
||||||
|
if (e.key === 'Enter') setIsModelDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
placeholder={formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto', defaultMessage: 'Auto' })}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
className="flex h-10 w-full rounded-l-md border border-r-0 border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsModelDropdownOpen(!isModelDropdownOpen);
|
||||||
|
if (!isModelDropdownOpen) modelInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex items-center justify-center h-10 w-9 shrink-0 rounded-r-md border border-input bg-background hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isModelDropdownOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md max-h-48 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setModel(undefined);
|
||||||
|
setModelInputValue('');
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
||||||
|
!model && 'bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto', defaultMessage: 'Auto' })}
|
||||||
|
</button>
|
||||||
|
{filteredModelOptions.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setModel(m);
|
||||||
|
setModelInputValue(m);
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground font-mono',
|
||||||
|
model === m && 'bg-accent/50'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="cli-config-model">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={AUTO_MODEL_VALUE}>
|
|
||||||
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto' })}
|
|
||||||
</SelectItem>
|
|
||||||
{modelOptions.map((m) => (
|
|
||||||
<SelectItem key={m} value={m}>
|
|
||||||
{m}
|
{m}
|
||||||
</SelectItem>
|
</button>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Activity,
|
Activity,
|
||||||
Plus,
|
Plus,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
@@ -36,11 +37,12 @@ import { toast } from '@/stores/notificationStore';
|
|||||||
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
|
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
|
||||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||||
import { useConfigStore } from '@/stores/configStore';
|
import { useConfigStore } from '@/stores/configStore';
|
||||||
|
import { useQueueSchedulerStore, selectQueueSchedulerStatus } from '@/stores/queueSchedulerStore';
|
||||||
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution';
|
export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution' | 'scheduler';
|
||||||
|
|
||||||
interface DashboardToolbarProps {
|
interface DashboardToolbarProps {
|
||||||
activePanel: PanelId | null;
|
activePanel: PanelId | null;
|
||||||
@@ -95,6 +97,10 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
// Execution monitor count
|
// Execution monitor count
|
||||||
const executionCount = useExecutionMonitorStore(selectActiveExecutionCount);
|
const executionCount = useExecutionMonitorStore(selectActiveExecutionCount);
|
||||||
|
|
||||||
|
// Scheduler status for badge indicator
|
||||||
|
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
|
||||||
|
const isSchedulerActive = schedulerStatus !== 'idle';
|
||||||
|
|
||||||
// Feature flags for panel visibility
|
// Feature flags for panel visibility
|
||||||
const featureFlags = useConfigStore((s) => s.featureFlags);
|
const featureFlags = useConfigStore((s) => s.featureFlags);
|
||||||
const showQueue = featureFlags.dashboardQueuePanelEnabled;
|
const showQueue = featureFlags.dashboardQueuePanelEnabled;
|
||||||
@@ -244,6 +250,13 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
badge={executionCount > 0 ? executionCount : undefined}
|
badge={executionCount > 0 ? executionCount : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Gauge}
|
||||||
|
label={formatMessage({ id: 'terminalDashboard.toolbar.scheduler', defaultMessage: 'Scheduler' })}
|
||||||
|
isActive={activePanel === 'scheduler'}
|
||||||
|
onClick={() => onTogglePanel('scheduler')}
|
||||||
|
dot={isSchedulerActive}
|
||||||
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
label={formatMessage({ id: 'terminalDashboard.toolbar.files', defaultMessage: 'Files' })}
|
label={formatMessage({ id: 'terminalDashboard.toolbar.files', defaultMessage: 'Files' })}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Send,
|
Send,
|
||||||
X,
|
X,
|
||||||
|
ListPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ import { executeInCliSession } from '@/lib/api';
|
|||||||
import type { Issue } from '@/lib/api';
|
import type { Issue } from '@/lib/api';
|
||||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
import { useTerminalGridStore, selectTerminalGridFocusedPaneId, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
import { useQueueSchedulerStore } from '@/stores/queueSchedulerStore';
|
||||||
import { toast } from '@/stores/notificationStore';
|
import { toast } from '@/stores/notificationStore';
|
||||||
|
|
||||||
// ========== Execution Method Type ==========
|
// ========== Execution Method Type ==========
|
||||||
@@ -51,6 +53,13 @@ const PROMPT_TEMPLATES: Record<ExecutionMethod, (idStr: string) => string> = {
|
|||||||
'direct-send': (idStr) => `根据@.workflow/issues/issues.jsonl中的 ${idStr} 需求,进行开发`,
|
'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 ==========
|
// ========== Priority Badge ==========
|
||||||
|
|
||||||
const PRIORITY_STYLES: Record<Issue['priority'], { variant: 'destructive' | 'warning' | 'info' | 'secondary'; label: string }> = {
|
const PRIORITY_STYLES: Record<Issue['priority'], { variant: 'destructive' | 'warning' | 'info' | 'secondary'; label: string }> = {
|
||||||
@@ -203,6 +212,13 @@ export function IssuePanel() {
|
|||||||
const [customPrompt, setCustomPrompt] = useState('');
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
const sentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sentTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const submitItems = useQueueSchedulerStore((s) => s.submitItems);
|
||||||
|
|
||||||
// Terminal refs
|
// Terminal refs
|
||||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||||
const panes = useTerminalGridStore(selectTerminalGridPanes);
|
const panes = useTerminalGridStore(selectTerminalGridPanes);
|
||||||
@@ -234,10 +250,11 @@ export function IssuePanel() {
|
|||||||
}
|
}
|
||||||
}, [availableMethods, executionMethod]);
|
}, [availableMethods, executionMethod]);
|
||||||
|
|
||||||
// Cleanup sent feedback timer on unmount
|
// Cleanup feedback timers on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (sentTimerRef.current) clearTimeout(sentTimerRef.current);
|
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]);
|
}, [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
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -512,10 +566,42 @@ export function IssuePanel() {
|
|||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Queue mode selector */}
|
||||||
|
<select
|
||||||
|
className="h-6 text-[10px] rounded border border-border bg-background px-1 text-muted-foreground"
|
||||||
|
value={queueMode}
|
||||||
|
onChange={(e) => setQueueMode(e.target.value as 'write' | 'analysis' | 'auto')}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.issuePanel.queueModeHint', defaultMessage: 'Execution mode for queued items' })}
|
||||||
|
>
|
||||||
|
<option value="write">write</option>
|
||||||
|
<option value="analysis">analysis</option>
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
</select>
|
||||||
|
{/* Add to Queue button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||||
|
justQueued
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-muted text-foreground hover:bg-muted/80',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={isAddingToQueue}
|
||||||
|
onClick={handleAddToQueue}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.issuePanel.addToQueueHint', defaultMessage: 'Add selected issues to the execution queue' })}
|
||||||
|
>
|
||||||
|
{isAddingToQueue ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : justQueued ? <Check className="w-3.5 h-3.5" /> : <ListPlus className="w-3.5 h-3.5" />}
|
||||||
|
{justQueued
|
||||||
|
? formatMessage({ id: 'terminalDashboard.issuePanel.addedToQueue', defaultMessage: 'Queued!' })
|
||||||
|
: formatMessage({ id: 'terminalDashboard.issuePanel.addToQueue', defaultMessage: 'Queue' })}
|
||||||
|
</button>
|
||||||
|
{/* Send to Terminal button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||||
justSent
|
justSent
|
||||||
? 'bg-green-600 text-white'
|
? 'bg-green-600 text-white'
|
||||||
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
@@ -530,6 +616,7 @@ export function IssuePanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<QueueItemStatus, {
|
||||||
|
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
|
||||||
|
icon: typeof Clock;
|
||||||
|
label: string;
|
||||||
|
}> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2.5 py-1.5 rounded-md transition-colors',
|
||||||
|
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
|
||||||
|
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30',
|
||||||
|
isExecuting && 'border-l-2 border-l-blue-500'
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<StatusIcon
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 shrink-0',
|
||||||
|
isExecuting && 'animate-spin'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-foreground truncate font-mono">
|
||||||
|
{displayId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={config.variant} className="text-[10px] px-1 py-0 shrink-0">
|
||||||
|
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground pl-4">
|
||||||
|
{item.execution_group && <span>{item.execution_group}</span>}
|
||||||
|
{sessionKey && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Terminal className="w-2.5 h-2.5" />
|
||||||
|
{sessionKey}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isBlocked && item.depends_on.length > 0 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-orange-500/80 pl-4 truncate">
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
|
||||||
|
{ deps: item.depends_on.join(', ') }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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 (
|
||||||
|
<div className="border-t border-border px-2.5 py-1.5 shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{/* Status badge */}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn('text-[10px] px-1.5 py-0', SCHEDULER_STATUS_STYLE[status])}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: `terminalDashboard.queuePanel.scheduler.status.${status}`, defaultMessage: status })}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Progress + Concurrency */}
|
||||||
|
{isActive && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{progress}% | {concurrency}/{config.maxConcurrentSessions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{(canStart || canResume) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleStart}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Start' })}
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canPause && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={pauseQueue}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.pause', defaultMessage: 'Pause' })}
|
||||||
|
>
|
||||||
|
<Pause className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setIsStopConfirmOpen(true)}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="mt-1 h-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop confirmation dialog */}
|
||||||
|
<AlertDialog open={isStopConfirmOpen} onOpenChange={setIsStopConfirmOpen}>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started.' })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="h-8 text-xs">
|
||||||
|
{formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="h-8 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => { stopQueue(); setIsStopConfirmOpen(false); }}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Item list */}
|
||||||
|
{sortedItems.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground p-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<ListChecks className="h-5 w-5 mx-auto mb-1 opacity-30" />
|
||||||
|
<p className="text-xs">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
|
||||||
|
<p className="text-[10px] mt-0.5 opacity-70">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc', defaultMessage: 'Select issues and click Queue to add items' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<QueueItemRow
|
||||||
|
key={item.item_id}
|
||||||
|
item={item}
|
||||||
|
isHighlighted={associationChain?.queueItemId === item.item_id}
|
||||||
|
onSelect={() => handleSelect(item.item_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline scheduler controls */}
|
||||||
|
<SchedulerBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
// QueuePanel Component
|
// QueuePanel Component
|
||||||
// ========================================
|
// ========================================
|
||||||
// Queue list panel for the terminal dashboard with tab switching.
|
// 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.
|
// Tab 2 (Orchestrator): Active orchestration plans from orchestratorStore.
|
||||||
// Integrates with issueQueueIntegrationStore for association chain.
|
// Integrates with issueQueueIntegrationStore for association chain.
|
||||||
|
// Note: Scheduler controls are in the standalone SchedulerPanel.
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, memo } from 'react';
|
import { useState, useMemo, useCallback, memo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Timer,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -40,6 +42,11 @@ import {
|
|||||||
useQueueExecutionStore,
|
useQueueExecutionStore,
|
||||||
selectByQueueItem,
|
selectByQueueItem,
|
||||||
} from '@/stores/queueExecutionStore';
|
} from '@/stores/queueExecutionStore';
|
||||||
|
import {
|
||||||
|
useQueueSchedulerStore,
|
||||||
|
selectQueueSchedulerStatus,
|
||||||
|
selectQueueItems,
|
||||||
|
} from '@/stores/queueSchedulerStore';
|
||||||
import {
|
import {
|
||||||
useOrchestratorStore,
|
useOrchestratorStore,
|
||||||
selectActivePlans,
|
selectActivePlans,
|
||||||
@@ -47,54 +54,77 @@ import {
|
|||||||
type OrchestrationRunState,
|
type OrchestrationRunState,
|
||||||
} from '@/stores/orchestratorStore';
|
} from '@/stores/orchestratorStore';
|
||||||
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
|
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 ==========
|
// ========== Tab Type ==========
|
||||||
|
|
||||||
type QueueTab = 'queue' | 'orchestrator';
|
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 ==========
|
// ========== Queue Tab: Status Config ==========
|
||||||
|
|
||||||
type QueueItemStatus = QueueItem['status'];
|
const STATUS_CONFIG: Record<CombinedQueueItemStatus, {
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<QueueItemStatus, {
|
|
||||||
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
|
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
|
||||||
icon: typeof Clock;
|
icon: typeof Clock;
|
||||||
label: string;
|
label: string;
|
||||||
}> = {
|
}> = {
|
||||||
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
|
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
|
||||||
|
queued: { variant: 'info', icon: Timer, label: 'Queued' },
|
||||||
ready: { variant: 'info', icon: Zap, label: 'Ready' },
|
ready: { variant: 'info', icon: Zap, label: 'Ready' },
|
||||||
|
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
|
||||||
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
|
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
|
||||||
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
|
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
|
||||||
failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
|
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({
|
function QueueItemRow({
|
||||||
item,
|
item,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
item: QueueItem;
|
item: UnifiedQueueItem;
|
||||||
isHighlighted: boolean;
|
isHighlighted: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage } = useIntl();
|
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 StatusIcon = config.icon;
|
||||||
|
|
||||||
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
|
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
|
||||||
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
||||||
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
|
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
|
||||||
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30'
|
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30',
|
||||||
|
isExecuting && 'border-l-2 border-l-blue-500'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
@@ -103,7 +133,7 @@ function QueueItemRow({
|
|||||||
<StatusIcon
|
<StatusIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3.5 h-3.5 shrink-0',
|
'w-3.5 h-3.5 shrink-0',
|
||||||
item.status === 'executing' && 'animate-spin'
|
isExecuting && 'animate-spin'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-foreground truncate font-mono">
|
<span className="text-sm font-medium text-foreground truncate font-mono">
|
||||||
@@ -111,7 +141,7 @@ function QueueItemRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={config.variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
<Badge variant={config.variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
||||||
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}` })}
|
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground pl-5">
|
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground pl-5">
|
||||||
@@ -125,17 +155,25 @@ function QueueItemRow({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-border">|</span>
|
<span className="text-border">|</span>
|
||||||
<span>{item.execution_group}</span>
|
<span>{item.execution_group}</span>
|
||||||
{activeExec?.sessionKey && (
|
{sessionKey && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border">|</span>
|
<span className="text-border">|</span>
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<Terminal className="w-3 h-3" />
|
<Terminal className="w-3 h-3" />
|
||||||
{activeExec.sessionKey}
|
{sessionKey}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.depends_on.length > 0 && (
|
{isBlocked && item.depends_on.length > 0 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-orange-500/80 pl-5 truncate">
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
|
||||||
|
{ deps: item.depends_on.join(', ') }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isBlocked && item.depends_on.length > 0 && (
|
||||||
<div className="mt-0.5 text-[10px] text-muted-foreground/70 pl-5 truncate">
|
<div className="mt-0.5 text-[10px] text-muted-foreground/70 pl-5 truncate">
|
||||||
{formatMessage(
|
{formatMessage(
|
||||||
{ id: 'terminalDashboard.queuePanel.dependsOn' },
|
{ id: 'terminalDashboard.queuePanel.dependsOn' },
|
||||||
@@ -151,14 +189,24 @@ function QueueItemRow({
|
|||||||
|
|
||||||
function QueueTabContent(_props: { embedded?: boolean }) {
|
function QueueTabContent(_props: { embedded?: boolean }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
// Scheduler store data
|
||||||
|
const schedulerItems = useQueueSchedulerStore(selectQueueItems);
|
||||||
|
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
|
||||||
|
|
||||||
|
// Legacy API data (fallback)
|
||||||
const queueQuery = useIssueQueue();
|
const queueQuery = useIssueQueue();
|
||||||
|
|
||||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||||
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
|
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 [];
|
if (!queueQuery.data) return [];
|
||||||
const grouped = queueQuery.data.grouped_items ?? {};
|
const grouped = queueQuery.data.grouped_items ?? {};
|
||||||
const items: QueueItem[] = [];
|
const items: ApiQueueItem[] = [];
|
||||||
for (const group of Object.values(grouped)) {
|
for (const group of Object.values(grouped)) {
|
||||||
items.push(...group);
|
items.push(...group);
|
||||||
}
|
}
|
||||||
@@ -166,6 +214,13 @@ function QueueTabContent(_props: { embedded?: boolean }) {
|
|||||||
return items;
|
return items;
|
||||||
}, [queueQuery.data]);
|
}, [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(
|
const handleSelect = useCallback(
|
||||||
(queueItemId: string) => {
|
(queueItemId: string) => {
|
||||||
buildAssociationChain(queueItemId, 'queue');
|
buildAssociationChain(queueItemId, 'queue');
|
||||||
@@ -173,7 +228,8 @@ function QueueTabContent(_props: { embedded?: boolean }) {
|
|||||||
[buildAssociationChain]
|
[buildAssociationChain]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queueQuery.isLoading) {
|
// Show loading only for legacy mode
|
||||||
|
if (!useSchedulerData && queueQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
@@ -181,7 +237,7 @@ function QueueTabContent(_props: { embedded?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueQuery.error) {
|
if (!useSchedulerData && queueQuery.error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -446,8 +502,23 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [activeTab, setActiveTab] = useState<QueueTab>('queue');
|
const [activeTab, setActiveTab] = useState<QueueTab>('queue');
|
||||||
const orchestratorCount = useOrchestratorStore(selectActivePlanCount);
|
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 queueQuery = useIssueQueue();
|
||||||
|
|
||||||
const queueActiveCount = useMemo(() => {
|
const queueActiveCount = useMemo(() => {
|
||||||
|
if (useSchedulerData) {
|
||||||
|
return schedulerItems.filter(
|
||||||
|
(item) =>
|
||||||
|
item.status === 'pending' ||
|
||||||
|
item.status === 'queued' ||
|
||||||
|
item.status === 'executing'
|
||||||
|
).length;
|
||||||
|
}
|
||||||
if (!queueQuery.data) return 0;
|
if (!queueQuery.data) return 0;
|
||||||
const grouped = queueQuery.data.grouped_items ?? {};
|
const grouped = queueQuery.data.grouped_items ?? {};
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -457,7 +528,7 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}, [queueQuery.data]);
|
}, [useSchedulerData, schedulerItems, queueQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|||||||
@@ -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<QueueSchedulerStatus, string> = {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 10) {
|
||||||
|
updateConfig({ maxConcurrentSessions: value });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionEntries = Object.entries(sessionPool);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Status + Controls */}
|
||||||
|
<div className="px-3 py-3 border-b border-border space-y-3 shrink-0">
|
||||||
|
{/* Status badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1 rounded text-xs font-medium border',
|
||||||
|
SCHEDULER_STATUS_CLASS[schedulerStatus]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMessage({
|
||||||
|
id: `terminalDashboard.queuePanel.scheduler.status.${schedulerStatus}`,
|
||||||
|
defaultMessage: schedulerStatus,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto tabular-nums">
|
||||||
|
{concurrency}/{config.maxConcurrentSessions}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(isIdle || isTerminal) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1.5 flex-1"
|
||||||
|
onClick={() => startQueue()}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Start' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isPaused && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1.5 flex-1"
|
||||||
|
onClick={() => startQueue()}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Resume' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1.5 flex-1"
|
||||||
|
onClick={pauseQueue}
|
||||||
|
>
|
||||||
|
<Pause className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.pause', defaultMessage: 'Pause' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isRunning || isPaused) && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1.5"
|
||||||
|
disabled={isStopping}
|
||||||
|
onClick={() => setIsStopConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
{isStopping ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Square className="w-3.5 h-3.5" />}
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar (visible when not idle) */}
|
||||||
|
{!isIdle && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="h-1.5 flex-1"
|
||||||
|
indicatorClassName={cn(
|
||||||
|
schedulerStatus === 'failed' && 'bg-destructive',
|
||||||
|
schedulerStatus === 'completed' && 'bg-green-500',
|
||||||
|
schedulerStatus === 'paused' && 'bg-amber-500',
|
||||||
|
(schedulerStatus === 'running' || schedulerStatus === 'stopping') && 'bg-primary',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'terminalDashboard.queuePanel.scheduler.progress', defaultMessage: '{percent}%' },
|
||||||
|
{ percent: progress }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Concurrency Config */}
|
||||||
|
<div className="px-3 py-2.5 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.concurrency', defaultMessage: 'Concurrency' })}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={config.maxConcurrentSessions}
|
||||||
|
onChange={handleConcurrencyChange}
|
||||||
|
className="w-14 h-6 text-xs text-center rounded border border-border bg-background px-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session Pool */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<h4 className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.schedulerPanel.sessionPool', defaultMessage: 'Session Pool' })}
|
||||||
|
</h4>
|
||||||
|
{sessionEntries.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground/60 py-2">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.schedulerPanel.noSessions', defaultMessage: 'No active sessions' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sessionEntries.map(([resumeKey, binding]) => (
|
||||||
|
<div
|
||||||
|
key={resumeKey}
|
||||||
|
className="flex items-center justify-between px-2 py-1.5 rounded bg-muted/30 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-mono text-foreground truncate block">{binding.sessionKey}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate block">{resumeKey}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0 ml-2 tabular-nums">
|
||||||
|
{new Date(binding.lastUsed).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 border-t border-destructive/30 bg-destructive/5 shrink-0">
|
||||||
|
<p className="text-xs text-destructive break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop confirmation dialog */}
|
||||||
|
<AlertDialog open={isStopConfirmOpen} onOpenChange={setIsStopConfirmOpen}>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{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.' })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="h-8 text-xs">
|
||||||
|
{formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="h-8 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => { stopQueue(); setIsStopConfirmOpen(false); }}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,9 +85,14 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
|
|||||||
if (filter?.search) {
|
if (filter?.search) {
|
||||||
const searchLower = filter.search.toLowerCase();
|
const searchLower = filter.search.toLowerCase();
|
||||||
executions = executions.filter(
|
executions = executions.filter(
|
||||||
(exec) =>
|
(exec) => {
|
||||||
exec.prompt_preview.toLowerCase().includes(searchLower) ||
|
// Guard against prompt_preview being an object instead of string
|
||||||
exec.tool.toLowerCase().includes(searchLower)
|
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);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
ccw/frontend/src/hooks/useNativeSessions.ts
Normal file
127
ccw/frontend/src/hooks/useNativeSessions.ts
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group sessions by tool type
|
||||||
|
*/
|
||||||
|
function groupByTool(sessions: NativeSessionListItem[]): ByToolRecord {
|
||||||
|
return sessions.reduce<ByToolRecord>((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<NativeSessionsListResponse>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2100,7 +2100,8 @@ export interface CliExecution {
|
|||||||
tool: 'gemini' | 'qwen' | 'codex' | string;
|
tool: 'gemini' | 'qwen' | 'codex' | string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
status: 'success' | 'error' | 'timeout';
|
status: 'success' | 'error' | 'timeout';
|
||||||
prompt_preview: string;
|
// Backend may return string or object {text: string} for legacy data
|
||||||
|
prompt_preview: string | { text: string } | Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
sourceDir?: string;
|
sourceDir?: string;
|
||||||
@@ -2233,7 +2234,8 @@ export interface ConversationRecord {
|
|||||||
*/
|
*/
|
||||||
export interface ConversationTurn {
|
export interface ConversationTurn {
|
||||||
turn: number;
|
turn: number;
|
||||||
prompt: string;
|
// Backend may return string or object {text: string} for legacy data
|
||||||
|
prompt: string | { text: string } | Record<string, unknown>;
|
||||||
output: {
|
output: {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr?: string;
|
stderr?: string;
|
||||||
@@ -2270,7 +2272,8 @@ export interface NativeSessionTurn {
|
|||||||
turnNumber: number;
|
turnNumber: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
// Backend may return string or object {text: string} for legacy data
|
||||||
|
content: string | { text: string } | Record<string, unknown>;
|
||||||
thoughts?: string[];
|
thoughts?: string[];
|
||||||
toolCalls?: NativeToolCall[];
|
toolCalls?: NativeToolCall[];
|
||||||
tokens?: NativeTokenInfo;
|
tokens?: NativeTokenInfo;
|
||||||
|
|||||||
171
ccw/frontend/src/lib/cli-tool-theme.ts
Normal file
171
ccw/frontend/src/lib/cli-tool-theme.ts
Normal file
@@ -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<string, CliToolTheme> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -118,6 +118,11 @@ export const workspaceQueryKeys = {
|
|||||||
cliExecutionDetail: (projectPath: string, executionId: string) =>
|
cliExecutionDetail: (projectPath: string, executionId: string) =>
|
||||||
[...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const,
|
[...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 ==========
|
||||||
audit: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'audit'] as const,
|
audit: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'audit'] as const,
|
||||||
cliSessionAudit: (
|
cliSessionAudit: (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"description": "View and manage your CLI execution history",
|
"description": "View and manage your CLI execution history",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"executions": "Executions",
|
"executions": "Executions",
|
||||||
"observability": "Session Audit"
|
"observability": "Session Audit",
|
||||||
|
"nativeSessions": "Native Sessions"
|
||||||
},
|
},
|
||||||
"searchPlaceholder": "Search executions...",
|
"searchPlaceholder": "Search executions...",
|
||||||
"filterAllTools": "All Tools",
|
"filterAllTools": "All Tools",
|
||||||
@@ -33,5 +34,13 @@
|
|||||||
"message": "CLI execution history will appear here when you run CLI commands.",
|
"message": "CLI execution history will appear here when you run CLI commands.",
|
||||||
"filtered": "No Matching Results",
|
"filtered": "No Matching Results",
|
||||||
"filteredMessage": "No executions match your current filter. Try adjusting your search or filter."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"issuePanel": {
|
"issuePanel": {
|
||||||
"title": "Issues",
|
"title": "Issues",
|
||||||
"sendToQueue": "Send to Queue",
|
"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",
|
"noIssues": "No issues found",
|
||||||
"noIssuesDesc": "Issues will appear here when discovered",
|
"noIssuesDesc": "Issues will appear here when discovered",
|
||||||
"error": "Failed to load issues"
|
"error": "Failed to load issues"
|
||||||
@@ -59,13 +64,35 @@
|
|||||||
"error": "Failed to load queue",
|
"error": "Failed to load queue",
|
||||||
"order": "#{order}",
|
"order": "#{order}",
|
||||||
"dependsOn": "Depends on: {deps}",
|
"dependsOn": "Depends on: {deps}",
|
||||||
|
"blockedBy": "Blocked by: {deps}",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
|
"queued": "Queued",
|
||||||
"ready": "Ready",
|
"ready": "Ready",
|
||||||
|
"blocked": "Blocked",
|
||||||
"executing": "Executing",
|
"executing": "Executing",
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"failed": "Failed",
|
"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": {
|
"toolbar": {
|
||||||
@@ -81,7 +108,13 @@
|
|||||||
"launchCli": "New Session",
|
"launchCli": "New Session",
|
||||||
"launchCliHint": "Click to configure and create a new CLI session",
|
"launchCliHint": "Click to configure and create a new CLI session",
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
"orchestrator": "Orchestrator"
|
"orchestrator": "Orchestrator",
|
||||||
|
"scheduler": "Scheduler",
|
||||||
|
"executionMonitor": "Execution Monitor"
|
||||||
|
},
|
||||||
|
"schedulerPanel": {
|
||||||
|
"sessionPool": "Session Pool",
|
||||||
|
"noSessions": "No active sessions"
|
||||||
},
|
},
|
||||||
"orchestratorPanel": {
|
"orchestratorPanel": {
|
||||||
"noPlans": "No active orchestrations",
|
"noPlans": "No active orchestrations",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"description": "查看和管理 CLI 执行历史",
|
"description": "查看和管理 CLI 执行历史",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"executions": "执行历史",
|
"executions": "执行历史",
|
||||||
"observability": "会话审计"
|
"observability": "会话审计",
|
||||||
|
"nativeSessions": "原生会话"
|
||||||
},
|
},
|
||||||
"searchPlaceholder": "搜索执行记录...",
|
"searchPlaceholder": "搜索执行记录...",
|
||||||
"filterAllTools": "全部工具",
|
"filterAllTools": "全部工具",
|
||||||
@@ -33,5 +34,13 @@
|
|||||||
"message": "运行 CLI 命令后,执行历史将显示在这里。",
|
"message": "运行 CLI 命令后,执行历史将显示在这里。",
|
||||||
"filtered": "没有匹配结果",
|
"filtered": "没有匹配结果",
|
||||||
"filteredMessage": "没有匹配当前筛选条件的执行记录。请尝试调整搜索或筛选条件。"
|
"filteredMessage": "没有匹配当前筛选条件的执行记录。请尝试调整搜索或筛选条件。"
|
||||||
|
},
|
||||||
|
"nativeSessions": {
|
||||||
|
"count": "{count} 个原生会话",
|
||||||
|
"sessions": "个会话",
|
||||||
|
"empty": {
|
||||||
|
"title": "无原生会话",
|
||||||
|
"message": "来自 Gemini、Codex、Qwen 等的原生 CLI 会话将显示在这里。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"issuePanel": {
|
"issuePanel": {
|
||||||
"title": "问题",
|
"title": "问题",
|
||||||
"sendToQueue": "发送到队列",
|
"sendToQueue": "发送到队列",
|
||||||
|
"addToQueue": "入队",
|
||||||
|
"addToQueueHint": "将选中的问题添加到执行队列",
|
||||||
|
"addedToQueue": "已入队!",
|
||||||
|
"addToQueueFailed": "添加到队列失败",
|
||||||
|
"queueModeHint": "队列项的执行模式",
|
||||||
"noIssues": "暂无问题",
|
"noIssues": "暂无问题",
|
||||||
"noIssuesDesc": "发现问题时将在此显示",
|
"noIssuesDesc": "发现问题时将在此显示",
|
||||||
"error": "加载问题失败"
|
"error": "加载问题失败"
|
||||||
@@ -59,13 +64,35 @@
|
|||||||
"error": "加载队列失败",
|
"error": "加载队列失败",
|
||||||
"order": "#{order}",
|
"order": "#{order}",
|
||||||
"dependsOn": "依赖: {deps}",
|
"dependsOn": "依赖: {deps}",
|
||||||
|
"blockedBy": "阻塞于: {deps}",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "等待中",
|
"pending": "等待中",
|
||||||
|
"queued": "排队中",
|
||||||
"ready": "就绪",
|
"ready": "就绪",
|
||||||
|
"blocked": "已阻塞",
|
||||||
"executing": "执行中",
|
"executing": "执行中",
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"failed": "已失败",
|
"failed": "已失败",
|
||||||
"blocked": "已阻塞"
|
"cancelled": "已取消",
|
||||||
|
"skipped": "已跳过"
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"start": "启动",
|
||||||
|
"pause": "暂停",
|
||||||
|
"stop": "停止",
|
||||||
|
"status": {
|
||||||
|
"idle": "空闲",
|
||||||
|
"running": "运行中",
|
||||||
|
"paused": "已暂停",
|
||||||
|
"stopping": "停止中",
|
||||||
|
"completed": "已完成",
|
||||||
|
"failed": "已失败"
|
||||||
|
},
|
||||||
|
"progress": "{percent}%",
|
||||||
|
"concurrency": "并发数",
|
||||||
|
"concurrencyLabel": "上限",
|
||||||
|
"stopConfirmTitle": "停止队列?",
|
||||||
|
"stopConfirmMessage": "执行中的任务将继续完成,但不会启动新任务。待处理项将保留在队列中。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
@@ -81,7 +108,13 @@
|
|||||||
"launchCli": "新建会话",
|
"launchCli": "新建会话",
|
||||||
"launchCliHint": "点击配置并创建新的 CLI 会话",
|
"launchCliHint": "点击配置并创建新的 CLI 会话",
|
||||||
"fullscreen": "全屏",
|
"fullscreen": "全屏",
|
||||||
"orchestrator": "编排器"
|
"orchestrator": "编排器",
|
||||||
|
"scheduler": "调度器",
|
||||||
|
"executionMonitor": "执行监控"
|
||||||
|
},
|
||||||
|
"schedulerPanel": {
|
||||||
|
"sessionPool": "会话池",
|
||||||
|
"noSessions": "无活跃会话"
|
||||||
},
|
},
|
||||||
"orchestratorPanel": {
|
"orchestratorPanel": {
|
||||||
"noPlans": "没有活跃的编排任务",
|
"noPlans": "没有活跃的编排任务",
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import {
|
|||||||
X,
|
X,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileJson,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useHistory } from '@/hooks/useHistory';
|
import { useHistory } from '@/hooks/useHistory';
|
||||||
|
import { useNativeSessions } from '@/hooks/useNativeSessions';
|
||||||
import { ConversationCard } from '@/components/shared/ConversationCard';
|
import { ConversationCard } from '@/components/shared/ConversationCard';
|
||||||
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
|
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
|
||||||
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
|
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
|
||||||
@@ -42,9 +48,55 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from '@/components/ui/Dropdown';
|
} 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
|
* HistoryPage component - Display CLI execution history
|
||||||
@@ -78,6 +130,40 @@ export function HistoryPage() {
|
|||||||
filter: { search: searchQuery || undefined, tool: toolFilter },
|
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 tools = React.useMemo(() => {
|
||||||
const toolSet = new Set(executions.map((e) => e.tool));
|
const toolSet = new Set(executions.map((e) => e.tool));
|
||||||
return Array.from(toolSet).sort();
|
return Array.from(toolSet).sort();
|
||||||
@@ -197,6 +283,19 @@ export function HistoryPage() {
|
|||||||
>
|
>
|
||||||
{formatMessage({ id: 'history.tabs.observability' })}
|
{formatMessage({ id: 'history.tabs.observability' })}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
@@ -354,6 +453,183 @@ export function HistoryPage() {
|
|||||||
</div>
|
</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 */}
|
{/* CLI Stream Panel */}
|
||||||
<CliStreamPanel
|
<CliStreamPanel
|
||||||
executionId={selectedExecution || ''}
|
executionId={selectedExecution || ''}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { FloatingPanel } from '@/components/terminal-dashboard/FloatingPanel';
|
|||||||
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
|
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
|
||||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
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 { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
||||||
import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel';
|
import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel';
|
||||||
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
||||||
@@ -105,9 +107,19 @@ export function TerminalDashboardPage() {
|
|||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
||||||
side="left"
|
side="left"
|
||||||
width={380}
|
width={700}
|
||||||
>
|
>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="flex-1 min-w-0 border-r border-border">
|
||||||
<IssuePanel />
|
<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>
|
</FloatingPanel>
|
||||||
|
|
||||||
{featureFlags.dashboardQueuePanelEnabled && (
|
{featureFlags.dashboardQueuePanelEnabled && (
|
||||||
@@ -145,6 +157,16 @@ export function TerminalDashboardPage() {
|
|||||||
<ExecutionMonitorPanel />
|
<ExecutionMonitorPanel />
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FloatingPanel
|
||||||
|
isOpen={activePanel === 'scheduler'}
|
||||||
|
onClose={closePanel}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.toolbar.scheduler', defaultMessage: 'Scheduler' })}
|
||||||
|
side="right"
|
||||||
|
width={340}
|
||||||
|
>
|
||||||
|
<SchedulerPanel />
|
||||||
|
</FloatingPanel>
|
||||||
</AssociationHighlightProvider>
|
</AssociationHighlightProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ interface CliStreamState extends BlockCacheState {
|
|||||||
executions: Record<string, CliExecutionState>;
|
executions: Record<string, CliExecutionState>;
|
||||||
currentExecutionId: string | null;
|
currentExecutionId: string | null;
|
||||||
userClosedExecutions: Set<string>; // Track executions closed by user
|
userClosedExecutions: Set<string>; // Track executions closed by user
|
||||||
|
deduplicationWindows: Record<string, string[]>; // Rolling hash window per execution
|
||||||
|
|
||||||
// Legacy methods
|
// Legacy methods
|
||||||
addOutput: (executionId: string, line: CliOutputLine) => void;
|
addOutput: (executionId: string, line: CliOutputLine) => void;
|
||||||
@@ -106,8 +107,26 @@ interface CliStreamState extends BlockCacheState {
|
|||||||
*/
|
*/
|
||||||
const MAX_OUTPUT_LINES = 5000;
|
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 ==========
|
// ========== 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
|
* Parse tool call metadata from content
|
||||||
* Expected format: "[Tool] toolName(args)"
|
* Expected format: "[Tool] toolName(args)"
|
||||||
@@ -325,12 +344,25 @@ export const useCliStreamStore = create<CliStreamState>()(
|
|||||||
executions: {},
|
executions: {},
|
||||||
currentExecutionId: null,
|
currentExecutionId: null,
|
||||||
userClosedExecutions: new Set<string>(),
|
userClosedExecutions: new Set<string>(),
|
||||||
|
deduplicationWindows: {},
|
||||||
|
|
||||||
// Block cache state
|
// Block cache state
|
||||||
blocks: {},
|
blocks: {},
|
||||||
lastUpdate: {},
|
lastUpdate: {},
|
||||||
|
|
||||||
addOutput: (executionId: string, line: CliOutputLine) => {
|
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) => {
|
set((state) => {
|
||||||
const current = state.outputs[executionId] || [];
|
const current = state.outputs[executionId] || [];
|
||||||
const updated = [...current, line];
|
const updated = [...current, line];
|
||||||
@@ -342,6 +374,10 @@ export const useCliStreamStore = create<CliStreamState>()(
|
|||||||
...state.outputs,
|
...state.outputs,
|
||||||
[executionId]: updated.slice(-MAX_OUTPUT_LINES),
|
[executionId]: updated.slice(-MAX_OUTPUT_LINES),
|
||||||
},
|
},
|
||||||
|
deduplicationWindows: {
|
||||||
|
...state.deduplicationWindows,
|
||||||
|
[executionId]: newWindow,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +386,10 @@ export const useCliStreamStore = create<CliStreamState>()(
|
|||||||
...state.outputs,
|
...state.outputs,
|
||||||
[executionId]: updated,
|
[executionId]: updated,
|
||||||
},
|
},
|
||||||
|
deduplicationWindows: {
|
||||||
|
...state.deduplicationWindows,
|
||||||
|
[executionId]: newWindow,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, false, 'cliStream/addOutput');
|
}, false, 'cliStream/addOutput');
|
||||||
|
|
||||||
@@ -425,13 +465,16 @@ export const useCliStreamStore = create<CliStreamState>()(
|
|||||||
const newExecutions = { ...state.executions };
|
const newExecutions = { ...state.executions };
|
||||||
const newBlocks = { ...state.blocks };
|
const newBlocks = { ...state.blocks };
|
||||||
const newLastUpdate = { ...state.lastUpdate };
|
const newLastUpdate = { ...state.lastUpdate };
|
||||||
|
const newDeduplicationWindows = { ...state.deduplicationWindows };
|
||||||
delete newExecutions[executionId];
|
delete newExecutions[executionId];
|
||||||
delete newBlocks[executionId];
|
delete newBlocks[executionId];
|
||||||
delete newLastUpdate[executionId];
|
delete newLastUpdate[executionId];
|
||||||
|
delete newDeduplicationWindows[executionId];
|
||||||
return {
|
return {
|
||||||
executions: newExecutions,
|
executions: newExecutions,
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
lastUpdate: newLastUpdate,
|
lastUpdate: newLastUpdate,
|
||||||
|
deduplicationWindows: newDeduplicationWindows,
|
||||||
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
|
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
|
||||||
};
|
};
|
||||||
}, false, 'cliStream/removeExecution');
|
}, false, 'cliStream/removeExecution');
|
||||||
|
|||||||
351
ccw/frontend/src/stores/queueSchedulerStore.ts
Normal file
351
ccw/frontend/src/stores/queueSchedulerStore.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// ========================================
|
||||||
|
// Queue Scheduler Store
|
||||||
|
// ========================================
|
||||||
|
// Zustand store for queue scheduler state management.
|
||||||
|
// Handles WebSocket message dispatch, API actions, and provides
|
||||||
|
// granular selectors for efficient React re-renders.
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
QueueSchedulerStatus,
|
||||||
|
QueueSchedulerConfig,
|
||||||
|
QueueItem,
|
||||||
|
QueueSchedulerState,
|
||||||
|
QueueWSMessage,
|
||||||
|
SessionBinding,
|
||||||
|
} from '../types/queue-frontend-types';
|
||||||
|
|
||||||
|
// ========== Default Config ==========
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: QueueSchedulerConfig = {
|
||||||
|
maxConcurrentSessions: 2,
|
||||||
|
sessionIdleTimeoutMs: 60_000,
|
||||||
|
resumeKeySessionBindingTimeoutMs: 300_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Store State Interface ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store state extends the wire format QueueSchedulerState with
|
||||||
|
* nullable fields for "not yet loaded" state.
|
||||||
|
*/
|
||||||
|
interface QueueSchedulerStoreState {
|
||||||
|
status: QueueSchedulerStatus;
|
||||||
|
items: QueueItem[];
|
||||||
|
sessionPool: Record<string, SessionBinding>;
|
||||||
|
config: QueueSchedulerConfig;
|
||||||
|
currentConcurrency: number;
|
||||||
|
lastActivityAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Actions Interface ==========
|
||||||
|
|
||||||
|
interface QueueSchedulerActions {
|
||||||
|
/** Dispatch a WebSocket message to update store state */
|
||||||
|
handleSchedulerMessage: (msg: QueueWSMessage) => void;
|
||||||
|
/** Fetch initial state from GET /api/queue/scheduler/state */
|
||||||
|
loadInitialState: () => Promise<void>;
|
||||||
|
/** Submit items to the queue via POST /api/queue/execute (auto-starts if idle) */
|
||||||
|
submitItems: (items: QueueItem[]) => Promise<void>;
|
||||||
|
/** Start the queue scheduler via POST /api/queue/scheduler/start */
|
||||||
|
startQueue: (items?: QueueItem[]) => Promise<void>;
|
||||||
|
/** Pause the queue scheduler via POST /api/queue/scheduler/pause */
|
||||||
|
pauseQueue: () => Promise<void>;
|
||||||
|
/** Stop the queue scheduler via POST /api/queue/scheduler/stop */
|
||||||
|
stopQueue: () => Promise<void>;
|
||||||
|
/** Update scheduler config via POST /api/queue/scheduler/config */
|
||||||
|
updateConfig: (config: Partial<QueueSchedulerConfig>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueSchedulerStore = QueueSchedulerStoreState & QueueSchedulerActions;
|
||||||
|
|
||||||
|
// ========== Initial State ==========
|
||||||
|
|
||||||
|
const initialState: QueueSchedulerStoreState = {
|
||||||
|
status: 'idle',
|
||||||
|
items: [],
|
||||||
|
sessionPool: {},
|
||||||
|
config: DEFAULT_CONFIG,
|
||||||
|
currentConcurrency: 0,
|
||||||
|
lastActivityAt: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Store ==========
|
||||||
|
|
||||||
|
export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||||
|
devtools(
|
||||||
|
(set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== WebSocket Message Handler ==========
|
||||||
|
|
||||||
|
handleSchedulerMessage: (msg: QueueWSMessage) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'QUEUE_SCHEDULER_STATE_UPDATE':
|
||||||
|
// Backend sends full state snapshot
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
status: msg.state.status,
|
||||||
|
items: msg.state.items,
|
||||||
|
sessionPool: msg.state.sessionPool,
|
||||||
|
config: msg.state.config,
|
||||||
|
currentConcurrency: msg.state.currentConcurrency,
|
||||||
|
lastActivityAt: msg.state.lastActivityAt,
|
||||||
|
error: msg.state.error ?? null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'handleSchedulerMessage/QUEUE_SCHEDULER_STATE_UPDATE'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'QUEUE_ITEM_ADDED':
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
items: [...state.items, msg.item],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'handleSchedulerMessage/QUEUE_ITEM_ADDED'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'QUEUE_ITEM_UPDATED':
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
items: state.items.map((item) =>
|
||||||
|
item.item_id === msg.item.item_id ? msg.item : item
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'handleSchedulerMessage/QUEUE_ITEM_UPDATED'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'QUEUE_ITEM_REMOVED':
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
items: state.items.filter((item) => item.item_id !== msg.item_id),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'handleSchedulerMessage/QUEUE_ITEM_REMOVED'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'QUEUE_SCHEDULER_CONFIG_UPDATED':
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
config: msg.config,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'handleSchedulerMessage/QUEUE_SCHEDULER_CONFIG_UPDATED'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// No default - all 5 message types are handled exhaustively
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== API Actions ==========
|
||||||
|
|
||||||
|
submitItems: async (items: QueueItem[]) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || response.statusText);
|
||||||
|
}
|
||||||
|
// State will be updated via WebSocket broadcast from backend
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] submitItems error:', message);
|
||||||
|
set({ error: message }, false, 'submitItems/error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadInitialState: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/scheduler/state', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load scheduler state: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data: QueueSchedulerState = await response.json();
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
status: data.status,
|
||||||
|
items: data.items,
|
||||||
|
sessionPool: data.sessionPool,
|
||||||
|
config: data.config,
|
||||||
|
currentConcurrency: data.currentConcurrency,
|
||||||
|
lastActivityAt: data.lastActivityAt,
|
||||||
|
error: data.error ?? null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'loadInitialState'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] loadInitialState error:', message);
|
||||||
|
set({ error: message }, false, 'loadInitialState/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startQueue: async (items?: QueueItem[]) => {
|
||||||
|
try {
|
||||||
|
const body = items ? { items } : {};
|
||||||
|
const response = await fetch('/api/queue/scheduler/start', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] startQueue error:', message);
|
||||||
|
set({ error: message }, false, 'startQueue/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pauseQueue: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/scheduler/pause', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] pauseQueue error:', message);
|
||||||
|
set({ error: message }, false, 'pauseQueue/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopQueue: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/scheduler/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] stopQueue error:', message);
|
||||||
|
set({ error: message }, false, 'stopQueue/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfig: async (config: Partial<QueueSchedulerConfig>) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/scheduler/config', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[QueueScheduler] updateConfig error:', message);
|
||||||
|
set({ error: message }, false, 'updateConfig/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'QueueSchedulerStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== Selectors ==========
|
||||||
|
|
||||||
|
/** Stable empty array to avoid new references on each call */
|
||||||
|
const EMPTY_ITEMS: QueueItem[] = [];
|
||||||
|
|
||||||
|
/** Select current scheduler status */
|
||||||
|
export const selectQueueSchedulerStatus = (state: QueueSchedulerStore): QueueSchedulerStatus =>
|
||||||
|
state.status;
|
||||||
|
|
||||||
|
/** Select all queue items */
|
||||||
|
export const selectQueueItems = (state: QueueSchedulerStore): QueueItem[] =>
|
||||||
|
state.items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select items that are ready to execute (status 'queued' or 'pending').
|
||||||
|
* WARNING: Returns new array each call - use with useMemo in components.
|
||||||
|
*/
|
||||||
|
export const selectReadyItems = (state: QueueSchedulerStore): QueueItem[] => {
|
||||||
|
const ready = state.items.filter(
|
||||||
|
(item) => item.status === 'queued' || item.status === 'pending'
|
||||||
|
);
|
||||||
|
return ready.length === 0 ? EMPTY_ITEMS : ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select items that are blocked (status 'blocked').
|
||||||
|
* WARNING: Returns new array each call - use with useMemo in components.
|
||||||
|
*/
|
||||||
|
export const selectBlockedItems = (state: QueueSchedulerStore): QueueItem[] => {
|
||||||
|
const blocked = state.items.filter((item) => item.status === 'blocked');
|
||||||
|
return blocked.length === 0 ? EMPTY_ITEMS : blocked;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select items currently executing (status 'executing').
|
||||||
|
* WARNING: Returns new array each call - use with useMemo in components.
|
||||||
|
*/
|
||||||
|
export const selectExecutingItems = (state: QueueSchedulerStore): QueueItem[] => {
|
||||||
|
const executing = state.items.filter((item) => item.status === 'executing');
|
||||||
|
return executing.length === 0 ? EMPTY_ITEMS : executing;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate overall scheduler progress as a percentage (0-100).
|
||||||
|
* Progress = (completed + failed) / total * 100.
|
||||||
|
* Returns 0 when there are no items.
|
||||||
|
*/
|
||||||
|
export const selectSchedulerProgress = (state: QueueSchedulerStore): number => {
|
||||||
|
const total = state.items.length;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
const terminal = state.items.filter(
|
||||||
|
(item) => item.status === 'completed' || item.status === 'failed'
|
||||||
|
).length;
|
||||||
|
return Math.round((terminal / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Select scheduler config */
|
||||||
|
export const selectSchedulerConfig = (state: QueueSchedulerStore): QueueSchedulerConfig =>
|
||||||
|
state.config;
|
||||||
|
|
||||||
|
/** Select session pool */
|
||||||
|
export const selectSessionPool = (state: QueueSchedulerStore): Record<string, SessionBinding> =>
|
||||||
|
state.sessionPool;
|
||||||
|
|
||||||
|
/** Select current concurrency */
|
||||||
|
export const selectCurrentConcurrency = (state: QueueSchedulerStore): number =>
|
||||||
|
state.currentConcurrency;
|
||||||
|
|
||||||
|
/** Select scheduler error */
|
||||||
|
export const selectSchedulerError = (state: QueueSchedulerStore): string | null =>
|
||||||
|
state.error;
|
||||||
@@ -283,6 +283,54 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'codexlens.reranker.selectBackend': 'Select backend...',
|
'codexlens.reranker.selectBackend': 'Select backend...',
|
||||||
'codexlens.reranker.selectModel': 'Select model...',
|
'codexlens.reranker.selectModel': 'Select model...',
|
||||||
'codexlens.reranker.selectProvider': 'Select provider...',
|
'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',
|
'navigation.codexlens': 'CodexLens',
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
@@ -555,6 +603,54 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'codexlens.reranker.selectBackend': '选择后端...',
|
'codexlens.reranker.selectBackend': '选择后端...',
|
||||||
'codexlens.reranker.selectModel': '选择模型...',
|
'codexlens.reranker.selectModel': '选择模型...',
|
||||||
'codexlens.reranker.selectProvider': '选择提供商...',
|
'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',
|
'navigation.codexlens': 'CodexLens',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
201
ccw/frontend/src/types/queue-frontend-types.ts
Normal file
201
ccw/frontend/src/types/queue-frontend-types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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<string, SessionBinding>;
|
||||||
|
/** Active scheduler configuration */
|
||||||
|
config: QueueSchedulerConfig;
|
||||||
|
/** Number of currently executing tasks */
|
||||||
|
currentConcurrency: number;
|
||||||
|
/** Timestamp of last scheduler activity */
|
||||||
|
lastActivityAt: string;
|
||||||
|
/** Error message if scheduler status is 'failed' */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WebSocket Message Types ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminator values for queue-related WebSocket messages.
|
||||||
|
*/
|
||||||
|
export type QueueWSMessageType =
|
||||||
|
| 'QUEUE_SCHEDULER_STATE_UPDATE'
|
||||||
|
| 'QUEUE_ITEM_ADDED'
|
||||||
|
| 'QUEUE_ITEM_UPDATED'
|
||||||
|
| 'QUEUE_ITEM_REMOVED'
|
||||||
|
| 'QUEUE_SCHEDULER_CONFIG_UPDATED';
|
||||||
|
|
||||||
|
// ========== 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;
|
||||||
93
ccw/src/config/litellm-static-models.ts
Normal file
93
ccw/src/config/litellm-static-models.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* LiteLLM Static Model Lists (Fallback)
|
||||||
|
*
|
||||||
|
* Sourced from LiteLLM's internal model lists.
|
||||||
|
* Used as fallback when user config has no availableModels defined.
|
||||||
|
*
|
||||||
|
* Last updated: 2026-02-27
|
||||||
|
* Source: Python litellm module static lists
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from CLI tool names to LiteLLM provider model lists
|
||||||
|
*/
|
||||||
|
export const LITELLM_STATIC_MODELS: Record<string, ModelInfo[]> = {
|
||||||
|
// Gemini models (from litellm.gemini_models)
|
||||||
|
gemini: [
|
||||||
|
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||||
|
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||||
|
{ id: 'gemini-2.0-pro-exp-02-05', name: 'Gemini 2.0 Pro Exp' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||||
|
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' },
|
||||||
|
{ id: 'gemini-1.5-pro-latest', name: 'Gemini 1.5 Pro Latest' },
|
||||||
|
{ id: 'gemini-embedding-001', name: 'Gemini Embedding 001' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// OpenAI models (from litellm.open_ai_chat_completion_models)
|
||||||
|
codex: [
|
||||||
|
{ id: 'gpt-5.2', name: 'GPT-5.2' },
|
||||||
|
{ id: 'gpt-5.1-chat-latest', name: 'GPT-5.1 Chat Latest' },
|
||||||
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
|
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||||
|
{ id: 'o4-mini-2025-04-16', name: 'O4 Mini' },
|
||||||
|
{ id: 'o3', name: 'O3' },
|
||||||
|
{ id: 'o1-mini', name: 'O1 Mini' },
|
||||||
|
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Anthropic models (from litellm.anthropic_models)
|
||||||
|
claude: [
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
||||||
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
|
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||||
|
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
|
||||||
|
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||||
|
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
|
||||||
|
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
|
||||||
|
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// OpenAI models for opencode (via LiteLLM proxy)
|
||||||
|
opencode: [
|
||||||
|
{ id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free' },
|
||||||
|
{ id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano' },
|
||||||
|
{ id: 'opencode/grok-code', name: 'Grok Code' },
|
||||||
|
{ id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Qwen models
|
||||||
|
qwen: [
|
||||||
|
{ id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B' },
|
||||||
|
{ id: 'qwen2.5-coder', name: 'Qwen 2.5 Coder' },
|
||||||
|
{ id: 'qwen2.5-72b', name: 'Qwen 2.5 72B' },
|
||||||
|
{ id: 'qwen2-72b', name: 'Qwen 2 72B' },
|
||||||
|
{ id: 'coder-model', name: 'Qwen Coder' },
|
||||||
|
{ id: 'vision-model', name: 'Qwen Vision' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fallback models for a tool
|
||||||
|
* @param toolId - Tool identifier (e.g., 'gemini', 'claude', 'codex')
|
||||||
|
* @returns Array of model info, or empty array if not found
|
||||||
|
*/
|
||||||
|
export function getFallbackModels(toolId: string): ModelInfo[] {
|
||||||
|
return LITELLM_STATIC_MODELS[toolId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool has fallback models defined
|
||||||
|
* @param toolId - Tool identifier
|
||||||
|
* @returns true if fallback models exist
|
||||||
|
*/
|
||||||
|
export function hasFallbackModels(toolId: string): boolean {
|
||||||
|
return toolId in LITELLM_STATIC_MODELS;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* CLI Tool Model Reference Library
|
* CLI Tool Model Type Definitions
|
||||||
*
|
*
|
||||||
* System reference for available models per CLI tool provider.
|
* Type definitions for CLI tool models.
|
||||||
* This is a read-only reference, NOT user configuration.
|
* Model lists are now read from user configuration (cli-tools.json).
|
||||||
* User configuration is managed via tools.{tool}.primaryModel/secondaryModel in cli-tools.json
|
* Each tool can define availableModels in its configuration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ProviderModelInfo {
|
export interface ProviderModelInfo {
|
||||||
@@ -19,105 +19,5 @@ export interface ProviderInfo {
|
|||||||
models: ProviderModelInfo[];
|
models: ProviderModelInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Re-export from claude-cli-tools for convenience
|
||||||
* System reference for CLI tool models
|
export type { ClaudeCliTool, ClaudeCliToolsConfig, CliToolName } from '../tools/claude-cli-tools.js';
|
||||||
* Maps provider names to their available models
|
|
||||||
*/
|
|
||||||
export const PROVIDER_MODELS: Record<string, ProviderInfo> = {
|
|
||||||
google: {
|
|
||||||
name: 'Google AI',
|
|
||||||
models: [
|
|
||||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', capabilities: ['text', 'vision', 'code'], contextWindow: 1000000 },
|
|
||||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', capabilities: ['text', 'code'], contextWindow: 1000000 },
|
|
||||||
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', capabilities: ['text'], contextWindow: 1000000 },
|
|
||||||
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', capabilities: ['text', 'vision'], contextWindow: 2000000 },
|
|
||||||
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', capabilities: ['text'], contextWindow: 1000000 }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
qwen: {
|
|
||||||
name: 'Qwen',
|
|
||||||
models: [
|
|
||||||
{ id: 'coder-model', name: 'Qwen Coder', capabilities: ['code'] },
|
|
||||||
{ id: 'vision-model', name: 'Qwen Vision', capabilities: ['vision'] },
|
|
||||||
{ id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B', capabilities: ['code'] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
name: 'OpenAI',
|
|
||||||
models: [
|
|
||||||
{ id: 'gpt-5.2', name: 'GPT-5.2', capabilities: ['text', 'code'] },
|
|
||||||
{ id: 'gpt-4.1', name: 'GPT-4.1', capabilities: ['text', 'code'] },
|
|
||||||
{ id: 'o4-mini', name: 'O4 Mini', capabilities: ['text'] },
|
|
||||||
{ id: 'o3', name: 'O3', capabilities: ['text'] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
anthropic: {
|
|
||||||
name: 'Anthropic',
|
|
||||||
models: [
|
|
||||||
{ id: 'sonnet', name: 'Claude Sonnet', capabilities: ['text', 'code'] },
|
|
||||||
{ id: 'opus', name: 'Claude Opus', capabilities: ['text', 'code', 'vision'] },
|
|
||||||
{ id: 'haiku', name: 'Claude Haiku', capabilities: ['text'] },
|
|
||||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude 4.5 Sonnet (2025-09-29)', capabilities: ['text', 'code'] },
|
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude 4.5 Opus (2025-11-01)', capabilities: ['text', 'code', 'vision'] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
litellm: {
|
|
||||||
name: 'LiteLLM Aggregator',
|
|
||||||
models: [
|
|
||||||
{ id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free', capabilities: ['text'] },
|
|
||||||
{ id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano', capabilities: ['text'] },
|
|
||||||
{ id: 'opencode/grok-code', name: 'Grok Code', capabilities: ['code'] },
|
|
||||||
{ id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free', capabilities: ['text'] },
|
|
||||||
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (via LiteLLM)', capabilities: ['text'] },
|
|
||||||
{ id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4 (via LiteLLM)', capabilities: ['text'] },
|
|
||||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1 (via LiteLLM)', capabilities: ['text'] },
|
|
||||||
{ id: 'openai/o3', name: 'O3 (via LiteLLM)', capabilities: ['text'] },
|
|
||||||
{ id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (via LiteLLM)', capabilities: ['text'] },
|
|
||||||
{ id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (via LiteLLM)', capabilities: ['text'] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get models for a specific provider
|
|
||||||
* @param provider - Provider name (e.g., 'google', 'qwen', 'openai', 'anthropic', 'litellm')
|
|
||||||
* @returns Array of model information
|
|
||||||
*/
|
|
||||||
export function getProviderModels(provider: string): ProviderModelInfo[] {
|
|
||||||
return PROVIDER_MODELS[provider]?.models || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all provider names
|
|
||||||
* @returns Array of provider names
|
|
||||||
*/
|
|
||||||
export function getAllProviders(): string[] {
|
|
||||||
return Object.keys(PROVIDER_MODELS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find model information across all providers
|
|
||||||
* @param modelId - Model identifier to search for
|
|
||||||
* @returns Model information or undefined if not found
|
|
||||||
*/
|
|
||||||
export function findModelInfo(modelId: string): ProviderModelInfo | undefined {
|
|
||||||
for (const provider of Object.values(PROVIDER_MODELS)) {
|
|
||||||
const model = provider.models.find(m => m.id === modelId);
|
|
||||||
if (model) return model;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get provider name for a model ID
|
|
||||||
* @param modelId - Model identifier
|
|
||||||
* @returns Provider name or undefined if not found
|
|
||||||
*/
|
|
||||||
export function getProviderForModel(modelId: string): string | undefined {
|
|
||||||
for (const [providerId, provider] of Object.entries(PROVIDER_MODELS)) {
|
|
||||||
if (provider.models.some(m => m.id === modelId)) {
|
|
||||||
return providerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getEnrichedConversation,
|
getEnrichedConversation,
|
||||||
getHistoryWithNativeInfo
|
getHistoryWithNativeInfo
|
||||||
} from '../../tools/cli-executor.js';
|
} from '../../tools/cli-executor.js';
|
||||||
|
import { listAllNativeSessions } from '../../tools/native-session-discovery.js';
|
||||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||||
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
|
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
|
||||||
import {
|
import {
|
||||||
@@ -851,6 +852,35 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: List Native CLI Sessions
|
||||||
|
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
|
||||||
|
const projectPath = url.searchParams.get('path') || null;
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = listAllNativeSessions({
|
||||||
|
workingDir: projectPath || undefined,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group sessions by tool
|
||||||
|
const byTool: Record<string, typeof sessions> = {};
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!byTool[session.tool]) {
|
||||||
|
byTool[session.tool] = [];
|
||||||
|
}
|
||||||
|
byTool[session.tool].push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ sessions, byTool }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// API: Execute CLI Tool
|
// API: Execute CLI Tool
|
||||||
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body) => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* Provider Reference Routes Module
|
* Provider Reference Routes Module
|
||||||
* Handles read-only provider model reference API endpoints
|
* 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 type { RouteContext } from './types.js';
|
||||||
import {
|
import { loadClaudeCliTools, type ClaudeCliToolsConfig } from '../../tools/claude-cli-tools.js';
|
||||||
PROVIDER_MODELS,
|
import { getFallbackModels, hasFallbackModels, type ModelInfo } from '../../config/litellm-static-models.js';
|
||||||
getAllProviders,
|
|
||||||
getProviderModels
|
/**
|
||||||
} from '../../config/provider-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
|
* Handle Provider Reference routes
|
||||||
* @returns true if route was handled, false otherwise
|
* @returns true if route was handled, false otherwise
|
||||||
*/
|
*/
|
||||||
export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean> {
|
export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
const { pathname, req, res } = ctx;
|
const { pathname, req, res, initialPath } = ctx;
|
||||||
|
|
||||||
// ========== GET ALL PROVIDERS ==========
|
// ========== GET ALL PROVIDERS ==========
|
||||||
// GET /api/providers
|
// GET /api/providers
|
||||||
if (pathname === '/api/providers' && req.method === 'GET') {
|
if (pathname === '/api/providers' && req.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const providers = getAllProviders().map(id => ({
|
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,
|
id,
|
||||||
name: PROVIDER_MODELS[id].name,
|
name: id.charAt(0).toUpperCase() + id.slice(1),
|
||||||
modelCount: PROVIDER_MODELS[id].models.length
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ success: true, providers }));
|
res.end(JSON.stringify({ success: true, providers }));
|
||||||
@@ -46,9 +71,10 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean>
|
|||||||
const provider = decodeURIComponent(providerMatch[1]);
|
const provider = decodeURIComponent(providerMatch[1]);
|
||||||
|
|
||||||
try {
|
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.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -57,12 +83,19 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise<boolean>
|
|||||||
return true;
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
provider,
|
provider,
|
||||||
providerName: PROVIDER_MODELS[provider].name,
|
providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||||
models
|
models,
|
||||||
|
primaryModel: tool.primaryModel ?? '',
|
||||||
|
secondaryModel: tool.secondaryModel ?? '',
|
||||||
|
source: usingFallback ? 'fallback' : 'config'
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
157
ccw/src/core/routes/queue-routes.ts
Normal file
157
ccw/src/core/routes/queue-routes.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Queue Scheduler Routes Module
|
||||||
|
*
|
||||||
|
* HTTP API endpoints for the Queue Scheduler Service.
|
||||||
|
* Delegates all business logic to QueueSchedulerService.
|
||||||
|
*
|
||||||
|
* API Endpoints:
|
||||||
|
* - POST /api/queue/execute - Submit items to the scheduler and start
|
||||||
|
* - GET /api/queue/scheduler/state - Get full scheduler state
|
||||||
|
* - POST /api/queue/scheduler/start - Start scheduling loop with items
|
||||||
|
* - POST /api/queue/scheduler/pause - Pause scheduling
|
||||||
|
* - POST /api/queue/scheduler/stop - Graceful stop
|
||||||
|
* - POST /api/queue/scheduler/config - Update scheduler configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
import type { QueueSchedulerService } from '../services/queue-scheduler-service.js';
|
||||||
|
import type { QueueItem, QueueSchedulerConfig } from '../../types/queue-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle queue scheduler routes
|
||||||
|
* @returns true if route was handled, false otherwise
|
||||||
|
*/
|
||||||
|
export async function handleQueueSchedulerRoutes(
|
||||||
|
ctx: RouteContext,
|
||||||
|
schedulerService: QueueSchedulerService,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { pathname, req, res, handlePostRequest } = ctx;
|
||||||
|
|
||||||
|
// POST /api/queue/execute - Submit items and start the scheduler
|
||||||
|
if (pathname === '/api/queue/execute' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { items } = body as { items?: QueueItem[] };
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return { error: 'items array is required and must not be empty', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = schedulerService.getState();
|
||||||
|
|
||||||
|
// If idle, start with items; otherwise add items to running scheduler
|
||||||
|
if (state.status === 'idle') {
|
||||||
|
schedulerService.start(items);
|
||||||
|
} else if (state.status === 'running' || state.status === 'paused') {
|
||||||
|
for (const item of items) {
|
||||||
|
schedulerService.addItem(item);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
error: `Cannot add items when scheduler is in '${state.status}' state`,
|
||||||
|
status: 409,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
state: schedulerService.getState(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message, status: 500 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/queue/scheduler/state - Return full scheduler state
|
||||||
|
if (pathname === '/api/queue/scheduler/state' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const state = schedulerService.getState();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, state }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/queue/scheduler/start - Start scheduling loop with items
|
||||||
|
if (pathname === '/api/queue/scheduler/start' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { items } = body as { items?: QueueItem[] };
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return { error: 'items array is required and must not be empty', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
schedulerService.start(items);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
state: schedulerService.getState(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message, status: 409 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/queue/scheduler/pause - Pause scheduling
|
||||||
|
if (pathname === '/api/queue/scheduler/pause' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async () => {
|
||||||
|
try {
|
||||||
|
schedulerService.pause();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
state: schedulerService.getState(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message, status: 409 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/queue/scheduler/stop - Graceful stop
|
||||||
|
if (pathname === '/api/queue/scheduler/stop' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async () => {
|
||||||
|
try {
|
||||||
|
await schedulerService.stop();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
state: schedulerService.getState(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message, status: 409 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/queue/scheduler/config - Update scheduler configuration
|
||||||
|
if (pathname === '/api/queue/scheduler/config' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const config = body as Partial<QueueSchedulerConfig>;
|
||||||
|
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
return { error: 'Configuration object is required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
schedulerService.updateConfig(config);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
config: schedulerService.getState().config,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message, status: 500 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { handleSkillsRoutes } from './routes/skills-routes.js';
|
|||||||
import { handleSkillHubRoutes } from './routes/skill-hub-routes.js';
|
import { handleSkillHubRoutes } from './routes/skill-hub-routes.js';
|
||||||
import { handleCommandsRoutes } from './routes/commands-routes.js';
|
import { handleCommandsRoutes } from './routes/commands-routes.js';
|
||||||
import { handleIssueRoutes } from './routes/issue-routes.js';
|
import { handleIssueRoutes } from './routes/issue-routes.js';
|
||||||
|
import { handleQueueSchedulerRoutes } from './routes/queue-routes.js';
|
||||||
import { handleDiscoveryRoutes } from './routes/discovery-routes.js';
|
import { handleDiscoveryRoutes } from './routes/discovery-routes.js';
|
||||||
import { handleRulesRoutes } from './routes/rules-routes.js';
|
import { handleRulesRoutes } from './routes/rules-routes.js';
|
||||||
import { handleSessionRoutes } from './routes/session-routes.js';
|
import { handleSessionRoutes } from './routes/session-routes.js';
|
||||||
@@ -56,6 +57,8 @@ import { randomBytes } from 'crypto';
|
|||||||
// Import health check service
|
// Import health check service
|
||||||
import { getHealthCheckService } from './services/health-check-service.js';
|
import { getHealthCheckService } from './services/health-check-service.js';
|
||||||
import { getCliSessionShareManager } from './services/cli-session-share.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 status check functions for warmup
|
||||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||||
@@ -294,6 +297,10 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
||||||
const cliSessionShareManager = getCliSessionShareManager();
|
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 server = http.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
@@ -589,7 +596,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleCommandsRoutes(routeContext)) return;
|
if (await handleCommandsRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue routes (/api/queue*) - top-level queue API
|
// Queue Scheduler routes (/api/queue/execute, /api/queue/scheduler/*)
|
||||||
|
if (pathname === '/api/queue/execute' || pathname.startsWith('/api/queue/scheduler')) {
|
||||||
|
if (await handleQueueSchedulerRoutes(routeContext, queueSchedulerService)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue routes (/api/queue*) - top-level queue API (issue-based)
|
||||||
if (pathname.startsWith('/api/queue')) {
|
if (pathname.startsWith('/api/queue')) {
|
||||||
if (await handleIssueRoutes(routeContext)) return;
|
if (await handleIssueRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|||||||
691
ccw/src/core/services/queue-scheduler-service.ts
Normal file
691
ccw/src/core/services/queue-scheduler-service.ts
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
/**
|
||||||
|
* Queue Scheduler Service
|
||||||
|
*
|
||||||
|
* Core scheduling engine managing task queue lifecycle with state machine,
|
||||||
|
* dependency resolution, session pool management, and concurrency control.
|
||||||
|
*
|
||||||
|
* Integrates with:
|
||||||
|
* - cli-session-manager.ts for PTY session creation and command execution
|
||||||
|
* - websocket.ts for real-time state broadcasts via broadcastToClients
|
||||||
|
* - queue-types.ts for type definitions
|
||||||
|
*
|
||||||
|
* Design decisions:
|
||||||
|
* - In-memory state (no persistence) for simplicity; crash recovery deferred.
|
||||||
|
* - processQueue() selection phase runs synchronously to avoid race conditions
|
||||||
|
* in session allocation; only execution is async.
|
||||||
|
* - Session pool uses 3-tier allocation: resumeKey affinity -> idle reuse -> new creation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CliSessionManager } from './cli-session-manager.js';
|
||||||
|
import type {
|
||||||
|
QueueItem,
|
||||||
|
QueueItemStatus,
|
||||||
|
QueueSchedulerConfig,
|
||||||
|
QueueSchedulerState,
|
||||||
|
QueueSchedulerStatus,
|
||||||
|
QueueWSMessage,
|
||||||
|
SessionBinding,
|
||||||
|
} from '../../types/queue-types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: QueueSchedulerConfig = {
|
||||||
|
maxConcurrentSessions: 2,
|
||||||
|
sessionIdleTimeoutMs: 5 * 60 * 1000, // 5 minutes
|
||||||
|
resumeKeySessionBindingTimeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid state machine transitions.
|
||||||
|
* Key = current status, Value = set of allowed target statuses.
|
||||||
|
*/
|
||||||
|
const VALID_TRANSITIONS: Record<QueueSchedulerStatus, Set<QueueSchedulerStatus>> = {
|
||||||
|
idle: new Set(['running']),
|
||||||
|
running: new Set(['paused', 'stopping']),
|
||||||
|
paused: new Set(['running', 'stopping']),
|
||||||
|
stopping: new Set(['completed', 'failed']),
|
||||||
|
completed: new Set(['idle']),
|
||||||
|
failed: new Set(['idle']),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QueueSchedulerService
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class QueueSchedulerService {
|
||||||
|
private state: QueueSchedulerState;
|
||||||
|
private broadcastFn: (data: unknown) => void;
|
||||||
|
private cliSessionManager: CliSessionManager;
|
||||||
|
|
||||||
|
/** Tracks in-flight execution promises by item_id. */
|
||||||
|
private executingTasks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/** Interval handle for session idle cleanup. */
|
||||||
|
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Guard to prevent re-entrant processQueue calls. */
|
||||||
|
private processingLock = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
broadcastToClients: (data: unknown) => void,
|
||||||
|
cliSessionManager: CliSessionManager,
|
||||||
|
config?: Partial<QueueSchedulerConfig>,
|
||||||
|
) {
|
||||||
|
this.broadcastFn = broadcastToClients;
|
||||||
|
this.cliSessionManager = cliSessionManager;
|
||||||
|
|
||||||
|
const mergedConfig: QueueSchedulerConfig = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
status: 'idle',
|
||||||
|
items: [],
|
||||||
|
sessionPool: {},
|
||||||
|
config: mergedConfig,
|
||||||
|
currentConcurrency: 0,
|
||||||
|
lastActivityAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Public API
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the scheduler with an initial set of items.
|
||||||
|
* Transitions: idle -> running.
|
||||||
|
*/
|
||||||
|
start(items: QueueItem[]): void {
|
||||||
|
this.validateTransition('running');
|
||||||
|
this.state.status = 'running';
|
||||||
|
this.state.error = undefined;
|
||||||
|
this.touchActivity();
|
||||||
|
|
||||||
|
// Resolve initial statuses based on dependency graph
|
||||||
|
for (const item of items) {
|
||||||
|
const resolved = this.resolveInitialStatus(item, items);
|
||||||
|
this.state.items.push({ ...item, status: resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startCleanupInterval();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
|
||||||
|
// Kick off the scheduling loop (non-blocking)
|
||||||
|
void this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the scheduler. Running tasks continue to completion but no new tasks start.
|
||||||
|
* Transitions: running -> paused.
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.validateTransition('paused');
|
||||||
|
this.state.status = 'paused';
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume from paused state.
|
||||||
|
* Transitions: paused -> running.
|
||||||
|
*/
|
||||||
|
resume(): void {
|
||||||
|
this.validateTransition('running');
|
||||||
|
this.state.status = 'running';
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
void this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request graceful stop. Waits for executing tasks to finish.
|
||||||
|
* Transitions: running|paused -> stopping -> completed|failed.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.validateTransition('stopping');
|
||||||
|
this.state.status = 'stopping';
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
|
||||||
|
// Wait for all in-flight executions
|
||||||
|
if (this.executingTasks.size > 0) {
|
||||||
|
await Promise.allSettled(Array.from(this.executingTasks.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine final status
|
||||||
|
const hasFailures = this.state.items.some(i => i.status === 'failed');
|
||||||
|
const finalStatus: QueueSchedulerStatus = hasFailures ? 'failed' : 'completed';
|
||||||
|
this.state.status = finalStatus;
|
||||||
|
|
||||||
|
// Cancel any remaining pending/queued/blocked items
|
||||||
|
for (const item of this.state.items) {
|
||||||
|
if (item.status === 'pending' || item.status === 'queued' || item.status === 'ready' || item.status === 'blocked') {
|
||||||
|
item.status = 'cancelled';
|
||||||
|
item.completedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopCleanupInterval();
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the scheduler back to idle, clearing all items and session pool.
|
||||||
|
* Transitions: completed|failed -> idle.
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.validateTransition('idle');
|
||||||
|
this.state.status = 'idle';
|
||||||
|
this.state.items = [];
|
||||||
|
this.state.sessionPool = {};
|
||||||
|
this.state.currentConcurrency = 0;
|
||||||
|
this.state.error = undefined;
|
||||||
|
this.executingTasks.clear();
|
||||||
|
this.stopCleanupInterval();
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single item to the queue while the scheduler is running.
|
||||||
|
*/
|
||||||
|
addItem(item: QueueItem): void {
|
||||||
|
const resolved = this.resolveInitialStatus(item, this.state.items);
|
||||||
|
const newItem = { ...item, status: resolved };
|
||||||
|
this.state.items.push(newItem);
|
||||||
|
this.touchActivity();
|
||||||
|
|
||||||
|
this.broadcast({
|
||||||
|
type: 'QUEUE_ITEM_ADDED',
|
||||||
|
item: newItem,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger scheduling if running
|
||||||
|
if (this.state.status === 'running') {
|
||||||
|
void this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item from the queue. Only non-executing items can be removed.
|
||||||
|
*/
|
||||||
|
removeItem(itemId: string): boolean {
|
||||||
|
const idx = this.state.items.findIndex(i => i.item_id === itemId);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
|
||||||
|
const item = this.state.items[idx];
|
||||||
|
if (item.status === 'executing') return false;
|
||||||
|
|
||||||
|
this.state.items.splice(idx, 1);
|
||||||
|
this.touchActivity();
|
||||||
|
|
||||||
|
this.broadcast({
|
||||||
|
type: 'QUEUE_ITEM_REMOVED',
|
||||||
|
item_id: itemId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update scheduler configuration at runtime.
|
||||||
|
*/
|
||||||
|
updateConfig(partial: Partial<QueueSchedulerConfig>): void {
|
||||||
|
Object.assign(this.state.config, partial);
|
||||||
|
this.touchActivity();
|
||||||
|
|
||||||
|
this.broadcast({
|
||||||
|
type: 'QUEUE_SCHEDULER_CONFIG_UPDATED',
|
||||||
|
config: { ...this.state.config },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If maxConcurrentSessions increased, try to schedule more
|
||||||
|
if (partial.maxConcurrentSessions !== undefined && this.state.status === 'running') {
|
||||||
|
void this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a snapshot of the current scheduler state.
|
||||||
|
*/
|
||||||
|
getState(): QueueSchedulerState {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
items: this.state.items.map(i => ({ ...i })),
|
||||||
|
sessionPool: { ...this.state.sessionPool },
|
||||||
|
config: { ...this.state.config },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific item by ID.
|
||||||
|
*/
|
||||||
|
getItem(itemId: string): QueueItem | undefined {
|
||||||
|
return this.state.items.find(i => i.item_id === itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Core Scheduling Loop
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main scheduling loop. Resolves dependencies, selects ready tasks,
|
||||||
|
* allocates sessions, and triggers execution.
|
||||||
|
*
|
||||||
|
* The selection phase is synchronous (guarded by processingLock) to prevent
|
||||||
|
* race conditions in session allocation. Only execution is async.
|
||||||
|
*/
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
// Guard: prevent re-entrant calls
|
||||||
|
if (this.processingLock) return;
|
||||||
|
this.processingLock = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this.state.status === 'running') {
|
||||||
|
// Step 1: Check preconditions
|
||||||
|
if (this.state.currentConcurrency >= this.state.config.maxConcurrentSessions) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Resolve blocked items whose dependencies are now completed
|
||||||
|
this.resolveDependencies();
|
||||||
|
|
||||||
|
// Step 3: Select next task to execute
|
||||||
|
const candidate = this.selectNextTask();
|
||||||
|
if (!candidate) {
|
||||||
|
// Check if everything is done
|
||||||
|
this.checkCompletion();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Allocate a session
|
||||||
|
const sessionKey = this.allocateSession(candidate);
|
||||||
|
if (!sessionKey) {
|
||||||
|
// Could not allocate a session (all slots busy)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Mark as executing and launch
|
||||||
|
candidate.status = 'executing';
|
||||||
|
candidate.sessionKey = sessionKey;
|
||||||
|
candidate.startedAt = new Date().toISOString();
|
||||||
|
this.state.currentConcurrency++;
|
||||||
|
this.touchActivity();
|
||||||
|
|
||||||
|
this.broadcastItemUpdate(candidate);
|
||||||
|
|
||||||
|
// Step 6: Execute asynchronously
|
||||||
|
const execPromise = this.executeTask(candidate, sessionKey);
|
||||||
|
this.executingTasks.set(candidate.item_id, execPromise);
|
||||||
|
|
||||||
|
// Chain cleanup and re-trigger
|
||||||
|
void execPromise.then(() => {
|
||||||
|
this.executingTasks.delete(candidate.item_id);
|
||||||
|
// Re-trigger scheduling on completion
|
||||||
|
if (this.state.status === 'running') {
|
||||||
|
void this.processQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processingLock = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve blocked items whose depends_on are all completed.
|
||||||
|
*/
|
||||||
|
private resolveDependencies(): void {
|
||||||
|
const completedIds = new Set(
|
||||||
|
this.state.items
|
||||||
|
.filter(i => i.status === 'completed')
|
||||||
|
.map(i => i.item_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of this.state.items) {
|
||||||
|
if (item.status !== 'blocked' && item.status !== 'pending') continue;
|
||||||
|
|
||||||
|
if (item.depends_on.length === 0) {
|
||||||
|
if (item.status === 'pending') {
|
||||||
|
item.status = 'queued';
|
||||||
|
this.broadcastItemUpdate(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any dependency failed
|
||||||
|
const anyDepFailed = item.depends_on.some(depId => {
|
||||||
|
const dep = this.state.items.find(i => i.item_id === depId);
|
||||||
|
return dep && (dep.status === 'failed' || dep.status === 'cancelled');
|
||||||
|
});
|
||||||
|
if (anyDepFailed) {
|
||||||
|
item.status = 'cancelled';
|
||||||
|
item.completedAt = new Date().toISOString();
|
||||||
|
item.error = 'Dependency failed or was cancelled';
|
||||||
|
this.broadcastItemUpdate(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDepsComplete = item.depends_on.every(depId => completedIds.has(depId));
|
||||||
|
if (allDepsComplete) {
|
||||||
|
item.status = 'queued';
|
||||||
|
this.broadcastItemUpdate(item);
|
||||||
|
} else if (item.status === 'pending') {
|
||||||
|
item.status = 'blocked';
|
||||||
|
this.broadcastItemUpdate(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the next queued task by execution_order, then createdAt.
|
||||||
|
*/
|
||||||
|
private selectNextTask(): QueueItem | undefined {
|
||||||
|
const queued = this.state.items.filter(i => i.status === 'queued');
|
||||||
|
if (queued.length === 0) return undefined;
|
||||||
|
|
||||||
|
queued.sort((a, b) => {
|
||||||
|
if (a.execution_order !== b.execution_order) {
|
||||||
|
return a.execution_order - b.execution_order;
|
||||||
|
}
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return queued[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Session Pool Management
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-tier session allocation strategy:
|
||||||
|
* 1. ResumeKey affinity: if the item has a resumeKey and we have a bound session, reuse it.
|
||||||
|
* 2. Idle session reuse: find any session in the pool not currently executing.
|
||||||
|
* 3. New session creation: create a new session via CliSessionManager if under the limit.
|
||||||
|
*
|
||||||
|
* Returns sessionKey or null if no session available.
|
||||||
|
*/
|
||||||
|
private allocateSession(item: QueueItem): string | null {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Tier 1: ResumeKey affinity
|
||||||
|
if (item.resumeKey) {
|
||||||
|
const binding = this.state.sessionPool[item.resumeKey];
|
||||||
|
if (binding) {
|
||||||
|
const bindingAge = now.getTime() - new Date(binding.lastUsed).getTime();
|
||||||
|
if (bindingAge < this.state.config.resumeKeySessionBindingTimeoutMs) {
|
||||||
|
// Verify the session still exists in CliSessionManager
|
||||||
|
if (this.cliSessionManager.hasSession(binding.sessionKey)) {
|
||||||
|
binding.lastUsed = now.toISOString();
|
||||||
|
return binding.sessionKey;
|
||||||
|
}
|
||||||
|
// Session gone, remove stale binding
|
||||||
|
delete this.state.sessionPool[item.resumeKey];
|
||||||
|
} else {
|
||||||
|
// Binding expired
|
||||||
|
delete this.state.sessionPool[item.resumeKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Idle session reuse
|
||||||
|
const executingSessionKeys = new Set(
|
||||||
|
this.state.items
|
||||||
|
.filter(i => i.status === 'executing' && i.sessionKey)
|
||||||
|
.map(i => i.sessionKey!),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [resumeKey, binding] of Object.entries(this.state.sessionPool)) {
|
||||||
|
if (!executingSessionKeys.has(binding.sessionKey)) {
|
||||||
|
// This session is idle in the pool
|
||||||
|
if (this.cliSessionManager.hasSession(binding.sessionKey)) {
|
||||||
|
binding.lastUsed = now.toISOString();
|
||||||
|
// Rebind to new resumeKey if different
|
||||||
|
if (item.resumeKey && item.resumeKey !== resumeKey) {
|
||||||
|
this.state.sessionPool[item.resumeKey] = binding;
|
||||||
|
}
|
||||||
|
return binding.sessionKey;
|
||||||
|
}
|
||||||
|
// Stale session, clean up
|
||||||
|
delete this.state.sessionPool[resumeKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: New session creation
|
||||||
|
const activeSessions = this.cliSessionManager.listSessions();
|
||||||
|
// Count sessions managed by our pool (not all sessions globally)
|
||||||
|
const poolSessionKeys = new Set(
|
||||||
|
Object.values(this.state.sessionPool).map(b => b.sessionKey),
|
||||||
|
);
|
||||||
|
const ourActiveCount = activeSessions.filter(s => poolSessionKeys.has(s.sessionKey)).length;
|
||||||
|
|
||||||
|
if (ourActiveCount < this.state.config.maxConcurrentSessions) {
|
||||||
|
try {
|
||||||
|
const newSession = this.cliSessionManager.createSession({
|
||||||
|
workingDir: this.cliSessionManager.getProjectRoot(),
|
||||||
|
tool: item.tool,
|
||||||
|
resumeKey: item.resumeKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const binding: SessionBinding = {
|
||||||
|
sessionKey: newSession.sessionKey,
|
||||||
|
lastUsed: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bind to resumeKey if available, otherwise use item_id as key
|
||||||
|
const poolKey = item.resumeKey || item.item_id;
|
||||||
|
this.state.sessionPool[poolKey] = binding;
|
||||||
|
|
||||||
|
return newSession.sessionKey;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[QueueScheduler] Failed to create session:', (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a session back to the pool after task completion.
|
||||||
|
*/
|
||||||
|
private releaseSession(item: QueueItem): void {
|
||||||
|
if (!item.sessionKey) return;
|
||||||
|
|
||||||
|
// Update the binding's lastUsed timestamp
|
||||||
|
const poolKey = item.resumeKey || item.item_id;
|
||||||
|
const binding = this.state.sessionPool[poolKey];
|
||||||
|
if (binding && binding.sessionKey === item.sessionKey) {
|
||||||
|
binding.lastUsed = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Task Execution
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single queue item via CliSessionManager.
|
||||||
|
*/
|
||||||
|
private async executeTask(item: QueueItem, sessionKey: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.cliSessionManager.execute(sessionKey, {
|
||||||
|
tool: item.tool,
|
||||||
|
prompt: item.prompt,
|
||||||
|
mode: item.mode,
|
||||||
|
resumeKey: item.resumeKey,
|
||||||
|
resumeStrategy: item.resumeStrategy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as completed (fire-and-forget execution model for PTY sessions)
|
||||||
|
// The actual CLI execution is async in the PTY; we mark completion
|
||||||
|
// after the command is sent. Real completion tracking requires
|
||||||
|
// hook callbacks or output parsing (future enhancement).
|
||||||
|
item.status = 'completed';
|
||||||
|
item.completedAt = new Date().toISOString();
|
||||||
|
} catch (err) {
|
||||||
|
item.status = 'failed';
|
||||||
|
item.completedAt = new Date().toISOString();
|
||||||
|
item.error = (err as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update concurrency and release session
|
||||||
|
this.state.currentConcurrency = Math.max(0, this.state.currentConcurrency - 1);
|
||||||
|
this.releaseSession(item);
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastItemUpdate(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// State Machine
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the requested transition is allowed.
|
||||||
|
* Throws if the transition is invalid.
|
||||||
|
*/
|
||||||
|
private validateTransition(target: QueueSchedulerStatus): void {
|
||||||
|
const allowed = VALID_TRANSITIONS[this.state.status];
|
||||||
|
if (!allowed || !allowed.has(target)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid state transition: ${this.state.status} -> ${target}. ` +
|
||||||
|
`Allowed transitions from '${this.state.status}': [${Array.from(allowed || []).join(', ')}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine initial status for an item based on its dependencies.
|
||||||
|
*/
|
||||||
|
private resolveInitialStatus(item: QueueItem, allItems: QueueItem[]): QueueItemStatus {
|
||||||
|
if (item.depends_on.length === 0) {
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
// Check if all dependencies are already completed
|
||||||
|
const completedIds = new Set(
|
||||||
|
allItems.filter(i => i.status === 'completed').map(i => i.item_id),
|
||||||
|
);
|
||||||
|
const allResolved = item.depends_on.every(id => completedIds.has(id));
|
||||||
|
return allResolved ? 'queued' : 'blocked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all items are in a terminal state, and transition scheduler accordingly.
|
||||||
|
*/
|
||||||
|
private checkCompletion(): void {
|
||||||
|
if (this.state.status !== 'running') return;
|
||||||
|
if (this.executingTasks.size > 0) return;
|
||||||
|
|
||||||
|
const allTerminal = this.state.items.every(
|
||||||
|
i => i.status === 'completed' || i.status === 'failed' || i.status === 'cancelled',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allTerminal) return;
|
||||||
|
|
||||||
|
const hasFailures = this.state.items.some(i => i.status === 'failed');
|
||||||
|
// Transition through stopping to final state
|
||||||
|
this.state.status = 'stopping';
|
||||||
|
this.state.status = hasFailures ? 'failed' : 'completed';
|
||||||
|
this.stopCleanupInterval();
|
||||||
|
this.touchActivity();
|
||||||
|
this.broadcastStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Session Cleanup
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic cleanup of idle sessions from the pool.
|
||||||
|
*/
|
||||||
|
private startCleanupInterval(): void {
|
||||||
|
this.stopCleanupInterval();
|
||||||
|
this.cleanupTimer = setInterval(() => {
|
||||||
|
this.cleanupIdleSessions();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
// Prevent the timer from keeping the process alive
|
||||||
|
if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
|
||||||
|
this.cleanupTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopCleanupInterval(): void {
|
||||||
|
if (this.cleanupTimer !== null) {
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
this.cleanupTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove sessions from the pool that have been idle beyond the timeout.
|
||||||
|
*/
|
||||||
|
private cleanupIdleSessions(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeoutMs = this.state.config.sessionIdleTimeoutMs;
|
||||||
|
|
||||||
|
const executingSessionKeys = new Set(
|
||||||
|
this.state.items
|
||||||
|
.filter(i => i.status === 'executing' && i.sessionKey)
|
||||||
|
.map(i => i.sessionKey!),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, binding] of Object.entries(this.state.sessionPool)) {
|
||||||
|
// Skip sessions currently in use
|
||||||
|
if (executingSessionKeys.has(binding.sessionKey)) continue;
|
||||||
|
|
||||||
|
const idleMs = now - new Date(binding.lastUsed).getTime();
|
||||||
|
if (idleMs >= timeoutMs) {
|
||||||
|
// Close the session in CliSessionManager
|
||||||
|
try {
|
||||||
|
this.cliSessionManager.close(binding.sessionKey);
|
||||||
|
} catch {
|
||||||
|
// Session may already be gone
|
||||||
|
}
|
||||||
|
delete this.state.sessionPool[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Broadcasting
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private broadcast(message: QueueWSMessage): void {
|
||||||
|
try {
|
||||||
|
this.broadcastFn(message);
|
||||||
|
} catch {
|
||||||
|
// Ignore broadcast errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastStateUpdate(): void {
|
||||||
|
this.broadcast({
|
||||||
|
type: 'QUEUE_SCHEDULER_STATE_UPDATE',
|
||||||
|
state: this.getState(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastItemUpdate(item: QueueItem): void {
|
||||||
|
this.broadcast({
|
||||||
|
type: 'QUEUE_ITEM_UPDATED',
|
||||||
|
item: { ...item },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Utilities
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private touchActivity(): void {
|
||||||
|
this.state.lastActivityAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,15 @@ import type { IncomingMessage } from 'http';
|
|||||||
import type { Duplex } from 'stream';
|
import type { Duplex } from 'stream';
|
||||||
import { a2uiWebSocketHandler, handleA2UIMessage } from './a2ui/A2UIWebSocketHandler.js';
|
import { a2uiWebSocketHandler, handleA2UIMessage } from './a2ui/A2UIWebSocketHandler.js';
|
||||||
import { handleAnswer } from '../tools/ask-question.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
|
// WebSocket clients for real-time notifications
|
||||||
export const wsClients = new Set<Duplex>();
|
export const wsClients = new Set<Duplex>();
|
||||||
@@ -622,3 +631,53 @@ export function broadcastCoordinatorLog(
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export Queue WebSocket types from queue-types.ts
|
||||||
|
export type {
|
||||||
|
QueueWSMessageType as QueueMessageType,
|
||||||
|
QueueSchedulerStateUpdateMessage,
|
||||||
|
QueueItemAddedMessage,
|
||||||
|
QueueItemUpdatedMessage,
|
||||||
|
QueueItemRemovedMessage,
|
||||||
|
QueueSchedulerConfigUpdatedMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for Queue messages (without timestamp - added automatically)
|
||||||
|
*/
|
||||||
|
export type QueueMessage =
|
||||||
|
| Omit<QueueSchedulerStateUpdateMessage, 'timestamp'>
|
||||||
|
| Omit<QueueItemAddedMessage, 'timestamp'>
|
||||||
|
| Omit<QueueItemUpdatedMessage, 'timestamp'>
|
||||||
|
| Omit<QueueItemRemovedMessage, 'timestamp'>
|
||||||
|
| Omit<QueueSchedulerConfigUpdatedMessage, 'timestamp'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue-specific broadcast with throttling
|
||||||
|
* Throttles QUEUE_SCHEDULER_STATE_UPDATE messages to avoid flooding clients
|
||||||
|
*/
|
||||||
|
let lastQueueBroadcast = 0;
|
||||||
|
const QUEUE_BROADCAST_THROTTLE = 1000; // 1 second
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast queue update with throttling
|
||||||
|
* STATE_UPDATE messages are throttled to 1 per second
|
||||||
|
* Other message types are sent immediately
|
||||||
|
*/
|
||||||
|
export function broadcastQueueUpdate(message: QueueMessage): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Throttle QUEUE_SCHEDULER_STATE_UPDATE to reduce WebSocket traffic
|
||||||
|
if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE' && now - lastQueueBroadcast < QUEUE_BROADCAST_THROTTLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE') {
|
||||||
|
lastQueueBroadcast = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToClients({
|
||||||
|
...message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -390,9 +390,13 @@ export function generateTransactionId(conversationId: string): TransactionId {
|
|||||||
* Inject transaction ID into user prompt
|
* Inject transaction ID into user prompt
|
||||||
* @param prompt - Original user prompt
|
* @param prompt - Original user prompt
|
||||||
* @param txId - Transaction ID to inject
|
* @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 {
|
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}`;
|
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
|
// Inject transaction ID at the start of the final prompt for session tracking
|
||||||
// This enables exact session matching during parallel execution scenarios
|
// This enables exact session matching during parallel execution scenarios
|
||||||
|
// 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);
|
finalPrompt = injectTransactionId(finalPrompt, transactionId);
|
||||||
debugLog('TX_ID', `Injected transaction ID into prompt`, { transactionId, promptLength: finalPrompt.length });
|
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
|
// Check tool availability
|
||||||
const toolStatus = await checkToolAvailability(tool);
|
const toolStatus = await checkToolAvailability(tool);
|
||||||
|
|||||||
@@ -502,9 +502,11 @@ export class CliHistoryStore {
|
|||||||
* Save or update a conversation
|
* Save or update a conversation
|
||||||
*/
|
*/
|
||||||
saveConversation(conversation: ConversationRecord): void {
|
saveConversation(conversation: ConversationRecord): void {
|
||||||
const promptPreview = conversation.turns.length > 0
|
// Ensure prompt is a string before calling substring
|
||||||
? conversation.turns[conversation.turns.length - 1].prompt.substring(0, 100)
|
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(`
|
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)
|
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 => ({
|
turns: turns.map(t => ({
|
||||||
turn: t.turn_number,
|
turn: t.turn_number,
|
||||||
timestamp: t.timestamp,
|
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,
|
duration_ms: t.duration_ms,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
exit_code: t.exit_code,
|
exit_code: t.exit_code,
|
||||||
@@ -840,7 +843,10 @@ export class CliHistoryStore {
|
|||||||
category: r.category || 'user',
|
category: r.category || 'user',
|
||||||
duration_ms: r.total_duration_ms,
|
duration_ms: r.total_duration_ms,
|
||||||
turn_count: r.turn_count,
|
turn_count: r.turn_count,
|
||||||
prompt_preview: r.prompt_preview || ''
|
// Ensure prompt_preview is always a string (handle legacy object data)
|
||||||
|
prompt_preview: typeof r.prompt_preview === 'string'
|
||||||
|
? r.prompt_preview
|
||||||
|
: (r.prompt_preview ? JSON.stringify(r.prompt_preview) : '')
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1197,3 +1197,30 @@ export function getToolSessionPath(tool: string): string | null {
|
|||||||
const discoverer = discoverers[tool];
|
const discoverer = discoverers[tool];
|
||||||
return discoverer?.basePath || null;
|
return discoverer?.basePath || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all native sessions from all supported CLI tools
|
||||||
|
* Aggregates sessions from Gemini, Qwen, Codex, Claude, and OpenCode
|
||||||
|
* @param options - Optional filtering (workingDir, limit, afterTimestamp)
|
||||||
|
* @returns Combined sessions sorted by updatedAt descending
|
||||||
|
*/
|
||||||
|
export function listAllNativeSessions(options?: SessionDiscoveryOptions): NativeSession[] {
|
||||||
|
const allSessions: NativeSession[] = [];
|
||||||
|
|
||||||
|
// Collect sessions from all discoverers
|
||||||
|
for (const tool of Object.keys(discoverers)) {
|
||||||
|
const discoverer = discoverers[tool];
|
||||||
|
const sessions = discoverer.getSessions(options);
|
||||||
|
allSessions.push(...sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by updatedAt descending
|
||||||
|
allSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||||
|
|
||||||
|
// Apply limit if provided
|
||||||
|
if (options?.limit) {
|
||||||
|
return allSessions.slice(0, options.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSessions;
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,13 +236,18 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
|
|||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
|
|
||||||
for (const msg of session.messages) {
|
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') {
|
if (msg.type === 'user') {
|
||||||
turnNumber++;
|
turnNumber++;
|
||||||
turns.push({
|
turns.push({
|
||||||
turnNumber,
|
turnNumber,
|
||||||
timestamp: msg.timestamp,
|
timestamp: msg.timestamp,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: msg.content
|
content: contentStr
|
||||||
});
|
});
|
||||||
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
|
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
|
||||||
// Find the corresponding user turn
|
// Find the corresponding user turn
|
||||||
@@ -255,7 +260,7 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
|
|||||||
turnNumber,
|
turnNumber,
|
||||||
timestamp: msg.timestamp,
|
timestamp: msg.timestamp,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: msg.content,
|
content: contentStr,
|
||||||
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
||||||
tokens: msg.tokens ? {
|
tokens: msg.tokens ? {
|
||||||
input: msg.tokens.input,
|
input: msg.tokens.input,
|
||||||
@@ -428,7 +433,11 @@ function parseCodexSession(content: string): ParsedSession {
|
|||||||
currentTurn++;
|
currentTurn++;
|
||||||
const textContent = item.payload.content
|
const textContent = item.payload.content
|
||||||
?.filter(c => c.type === 'input_text')
|
?.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') || '';
|
.join('\n') || '';
|
||||||
|
|
||||||
turns.push({
|
turns.push({
|
||||||
@@ -461,7 +470,11 @@ function parseCodexSession(content: string): ParsedSession {
|
|||||||
// Assistant message (final response)
|
// Assistant message (final response)
|
||||||
const textContent = item.payload.content
|
const textContent = item.payload.content
|
||||||
?.filter(c => c.type === 'output_text' || c.type === 'text')
|
?.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') || '';
|
.join('\n') || '';
|
||||||
|
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function createDefaultSettings(provider: CliProvider = 'claude'): CliSett
|
|||||||
env: {
|
env: {
|
||||||
DISABLE_AUTOUPDATER: '1'
|
DISABLE_AUTOUPDATER: '1'
|
||||||
},
|
},
|
||||||
model: 'sonnet',
|
model: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
availableModels: []
|
availableModels: []
|
||||||
} satisfies ClaudeCliSettings;
|
} satisfies ClaudeCliSettings;
|
||||||
|
|||||||
209
ccw/src/types/queue-types.ts
Normal file
209
ccw/src/types/queue-types.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Queue Scheduler Type Definitions
|
||||||
|
* TypeScript types for queue scheduling, dependency resolution, and session management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CliSessionResumeStrategy } from '../core/services/cli-session-command-builder.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a single queue item through its lifecycle.
|
||||||
|
*
|
||||||
|
* Transitions:
|
||||||
|
* pending -> queued -> ready -> executing -> completed | failed
|
||||||
|
* pending -> blocked (has unresolved depends_on)
|
||||||
|
* blocked -> queued (all depends_on completed)
|
||||||
|
* any -> cancelled (user cancellation)
|
||||||
|
*/
|
||||||
|
export type QueueItemStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'queued'
|
||||||
|
| 'ready'
|
||||||
|
| 'executing'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'blocked'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of the scheduler state machine.
|
||||||
|
*
|
||||||
|
* Transitions:
|
||||||
|
* idle -> running (start)
|
||||||
|
* running -> paused (pause)
|
||||||
|
* running -> stopping (stop requested, waiting for executing tasks)
|
||||||
|
* paused -> running (resume)
|
||||||
|
* stopping -> completed (all executing tasks finished successfully)
|
||||||
|
* stopping -> failed (executing task failure during stop)
|
||||||
|
*/
|
||||||
|
export type QueueSchedulerStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'running'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopping'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the queue scheduler.
|
||||||
|
*/
|
||||||
|
export interface QueueSchedulerConfig {
|
||||||
|
/** Maximum number of concurrent CLI sessions executing tasks. */
|
||||||
|
maxConcurrentSessions: number;
|
||||||
|
/** Idle timeout (ms) before releasing a session from the pool. */
|
||||||
|
sessionIdleTimeoutMs: number;
|
||||||
|
/** Timeout (ms) for resumeKey-to-session binding affinity. */
|
||||||
|
resumeKeySessionBindingTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Entities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single task item in the execution queue.
|
||||||
|
*/
|
||||||
|
export interface QueueItem {
|
||||||
|
/** Unique identifier for this queue item. */
|
||||||
|
item_id: string;
|
||||||
|
/** Reference to the parent issue (if applicable). */
|
||||||
|
issue_id?: string;
|
||||||
|
/** Current status of the item. */
|
||||||
|
status: QueueItemStatus;
|
||||||
|
/** CLI tool to use for execution (e.g., 'gemini', 'claude'). */
|
||||||
|
tool: string;
|
||||||
|
/** Prompt/instruction to send to the CLI tool. */
|
||||||
|
prompt: string;
|
||||||
|
/** Execution mode. */
|
||||||
|
mode?: 'analysis' | 'write' | 'auto';
|
||||||
|
/** Resume key for session affinity and conversation continuity. */
|
||||||
|
resumeKey?: string;
|
||||||
|
/** Strategy for resuming a previous CLI session. */
|
||||||
|
resumeStrategy?: CliSessionResumeStrategy;
|
||||||
|
/** Item IDs that must complete before this item can execute. */
|
||||||
|
depends_on: string[];
|
||||||
|
/** Numeric order for scheduling priority within a group. Lower = earlier. */
|
||||||
|
execution_order: number;
|
||||||
|
/** Logical grouping for related items (e.g., same issue). */
|
||||||
|
execution_group?: string;
|
||||||
|
/** Session key assigned when executing. */
|
||||||
|
sessionKey?: string;
|
||||||
|
/** Timestamp when item was added to the queue. */
|
||||||
|
createdAt: string;
|
||||||
|
/** Timestamp when execution started. */
|
||||||
|
startedAt?: string;
|
||||||
|
/** Timestamp when execution completed (success or failure). */
|
||||||
|
completedAt?: string;
|
||||||
|
/** Error message if status is 'failed'. */
|
||||||
|
error?: string;
|
||||||
|
/** Output from CLI execution. */
|
||||||
|
output?: string;
|
||||||
|
/** Arbitrary metadata for extensibility. */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks a session bound to a resumeKey for affinity-based allocation.
|
||||||
|
*/
|
||||||
|
export interface SessionBinding {
|
||||||
|
/** The CLI session key from CliSessionManager. */
|
||||||
|
sessionKey: string;
|
||||||
|
/** Timestamp of last activity on this binding. */
|
||||||
|
lastUsed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete snapshot of the scheduler state, used for WS broadcast and API responses.
|
||||||
|
*/
|
||||||
|
export interface QueueSchedulerState {
|
||||||
|
/** Current scheduler status. */
|
||||||
|
status: QueueSchedulerStatus;
|
||||||
|
/** All items in the queue (pending, executing, completed, etc.). */
|
||||||
|
items: QueueItem[];
|
||||||
|
/** Session pool: resumeKey -> SessionBinding. */
|
||||||
|
sessionPool: Record<string, SessionBinding>;
|
||||||
|
/** Active scheduler configuration. */
|
||||||
|
config: QueueSchedulerConfig;
|
||||||
|
/** Number of currently executing tasks. */
|
||||||
|
currentConcurrency: number;
|
||||||
|
/** Timestamp of last scheduler activity. */
|
||||||
|
lastActivityAt: string;
|
||||||
|
/** Error message if scheduler status is 'failed'. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket Message Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminator values for queue-related WebSocket messages.
|
||||||
|
*/
|
||||||
|
export type QueueWSMessageType =
|
||||||
|
| 'QUEUE_SCHEDULER_STATE_UPDATE'
|
||||||
|
| 'QUEUE_ITEM_ADDED'
|
||||||
|
| 'QUEUE_ITEM_UPDATED'
|
||||||
|
| 'QUEUE_ITEM_REMOVED'
|
||||||
|
| 'QUEUE_SCHEDULER_CONFIG_UPDATED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full scheduler state broadcast (sent on start/pause/stop/complete).
|
||||||
|
*/
|
||||||
|
export interface QueueSchedulerStateUpdateMessage {
|
||||||
|
type: 'QUEUE_SCHEDULER_STATE_UPDATE';
|
||||||
|
state: QueueSchedulerState;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast when a new item is added to the queue.
|
||||||
|
*/
|
||||||
|
export interface QueueItemAddedMessage {
|
||||||
|
type: 'QUEUE_ITEM_ADDED';
|
||||||
|
item: QueueItem;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast when an item's status or data changes.
|
||||||
|
*/
|
||||||
|
export interface QueueItemUpdatedMessage {
|
||||||
|
type: 'QUEUE_ITEM_UPDATED';
|
||||||
|
item: QueueItem;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast when an item is removed from the queue.
|
||||||
|
*/
|
||||||
|
export interface QueueItemRemovedMessage {
|
||||||
|
type: 'QUEUE_ITEM_REMOVED';
|
||||||
|
item_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast when scheduler configuration is updated.
|
||||||
|
*/
|
||||||
|
export interface QueueSchedulerConfigUpdatedMessage {
|
||||||
|
type: 'QUEUE_SCHEDULER_CONFIG_UPDATED';
|
||||||
|
config: QueueSchedulerConfig;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all queue WebSocket messages.
|
||||||
|
*/
|
||||||
|
export type QueueWSMessage =
|
||||||
|
| QueueSchedulerStateUpdateMessage
|
||||||
|
| QueueItemAddedMessage
|
||||||
|
| QueueItemUpdatedMessage
|
||||||
|
| QueueItemRemovedMessage
|
||||||
|
| QueueSchedulerConfigUpdatedMessage;
|
||||||
Reference in New Issue
Block a user