From f8ff9eaa7fb4273db39581e6bdad0d948274ebf5 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Fri, 20 Feb 2026 21:49:05 +0800 Subject: [PATCH] feat(orchestrator): redesign orchestrator page as template editor with terminal execution Phase 1: Orchestrator Simplification - Remove ExecutionMonitor from OrchestratorPage - Replace "Run Workflow" button with "Send to Terminal" button - Update i18n texts for template editor context Phase 2: Session Lock Mechanism - Add 'locked' status to TerminalStatus type - Extend TerminalMeta with isLocked, lockReason, lockedByExecutionId, lockedAt - Implement lockSession/unlockSession in sessionManagerStore - Create SessionLockConfirmDialog component for input interception Phase 3: Execution Monitor Panel - Create executionMonitorStore for execution state management - Create ExecutionMonitorPanel component with step progress display - Add execution panel to DashboardToolbar and TerminalDashboardPage - Support WebSocket message handling for execution updates Phase 4: Execution Bridge - Add POST /api/orchestrator/flows/:id/execute-in-session endpoint - Create useExecuteFlowInSession hook for frontend API calls - Broadcast EXECUTION_STARTED and CLI_SESSION_LOCKED WebSocket messages - Lock session when execution starts, unlock on completion --- .../terminal-dashboard/DashboardToolbar.tsx | 14 +- .../ExecutionMonitorPanel.tsx | 284 +++++++++++++++++ .../terminal-dashboard/SessionGroupTree.tsx | 262 +++++++--------- .../SessionLockConfirmDialog.tsx | 121 ++++++++ .../src/hooks/useOrchestratorExecution.ts | 154 +++++++++ ccw/frontend/src/locales/en/orchestrator.json | 10 +- ccw/frontend/src/locales/zh/orchestrator.json | 10 +- .../src/pages/TerminalDashboardPage.tsx | 11 + .../src/pages/orchestrator/FlowToolbar.tsx | 79 ++--- .../pages/orchestrator/OrchestratorPage.tsx | 11 +- .../src/stores/executionMonitorStore.ts | 291 ++++++++++++++++++ ccw/frontend/src/types/terminal-dashboard.ts | 2 + ccw/src/core/routes/orchestrator-routes.ts | 141 ++++++++- 13 files changed, 1156 insertions(+), 234 deletions(-) create mode 100644 ccw/frontend/src/components/terminal-dashboard/ExecutionMonitorPanel.tsx create mode 100644 ccw/frontend/src/components/terminal-dashboard/SessionLockConfirmDialog.tsx create mode 100644 ccw/frontend/src/hooks/useOrchestratorExecution.ts create mode 100644 ccw/frontend/src/stores/executionMonitorStore.ts diff --git a/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx b/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx index b33999e3..089c854c 100644 --- a/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx @@ -34,6 +34,7 @@ import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/ import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { toast } from '@/stores/notificationStore'; import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore'; +import { useSessionManagerStore } from '@/stores/sessionManagerStore'; import { CliConfigModal, type CliSessionConfig } from './CliConfigModal'; // ========== Types ========== @@ -106,6 +107,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen const projectPath = useWorkflowStore(selectProjectPath); const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId); const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign); + const updateTerminalMeta = useSessionManagerStore((s) => s.updateTerminalMeta); const [isCreating, setIsCreating] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false); @@ -131,7 +133,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen const targetPaneId = getOrCreateFocusedPane(); if (!targetPaneId) throw new Error('Failed to create pane'); - await createSessionAndAssign( + const result = await createSessionAndAssign( targetPaneId, { workingDir: config.workingDir || projectPath, @@ -142,6 +144,14 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen }, projectPath ); + + // Store tag in terminalMetas for grouping + if (result?.session?.sessionKey) { + updateTerminalMeta(result.session.sessionKey, { + tag: config.tag, + title: config.tag, + }); + } } catch (error: unknown) { const message = error instanceof Error ? error.message @@ -153,7 +163,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen } finally { setIsCreating(false); } - }, [projectPath, createSessionAndAssign, getOrCreateFocusedPane]); + }, [projectPath, createSessionAndAssign, getOrCreateFocusedPane, updateTerminalMeta]); return ( <> diff --git a/ccw/frontend/src/components/terminal-dashboard/ExecutionMonitorPanel.tsx b/ccw/frontend/src/components/terminal-dashboard/ExecutionMonitorPanel.tsx new file mode 100644 index 00000000..e25bd1bf --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/ExecutionMonitorPanel.tsx @@ -0,0 +1,284 @@ +// ======================================== +// Execution Monitor Panel +// ======================================== +// Panel for monitoring workflow executions in Terminal Dashboard. +// Displays execution progress, step list, and control buttons. + +import { useIntl } from 'react-intl'; +import { + Play, + Pause, + Square, + CheckCircle2, + XCircle, + Circle, + Loader2, + Clock, + Terminal, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { Progress } from '@/components/ui/Progress'; +import { + useExecutionMonitorStore, + selectCurrentExecution, + selectActiveExecutions, +} from '@/stores/executionMonitorStore'; +import type { ExecutionStatus, StepInfo } from '@/stores/executionMonitorStore'; + +// ========== Status Config ========== + +const statusConfig: Record = { + pending: { label: 'Pending', color: 'text-muted-foreground', bgColor: 'bg-muted' }, + running: { label: 'Running', color: 'text-primary', bgColor: 'bg-primary/10' }, + paused: { label: 'Paused', color: 'text-amber-500', bgColor: 'bg-amber-500/10' }, + completed: { label: 'Completed', color: 'text-green-500', bgColor: 'bg-green-500/10' }, + failed: { label: 'Failed', color: 'text-destructive', bgColor: 'bg-destructive/10' }, + cancelled: { label: 'Cancelled', color: 'text-muted-foreground', bgColor: 'bg-muted' }, +}; + +// ========== Step Status Icon ========== + +function StepStatusIcon({ status }: { status: ExecutionStatus }) { + switch (status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'paused': + return ; + case 'cancelled': + return ; + default: + return ; + } +} + +// ========== Step List Item ========== + +interface StepListItemProps { + step: StepInfo; + isCurrent: boolean; +} + +function StepListItem({ step, isCurrent }: StepListItemProps) { + return ( +
+
+ +
+
+ + {step.name} + + {step.error && ( +

{step.error}

+ )} +
+
+ ); +} + +// ========== Main Component ========== + +export function ExecutionMonitorPanel() { + const { formatMessage } = useIntl(); + + const currentExecution = useExecutionMonitorStore(selectCurrentExecution); + const activeExecutions = useExecutionMonitorStore(selectActiveExecutions); + const selectExecution = useExecutionMonitorStore((s) => s.selectExecution); + const pauseExecution = useExecutionMonitorStore((s) => s.pauseExecution); + const resumeExecution = useExecutionMonitorStore((s) => s.resumeExecution); + const stopExecution = useExecutionMonitorStore((s) => s.stopExecution); + const clearExecution = useExecutionMonitorStore((s) => s.clearExecution); + + const executions = Object.values(activeExecutions); + const hasExecutions = executions.length > 0; + + if (!hasExecutions) { + return ( +
+ +

+ {formatMessage({ id: 'executionMonitor.noExecutions', defaultMessage: 'No active executions' })} +

+

+ {formatMessage({ id: 'executionMonitor.sendToTerminal', defaultMessage: 'Send a workflow to terminal to start' })} +

+
+ ); + } + + return ( +
+ {/* Execution selector (if multiple) */} + {executions.length > 1 && ( +
+
+ {executions.map((exec) => ( + + ))} +
+
+ )} + + {currentExecution && ( + <> + {/* Header */} +
+
+

+ {currentExecution.flowName} +

+ + {statusConfig[currentExecution.status].label} + +
+ + {/* Progress */} +
+
+ + {formatMessage( + { id: 'executionMonitor.progress', defaultMessage: '{completed}/{total} steps' }, + { completed: currentExecution.completedSteps, total: currentExecution.totalSteps || currentExecution.steps.length } + )} + + + {Math.round( + (currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100 + )}% + +
+ +
+ + {/* Meta info */} +
+ + + {new Date(currentExecution.startedAt).toLocaleTimeString()} + + + + {currentExecution.sessionKey.slice(0, 20)}... + +
+
+ + {/* Step list */} +
+ {currentExecution.steps.map((step) => ( + + ))} +
+ + {/* Control bar */} +
+ {currentExecution.status === 'running' && ( + <> + + + + )} + + {currentExecution.status === 'paused' && ( + <> + + + + )} + + {(currentExecution.status === 'completed' || + currentExecution.status === 'failed' || + currentExecution.status === 'cancelled') && ( + + )} +
+ + )} +
+ ); +} + +export default ExecutionMonitorPanel; diff --git a/ccw/frontend/src/components/terminal-dashboard/SessionGroupTree.tsx b/ccw/frontend/src/components/terminal-dashboard/SessionGroupTree.tsx index adf23f39..a8a096e7 100644 --- a/ccw/frontend/src/components/terminal-dashboard/SessionGroupTree.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/SessionGroupTree.tsx @@ -1,28 +1,18 @@ // ======================================== // SessionGroupTree Component // ======================================== -// Tree view for session groups with drag-and-drop support. -// Sessions can be dragged between groups. Groups are expandable sections. -// Uses @hello-pangea/dnd for drag-and-drop, sessionManagerStore for state. +// Tree view for CLI sessions grouped by tag. +// Sessions are automatically grouped by their tag (e.g., "gemini-143052"). import { useState, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { - DragDropContext, - Droppable, - Draggable, - type DropResult, -} from '@hello-pangea/dnd'; import { ChevronRight, - FolderOpen, - Folder, - Plus, Terminal, - GripVertical, + Tag, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useSessionManagerStore, selectGroups, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores'; +import { useSessionManagerStore, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores'; import { useCliSessionStore } from '@/stores/cliSessionStore'; import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore'; import { Badge } from '@/components/ui/Badge'; @@ -36,17 +26,15 @@ const statusDotStyles: Record = { error: 'bg-red-500', paused: 'bg-yellow-500', resuming: 'bg-blue-400 animate-pulse', + locked: 'bg-purple-500', }; // ========== SessionGroupTree Component ========== export function SessionGroupTree() { const { formatMessage } = useIntl(); - const groups = useSessionManagerStore(selectGroups); const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId); const terminalMetas = useSessionManagerStore(selectTerminalMetas); - const createGroup = useSessionManagerStore((s) => s.createGroup); - const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup); const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal); const sessions = useCliSessionStore((s) => s.sessions); @@ -55,25 +43,20 @@ export function SessionGroupTree() { const assignSession = useTerminalGridStore((s) => s.assignSession); const setFocused = useTerminalGridStore((s) => s.setFocused); - const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [expandedTags, setExpandedTags] = useState>(new Set()); - const toggleGroup = useCallback((groupId: string) => { - setExpandedGroups((prev) => { + const toggleTag = useCallback((tag: string) => { + setExpandedTags((prev) => { const next = new Set(prev); - if (next.has(groupId)) { - next.delete(groupId); + if (next.has(tag)) { + next.delete(tag); } else { - next.add(groupId); + next.add(tag); } return next; }); }, []); - const handleCreateGroup = useCallback(() => { - const name = formatMessage({ id: 'terminalDashboard.sessionTree.defaultGroupName' }); - createGroup(name); - }, [createGroup, formatMessage]); - const handleSessionClick = useCallback( (sessionId: string) => { // Set active terminal in session manager @@ -100,44 +83,55 @@ export function SessionGroupTree() { [setActiveTerminal, panes, setFocused, assignSession] ); - const handleDragEnd = useCallback( - (result: DropResult) => { - const { draggableId, destination } = result; - if (!destination) return; + // Group sessions by tag + const sessionsByTag = useMemo(() => { + const groups: Record = {}; + const untagged: string[] = []; - // destination.droppableId is the target group ID - const targetGroupId = destination.droppableId; - moveSessionToGroup(draggableId, targetGroupId); - }, - [moveSessionToGroup] - ); + for (const sessionKey of Object.keys(sessions)) { + const meta = terminalMetas[sessionKey]; + const tag = meta?.tag; + if (tag) { + if (!groups[tag]) { + groups[tag] = { tag, sessionIds: [] }; + } + groups[tag].sessionIds.push(sessionKey); + } else { + untagged.push(sessionKey); + } + } + + // Convert to array and sort by tag name (newest first by time suffix) + const result = Object.values(groups).sort((a, b) => b.tag.localeCompare(a.tag)); + + // Add untagged sessions at the end + if (untagged.length > 0) { + result.push({ tag: '__untagged__', sessionIds: untagged }); + } + + return result; + }, [sessions, terminalMetas]); // Build a lookup for session display names const sessionNames = useMemo(() => { const map: Record = {}; for (const [key, meta] of Object.entries(sessions)) { - map[key] = meta.tool ? `${meta.tool} - ${meta.shellKind}` : meta.shellKind; + map[key] = meta.tool ?? meta.shellKind; } return map; }, [sessions]); - if (groups.length === 0) { + if (Object.keys(sessions).length === 0) { return (
-
- -
- +

{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}

+

+ Click "New Session" to create one +

); @@ -145,117 +139,69 @@ export function SessionGroupTree() { return (
- {/* Create group button */} -
- -
- - {/* Groups with drag-and-drop */} + {/* Session list grouped by tag */}
- - {groups.map((group) => { - const isExpanded = expandedGroups.has(group.id); - return ( -
- {/* Group header */} - + {sessionsByTag.map((group) => { + const isExpanded = expandedTags.has(group.tag); + const isUntagged = group.tag === '__untagged__'; + const displayName = isUntagged ? 'Other Sessions' : group.tag; - {/* Expanded: droppable session list */} - {isExpanded && ( - - {(provided, snapshot) => ( -
- {group.sessionIds.length === 0 ? ( -

- {formatMessage({ id: 'terminalDashboard.sessionTree.emptyGroup' })} -

- ) : ( - group.sessionIds.map((sessionId, index) => { - const meta = terminalMetas[sessionId]; - const sessionStatus: TerminalStatus = meta?.status ?? 'idle'; - return ( - - {(dragProvided, dragSnapshot) => ( -
handleSessionClick(sessionId)} - > - - - - {/* Status indicator dot */} - - - - {sessionNames[sessionId] ?? sessionId} - -
- )} -
- ); - }) - )} - {provided.placeholder} -
- )} -
+ return ( +
+ {/* Tag header */} +
- ); - })} - + > + + + {displayName} + + {group.sessionIds.length} + + + + {/* Expanded: session list */} + {isExpanded && ( +
+ {group.sessionIds.map((sessionId) => { + const meta = terminalMetas[sessionId]; + const sessionStatus: TerminalStatus = meta?.status ?? 'idle'; + return ( +
handleSessionClick(sessionId)} + > + {/* Status indicator dot */} + + + + {sessionNames[sessionId] ?? sessionId} + +
+ ); + })} +
+ )} +
+ ); + })}
); diff --git a/ccw/frontend/src/components/terminal-dashboard/SessionLockConfirmDialog.tsx b/ccw/frontend/src/components/terminal-dashboard/SessionLockConfirmDialog.tsx new file mode 100644 index 00000000..987f3573 --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/SessionLockConfirmDialog.tsx @@ -0,0 +1,121 @@ +// ======================================== +// Session Lock Confirm Dialog +// ======================================== +// Dialog shown when user tries to input in a locked session. +// Displays execution info and offers options to wait or unlock. + +import { useIntl } from 'react-intl'; +import { Lock, AlertTriangle } from 'lucide-react'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/AlertDialog'; +import { Progress } from '@/components/ui/Progress'; + +interface SessionLockConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + lockInfo: { + reason: string; + executionName?: string; + currentStep?: string; + progress?: number; + }; +} + +export function SessionLockConfirmDialog({ + isOpen, + onClose, + onConfirm, + lockInfo, +}: SessionLockConfirmDialogProps) { + const { formatMessage } = useIntl(); + + return ( + + + + + + {formatMessage({ id: 'sessionLock.title', defaultMessage: '会话正在执行任务' })} + + + {formatMessage({ + id: 'sessionLock.description', + defaultMessage: '此会话当前正在执行工作流,手动输入可能会中断执行。' + })} + + + +
+ {/* Execution info */} +
+
+ + {formatMessage({ id: 'sessionLock.workflow', defaultMessage: '工作流:' })} + + + {lockInfo.executionName || lockInfo.reason} + +
+ + {lockInfo.currentStep && ( +
+ + {formatMessage({ id: 'sessionLock.currentStep', defaultMessage: '当前步骤:' })} + + {lockInfo.currentStep} +
+ )} + + {lockInfo.progress !== undefined && ( +
+ +

+ {lockInfo.progress}% {formatMessage({ id: 'sessionLock.completed', defaultMessage: '完成' })} +

+
+ )} +
+ + {/* Warning alert */} +
+ +
+

+ {formatMessage({ id: 'sessionLock.warning', defaultMessage: '注意' })} +

+

+ {formatMessage({ + id: 'sessionLock.warningMessage', + defaultMessage: '继续输入将解锁会话,可能会影响正在执行的工作流。' + })} +

+
+
+
+ + + + {formatMessage({ id: 'sessionLock.cancel', defaultMessage: '取消,等待完成' })} + + + {formatMessage({ id: 'sessionLock.confirm', defaultMessage: '解锁并继续输入' })} + + +
+
+ ); +} + +export default SessionLockConfirmDialog; diff --git a/ccw/frontend/src/hooks/useOrchestratorExecution.ts b/ccw/frontend/src/hooks/useOrchestratorExecution.ts new file mode 100644 index 00000000..2dd6291c --- /dev/null +++ b/ccw/frontend/src/hooks/useOrchestratorExecution.ts @@ -0,0 +1,154 @@ +// ======================================== +// Orchestrator Execution Hooks +// ======================================== +// React Query hooks for executing flows in terminal sessions. + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useExecutionMonitorStore } from '@/stores/executionMonitorStore'; +import { useSessionManagerStore } from '@/stores/sessionManagerStore'; +import { toast } from '@/stores/notificationStore'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; + +// ========== Types ========== + +export interface SessionConfig { + tool?: 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode'; + model?: string; + preferredShell?: 'bash' | 'pwsh' | 'cmd'; +} + +export interface ExecuteInSessionRequest { + sessionConfig?: SessionConfig; + sessionKey?: string; + variables?: Record; + stepTimeout?: number; + errorStrategy?: 'pause' | 'skip' | 'stop'; +} + +export interface ExecuteInSessionResponse { + success: boolean; + data: { + executionId: string; + flowId: string; + sessionKey: string; + status: 'pending' | 'running'; + totalSteps: number; + startedAt: string; + }; + error?: string; +} + +// ========== Helper ========== + +function withPath(url: string, projectPath?: string | null): string { + const p = typeof projectPath === 'string' ? projectPath.trim() : ''; + if (!p) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}path=${encodeURIComponent(p)}`; +} + +// ========== Hook ========== + +export function useExecuteFlowInSession() { + const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const handleExecutionMessage = useExecutionMonitorStore((s) => s.handleExecutionMessage); + const setPanelOpen = useExecutionMonitorStore((s) => s.setPanelOpen); + const lockSession = useSessionManagerStore((s) => s.lockSession); + + return useMutation({ + mutationFn: async (params: { + flowId: string; + sessionConfig?: SessionConfig; + sessionKey?: string; + }): Promise => { + const url = withPath(`/api/orchestrator/flows/${params.flowId}/execute-in-session`, projectPath); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionConfig: params.sessionConfig, + sessionKey: params.sessionKey, + }), + }); + return response.json(); + }, + onSuccess: (data) => { + if (data.success) { + const { executionId, flowId, sessionKey, startedAt } = data.data; + + // Initialize execution in store + handleExecutionMessage({ + type: 'EXECUTION_STARTED', + payload: { + executionId, + flowId, + sessionKey, + stepName: flowId, + timestamp: startedAt, + }, + }); + + // Lock the session + lockSession(sessionKey, `Executing workflow: ${flowId}`, executionId); + + // Open the execution monitor panel + setPanelOpen(true); + + // Update query cache + queryClient.setQueryData(['activeExecution'], data.data); + } + }, + onError: (error) => { + console.error('[ExecuteFlowInSession] Error:', error); + toast.error( + 'Execution Failed', + 'Could not start workflow execution in terminal session.' + ); + }, + }); +} + +// ========== Session Lock Hooks ========== + +export function useLockSession() { + return useMutation({ + mutationFn: async (params: { + sessionKey: string; + reason: string; + executionId?: string; + }) => { + const response = await fetch(`/api/sessions/${params.sessionKey}/lock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: params.reason, executionId: params.executionId }), + }); + return response.json(); + }, + }); +} + +export function useUnlockSession() { + return useMutation({ + mutationFn: async (sessionKey: string) => { + const response = await fetch(`/api/sessions/${sessionKey}/unlock`, { + method: 'POST', + }); + return response.json(); + }, + }); +} + +// ========== Flow to Session Conversion Hook ========== + +export function usePrepareFlowForExecution() { + const projectPath = useWorkflowStore(selectProjectPath); + + return useMutation({ + mutationFn: async (flowId: string) => { + const url = withPath(`/api/orchestrator/flows/${flowId}`, projectPath); + const response = await fetch(url); + return response.json(); + }, + }); +} diff --git a/ccw/frontend/src/locales/en/orchestrator.json b/ccw/frontend/src/locales/en/orchestrator.json index c7337829..4a5e83c3 100644 --- a/ccw/frontend/src/locales/en/orchestrator.json +++ b/ccw/frontend/src/locales/en/orchestrator.json @@ -1,6 +1,6 @@ { - "title": "Orchestrator", - "description": "Manage and execute workflow flows", + "title": "Workflow Template Editor", + "description": "Create and edit workflow templates", "flow": { "title": "Flow", "flows": "Flows", @@ -98,6 +98,9 @@ "couldNotDuplicate": "Could not duplicate the flow", "flowExported": "Flow exported as JSON file", "noFlowToExport": "Create or load a flow first", + "saveBeforeExecute": "Please save the flow first", + "flowSent": "Flow Sent", + "sentToTerminal": "\"{name}\" sent to terminal for execution", "executionFailed": "Execution Failed", "couldNotExecute": "Could not start flow execution" }, @@ -152,8 +155,7 @@ "export": "Export Flow", "templates": "Templates", "importTemplate": "Import Template", - "runWorkflow": "Run Workflow", - "monitor": "Monitor", + "sendToTerminal": "Send to Terminal", "savedFlows": "Saved Flows ({count})", "loading": "Loading...", "noSavedFlows": "No saved flows", diff --git a/ccw/frontend/src/locales/zh/orchestrator.json b/ccw/frontend/src/locales/zh/orchestrator.json index 36218f95..985dfa78 100644 --- a/ccw/frontend/src/locales/zh/orchestrator.json +++ b/ccw/frontend/src/locales/zh/orchestrator.json @@ -1,6 +1,6 @@ { - "title": "编排器", - "description": "管理和执行工作流", + "title": "工作流模板编辑器", + "description": "创建和编辑工作流模板", "flow": { "title": "流程", "flows": "流程列表", @@ -98,6 +98,9 @@ "couldNotDuplicate": "无法复制流程", "flowExported": "流程已导出为 JSON 文件", "noFlowToExport": "请先创建或加载流程", + "saveBeforeExecute": "请先保存流程", + "flowSent": "流程已发送", + "sentToTerminal": "\"{name}\" 已发送到终端执行", "executionFailed": "执行失败", "couldNotExecute": "无法启动流程执行" }, @@ -152,8 +155,7 @@ "export": "导出流程", "templates": "模板", "importTemplate": "导入模板", - "runWorkflow": "运行流程", - "monitor": "监控", + "sendToTerminal": "发送到终端执行", "savedFlows": "已保存的流程 ({count})", "loading": "加载中...", "noSavedFlows": "无已保存的流程", diff --git a/ccw/frontend/src/pages/TerminalDashboardPage.tsx b/ccw/frontend/src/pages/TerminalDashboardPage.tsx index 622a9144..6f87c803 100644 --- a/ccw/frontend/src/pages/TerminalDashboardPage.tsx +++ b/ccw/frontend/src/pages/TerminalDashboardPage.tsx @@ -22,6 +22,7 @@ import { AgentList } from '@/components/terminal-dashboard/AgentList'; import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel'; import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel'; import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector'; +import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel'; import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore'; @@ -128,6 +129,16 @@ export function TerminalDashboardPage() { > + + + + ); diff --git a/ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx b/ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx index 6ded5b67..680b19b4 100644 --- a/ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx +++ b/ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx @@ -1,10 +1,11 @@ // ======================================== // Flow Toolbar Component // ======================================== -// Toolbar for flow operations: Save, Load, Import Template, Export, Run, Monitor +// Toolbar for flow operations: Save, Load, Import Template, Export, Send to Terminal import { useState, useCallback, useEffect } from 'react'; import { useIntl } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; import { Save, FolderOpen, @@ -15,8 +16,7 @@ import { Loader2, ChevronDown, Library, - Play, - Activity, + Terminal, Maximize2, Minimize2, } from 'lucide-react'; @@ -24,8 +24,6 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { useFlowStore, toast } from '@/stores'; -import { useExecutionStore } from '@/stores/executionStore'; -import { useExecuteFlow } from '@/hooks/useFlows'; import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore'; import type { Flow } from '@/types/flow'; @@ -36,6 +34,7 @@ interface FlowToolbarProps { export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) { const { formatMessage } = useIntl(); + const navigate = useNavigate(); const [isFlowListOpen, setIsFlowListOpen] = useState(false); const [flowName, setFlowName] = useState(''); const [isSaving, setIsSaving] = useState(false); @@ -55,18 +54,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro const duplicateFlow = useFlowStore((state) => state.duplicateFlow); const fetchFlows = useFlowStore((state) => state.fetchFlows); - // Execution store - const currentExecution = useExecutionStore((state) => state.currentExecution); - const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen); - const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen); - const startExecution = useExecutionStore((state) => state.startExecution); - - // Mutations - const executeFlow = useExecuteFlow(); - - const isExecuting = currentExecution?.status === 'running'; - const isPaused = currentExecution?.status === 'paused'; - // Load flows on mount useEffect(() => { fetchFlows(); @@ -194,24 +181,26 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro toast.success(formatMessage({ id: 'orchestrator.notifications.flowExported' }), formatMessage({ id: 'orchestrator.notifications.flowExported' })); }, [currentFlow]); - // Handle run workflow - const handleRun = useCallback(async () => { - if (!currentFlow) return; - try { - // Open monitor panel automatically - setMonitorPanelOpen(true); - const result = await executeFlow.mutateAsync(currentFlow.id); - startExecution(result.execId, currentFlow.id); - } catch (error) { - console.error('Failed to execute flow:', error); - toast.error(formatMessage({ id: 'orchestrator.notifications.executionFailed' }), formatMessage({ id: 'orchestrator.notifications.couldNotExecute' })); + // Handle send to terminal execution + const handleSendToTerminal = useCallback(async () => { + if (!currentFlow) { + toast.error(formatMessage({ id: 'orchestrator.notifications.noFlow' }), formatMessage({ id: 'orchestrator.notifications.saveBeforeExecute' })); + return; } - }, [currentFlow, executeFlow, startExecution, setMonitorPanelOpen]); - // Handle monitor toggle - const handleToggleMonitor = useCallback(() => { - setMonitorPanelOpen(!isMonitorPanelOpen); - }, [isMonitorPanelOpen, setMonitorPanelOpen]); + // Save flow first if modified + if (isModified) { + const saved = await saveFlow(); + if (!saved) { + toast.error(formatMessage({ id: 'orchestrator.notifications.saveFailed' }), formatMessage({ id: 'orchestrator.notifications.couldNotSave' })); + return; + } + } + + // Navigate to terminal dashboard with flow execution request + navigate(`/terminal?executeFlow=${currentFlow.id}`); + toast.success(formatMessage({ id: 'orchestrator.notifications.flowSent' }), formatMessage({ id: 'orchestrator.notifications.sentToTerminal' }, { name: currentFlow.name })); + }, [currentFlow, isModified, saveFlow, navigate, formatMessage]); return (
@@ -346,29 +335,15 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
- {/* Run & Monitor Group */} - - + {/* Execute in Terminal */}
diff --git a/ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx b/ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx index c15e1be4..a2926e9c 100644 --- a/ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx +++ b/ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx @@ -1,27 +1,25 @@ // ======================================== // Orchestrator Page // ======================================== -// Visual workflow editor with React Flow, drag-drop node palette, and property panel +// Visual workflow template editor with React Flow, drag-drop node palette, and property panel +// Execution functionality moved to Terminal Dashboard import { useEffect, useState, useCallback } from 'react'; import * as Collapsible from '@radix-ui/react-collapsible'; import { ChevronRight } from 'lucide-react'; import { useFlowStore } from '@/stores'; -import { useExecutionStore } from '@/stores/executionStore'; import { Button } from '@/components/ui/Button'; import { FlowCanvas } from './FlowCanvas'; import { LeftSidebar } from './LeftSidebar'; import { PropertyPanel } from './PropertyPanel'; import { FlowToolbar } from './FlowToolbar'; import { TemplateLibrary } from './TemplateLibrary'; -import { ExecutionMonitor } from './ExecutionMonitor'; export function OrchestratorPage() { const fetchFlows = useFlowStore((state) => state.fetchFlows); const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen); const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen); const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen); - const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen); const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false); // Load flows on mount @@ -60,15 +58,12 @@ export function OrchestratorPage() { {/* Property Panel as overlay - only shown when a node is selected */} - {!isMonitorPanelOpen && isPropertyPanelOpen && ( + {isPropertyPanelOpen && (
)}
- - {/* Execution Monitor Panel (Right) */} -
{/* Template Library Dialog */} diff --git a/ccw/frontend/src/stores/executionMonitorStore.ts b/ccw/frontend/src/stores/executionMonitorStore.ts new file mode 100644 index 00000000..8a271c8d --- /dev/null +++ b/ccw/frontend/src/stores/executionMonitorStore.ts @@ -0,0 +1,291 @@ +// ======================================== +// Execution Monitor Store +// ======================================== +// Zustand store for execution monitoring in Terminal Dashboard. +// Tracks active executions, handles WebSocket messages, and provides control actions. + +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +// ========== Types ========== + +export type ExecutionStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; + +export interface StepInfo { + id: string; + name: string; + status: ExecutionStatus; + output?: string; + error?: string; + startedAt?: string; + completedAt?: string; +} + +export interface ExecutionInfo { + executionId: string; + flowId: string; + flowName: string; + sessionKey: string; + status: ExecutionStatus; + totalSteps: number; + completedSteps: number; + currentStepId?: string; + steps: StepInfo[]; + startedAt: string; + completedAt?: string; +} + +export type ExecutionWSMessageType = + | 'EXECUTION_STARTED' + | 'EXECUTION_STEP_START' + | 'EXECUTION_STEP_PROGRESS' + | 'EXECUTION_STEP_COMPLETE' + | 'EXECUTION_STEP_FAILED' + | 'EXECUTION_PAUSED' + | 'EXECUTION_RESUMED' + | 'EXECUTION_STOPPED' + | 'EXECUTION_COMPLETED'; + +export interface ExecutionWSMessage { + type: ExecutionWSMessageType; + payload: { + executionId: string; + flowId: string; + sessionKey: string; + stepId?: string; + stepName?: string; + progress?: number; + output?: string; + error?: string; + timestamp: string; + }; +} + +// ========== State Interface ========== + +interface ExecutionMonitorState { + activeExecutions: Record; + currentExecutionId: string | null; + isPanelOpen: boolean; +} + +interface ExecutionMonitorActions { + handleExecutionMessage: (msg: ExecutionWSMessage) => void; + selectExecution: (executionId: string | null) => void; + pauseExecution: (executionId: string) => void; + resumeExecution: (executionId: string) => void; + stopExecution: (executionId: string) => void; + setPanelOpen: (open: boolean) => void; + clearExecution: (executionId: string) => void; + clearAllExecutions: () => void; +} + +type ExecutionMonitorStore = ExecutionMonitorState & ExecutionMonitorActions; + +// ========== Initial State ========== + +const initialState: ExecutionMonitorState = { + activeExecutions: {}, + currentExecutionId: null, + isPanelOpen: false, +}; + +// ========== Store ========== + +export const useExecutionMonitorStore = create()( + devtools( + (set) => ({ + ...initialState, + + handleExecutionMessage: (msg: ExecutionWSMessage) => { + const { type, payload } = msg; + const { executionId, flowId, sessionKey, stepId, stepName, output, error, timestamp } = payload; + + set((state) => { + const existing = state.activeExecutions[executionId]; + + switch (type) { + case 'EXECUTION_STARTED': + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { + executionId, + flowId, + flowName: stepName || 'Workflow', + sessionKey, + status: 'running', + totalSteps: 0, + completedSteps: 0, + steps: [], + startedAt: timestamp, + }, + }, + currentExecutionId: executionId, + isPanelOpen: true, + }; + + case 'EXECUTION_STEP_START': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { + ...existing, + status: 'running', + currentStepId: stepId, + steps: [ + ...existing.steps.filter(s => s.id !== stepId), + { + id: stepId || '', + name: stepName || '', + status: 'running', + startedAt: timestamp, + }, + ], + }, + }, + }; + + case 'EXECUTION_STEP_PROGRESS': + if (!existing || !stepId) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { + ...existing, + steps: existing.steps.map(s => + s.id === stepId + ? { ...s, output: (s.output || '') + (output || '') } + : s + ), + }, + }, + }; + + case 'EXECUTION_STEP_COMPLETE': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { + ...existing, + completedSteps: existing.completedSteps + 1, + steps: existing.steps.map(s => + s.id === stepId + ? { ...s, status: 'completed', completedAt: timestamp } + : s + ), + }, + }, + }; + + case 'EXECUTION_STEP_FAILED': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { + ...existing, + status: 'paused', + steps: existing.steps.map(s => + s.id === stepId + ? { ...s, status: 'failed', error, completedAt: timestamp } + : s + ), + }, + }, + }; + + case 'EXECUTION_PAUSED': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { ...existing, status: 'paused' }, + }, + }; + + case 'EXECUTION_RESUMED': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { ...existing, status: 'running' }, + }, + }; + + case 'EXECUTION_STOPPED': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { ...existing, status: 'cancelled', completedAt: timestamp }, + }, + }; + + case 'EXECUTION_COMPLETED': + if (!existing) return state; + return { + activeExecutions: { + ...state.activeExecutions, + [executionId]: { ...existing, status: 'completed', completedAt: timestamp }, + }, + }; + + default: + return state; + } + }, false, `handleExecutionMessage/${type}`); + }, + + selectExecution: (executionId: string | null) => { + set({ currentExecutionId: executionId }, false, 'selectExecution'); + }, + + pauseExecution: (executionId: string) => { + // TODO: Call API to pause execution + console.log('[ExecutionMonitor] Pause execution:', executionId); + }, + + resumeExecution: (executionId: string) => { + // TODO: Call API to resume execution + console.log('[ExecutionMonitor] Resume execution:', executionId); + }, + + stopExecution: (executionId: string) => { + // TODO: Call API to stop execution + console.log('[ExecutionMonitor] Stop execution:', executionId); + }, + + setPanelOpen: (open: boolean) => { + set({ isPanelOpen: open }, false, 'setPanelOpen'); + }, + + clearExecution: (executionId: string) => { + set((state) => { + const next = { ...state.activeExecutions }; + delete next[executionId]; + return { + activeExecutions: next, + currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId, + }; + }, false, 'clearExecution'); + }, + + clearAllExecutions: () => { + set({ activeExecutions: {}, currentExecutionId: null }, false, 'clearAllExecutions'); + }, + }), + { name: 'ExecutionMonitorStore' } + ) +); + +// ========== Selectors ========== + +export const selectActiveExecutions = (state: ExecutionMonitorStore) => state.activeExecutions; +export const selectCurrentExecution = (state: ExecutionMonitorStore) => + state.currentExecutionId ? state.activeExecutions[state.currentExecutionId] : null; +export const selectIsPanelOpen = (state: ExecutionMonitorStore) => state.isPanelOpen; +export const selectActiveExecutionCount = (state: ExecutionMonitorStore) => + Object.values(state.activeExecutions).filter(e => e.status === 'running' || e.status === 'paused').length; diff --git a/ccw/frontend/src/types/terminal-dashboard.ts b/ccw/frontend/src/types/terminal-dashboard.ts index 89c6ddfe..25c20132 100644 --- a/ccw/frontend/src/types/terminal-dashboard.ts +++ b/ccw/frontend/src/types/terminal-dashboard.ts @@ -28,6 +28,8 @@ export interface TerminalMeta { status: TerminalStatus; /** Number of unread alerts (errors, warnings) */ alertCount: number; + /** Session tag for grouping (e.g., "gemini-143052") */ + tag?: string; /** Whether the session is locked (executing a workflow) */ isLocked?: boolean; /** Reason for the lock (e.g., workflow name) */ diff --git a/ccw/src/core/routes/orchestrator-routes.ts b/ccw/src/core/routes/orchestrator-routes.ts index 5a574a56..64ad990d 100644 --- a/ccw/src/core/routes/orchestrator-routes.ts +++ b/ccw/src/core/routes/orchestrator-routes.ts @@ -11,12 +11,13 @@ * - POST /api/orchestrator/flows/:id/duplicate - Duplicate flow * * Execution Control Endpoints: - * - POST /api/orchestrator/flows/:id/execute - Start flow execution - * - POST /api/orchestrator/executions/:execId/pause - Pause execution - * - POST /api/orchestrator/executions/:execId/resume - Resume execution - * - POST /api/orchestrator/executions/:execId/stop - Stop execution - * - GET /api/orchestrator/executions/:execId - Get execution state - * - GET /api/orchestrator/executions/:execId/logs - Get execution logs + * - POST /api/orchestrator/flows/:id/execute - Start flow execution + * - POST /api/orchestrator/flows/:id/execute-in-session - Start flow execution in PTY session + * - POST /api/orchestrator/executions/:execId/pause - Pause execution + * - POST /api/orchestrator/executions/:execId/resume - Resume execution + * - POST /api/orchestrator/executions/:execId/stop - Stop execution + * - GET /api/orchestrator/executions/:execId - Get execution state + * - GET /api/orchestrator/executions/:execId/logs - Get execution logs * * Template Management Endpoints: * - GET /api/orchestrator/templates - List local + builtin templates @@ -1277,6 +1278,134 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise { + const { + sessionConfig, + sessionKey: existingSessionKey, + variables: inputVariables, + stepTimeout, + errorStrategy = 'pause' + } = body as { + sessionConfig?: { + tool?: string; + model?: string; + preferredShell?: string; + }; + sessionKey?: string; + variables?: Record; + stepTimeout?: number; + errorStrategy?: 'pause' | 'skip' | 'stop'; + }; + + try { + // Verify flow exists + const flow = await readFlowStorage(workflowDir, flowId); + if (!flow) { + return { success: false, error: 'Flow not found', status: 404 }; + } + + // Generate execution ID + const execId = generateExecutionId(); + const now = new Date().toISOString(); + + // Determine session key + let sessionKey = existingSessionKey; + if (!sessionKey) { + // Create new session if not provided + // This would typically call the session manager + sessionKey = `cli-session-${Date.now()}-${randomBytes(4).toString('hex')}`; + } + + // Create execution state + const nodeStates: Record = {}; + for (const node of flow.nodes) { + nodeStates[node.id] = { + status: 'pending' + }; + } + + const execution: ExecutionState = { + id: execId, + flowId: flowId, + status: 'pending', + startedAt: now, + variables: { ...flow.variables, ...inputVariables }, + nodeStates, + logs: [{ + timestamp: now, + level: 'info', + message: `Execution started in session: ${sessionKey}` + }] + }; + + // Save execution state + await writeExecutionStorage(workflowDir, execution); + + // Broadcast execution created + broadcastExecutionStateUpdate(execution); + + // Broadcast EXECUTION_STARTED to WebSocket clients + if (wsBroadcast) { + wsBroadcast({ + type: 'EXECUTION_STARTED', + payload: { + executionId: execId, + flowId: flowId, + sessionKey: sessionKey, + stepName: flow.name, + timestamp: now + } + }); + } + + // Lock the session (via WebSocket broadcast for frontend to handle) + if (wsBroadcast) { + wsBroadcast({ + type: 'CLI_SESSION_LOCKED', + payload: { + sessionKey: sessionKey, + reason: `Executing workflow: ${flow.name}`, + executionId: execId, + timestamp: now + } + }); + } + + // TODO: Implement actual step-by-step execution in PTY session + // For now, mark as running and let the frontend handle the orchestration + execution.status = 'running'; + await writeExecutionStorage(workflowDir, execution); + broadcastExecutionStateUpdate(execution); + + return { + success: true, + data: { + executionId: execution.id, + flowId: execution.flowId, + sessionKey: sessionKey, + status: execution.status, + totalSteps: flow.nodes.length, + startedAt: execution.startedAt + }, + message: 'Execution started in session' + }; + } catch (error) { + return { success: false, error: (error as Error).message, status: 500 }; + } + }); + return true; + } + // ==== PAUSE EXECUTION ==== // POST /api/orchestrator/executions/:execId/pause if (pathname.match(/^\/api\/orchestrator\/executions\/[^/]+\/pause$/) && req.method === 'POST') {