mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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
This commit is contained in:
@@ -34,6 +34,7 @@ import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/
|
|||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
import { toast } from '@/stores/notificationStore';
|
import { toast } from '@/stores/notificationStore';
|
||||||
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
|
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
|
||||||
|
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||||
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
@@ -106,6 +107,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||||
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
||||||
|
const updateTerminalMeta = useSessionManagerStore((s) => s.updateTerminalMeta);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
const targetPaneId = getOrCreateFocusedPane();
|
const targetPaneId = getOrCreateFocusedPane();
|
||||||
if (!targetPaneId) throw new Error('Failed to create pane');
|
if (!targetPaneId) throw new Error('Failed to create pane');
|
||||||
|
|
||||||
await createSessionAndAssign(
|
const result = await createSessionAndAssign(
|
||||||
targetPaneId,
|
targetPaneId,
|
||||||
{
|
{
|
||||||
workingDir: config.workingDir || projectPath,
|
workingDir: config.workingDir || projectPath,
|
||||||
@@ -142,6 +144,14 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
},
|
},
|
||||||
projectPath
|
projectPath
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Store tag in terminalMetas for grouping
|
||||||
|
if (result?.session?.sessionKey) {
|
||||||
|
updateTerminalMeta(result.session.sessionKey, {
|
||||||
|
tag: config.tag,
|
||||||
|
title: config.tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error
|
const message = error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@@ -153,7 +163,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane]);
|
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane, updateTerminalMeta]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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<ExecutionStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
|
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 <Circle className="w-4 h-4 text-muted-foreground" />;
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="w-4 h-4 text-amber-500" />;
|
||||||
|
case 'cancelled':
|
||||||
|
return <Square className="w-4 h-4 text-muted-foreground" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="w-4 h-4 text-muted-foreground opacity-50" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Step List Item ==========
|
||||||
|
|
||||||
|
interface StepListItemProps {
|
||||||
|
step: StepInfo;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepListItem({ step, isCurrent }: StepListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 px-3 py-2 rounded-md transition-colors',
|
||||||
|
isCurrent && step.status === 'running' && 'bg-primary/5 border border-primary/20',
|
||||||
|
step.status === 'failed' && 'bg-destructive/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pt-0.5 shrink-0">
|
||||||
|
<StepStatusIcon status={step.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium truncate block',
|
||||||
|
step.status === 'completed' && 'text-muted-foreground',
|
||||||
|
step.status === 'running' && 'text-foreground',
|
||||||
|
step.status === 'failed' && 'text-destructive'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
{step.error && (
|
||||||
|
<p className="text-xs text-destructive mt-1 truncate">{step.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-8">
|
||||||
|
<Terminal className="w-10 h-10 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatMessage({ id: 'executionMonitor.noExecutions', defaultMessage: 'No active executions' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">
|
||||||
|
{formatMessage({ id: 'executionMonitor.sendToTerminal', defaultMessage: 'Send a workflow to terminal to start' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Execution selector (if multiple) */}
|
||||||
|
{executions.length > 1 && (
|
||||||
|
<div className="border-b border-border p-2 shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{executions.map((exec) => (
|
||||||
|
<button
|
||||||
|
key={exec.executionId}
|
||||||
|
onClick={() => selectExecution(exec.executionId)}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-md transition-colors truncate max-w-[120px]',
|
||||||
|
currentExecution?.executionId === exec.executionId
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exec.flowName}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentExecution && (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-border space-y-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground truncate flex-1">
|
||||||
|
{currentExecution.flowName}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn('shrink-0', statusConfig[currentExecution.status].bgColor)}
|
||||||
|
>
|
||||||
|
{statusConfig[currentExecution.status].label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'executionMonitor.progress', defaultMessage: '{completed}/{total} steps' },
|
||||||
|
{ completed: currentExecution.completedSteps, total: currentExecution.totalSteps || currentExecution.steps.length }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{Math.round(
|
||||||
|
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
|
||||||
|
)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{new Date(currentExecution.startedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[100px]">{currentExecution.sessionKey.slice(0, 20)}...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
{currentExecution.steps.map((step) => (
|
||||||
|
<StepListItem
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
isCurrent={step.id === currentExecution.currentStepId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control bar */}
|
||||||
|
<div className="p-3 border-t border-border flex items-center gap-2 shrink-0">
|
||||||
|
{currentExecution.status === 'running' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => pauseExecution(currentExecution.executionId)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Pause className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'executionMonitor.pause', defaultMessage: 'Pause' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => stopExecution(currentExecution.executionId)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentExecution.status === 'paused' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resumeExecution(currentExecution.executionId)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'executionMonitor.resume', defaultMessage: 'Resume' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => stopExecution(currentExecution.executionId)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5" />
|
||||||
|
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(currentExecution.status === 'completed' ||
|
||||||
|
currentExecution.status === 'failed' ||
|
||||||
|
currentExecution.status === 'cancelled') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clearExecution(currentExecution.executionId)}
|
||||||
|
className="gap-1.5 ml-auto"
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'executionMonitor.clear', defaultMessage: 'Clear' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExecutionMonitorPanel;
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// SessionGroupTree Component
|
// SessionGroupTree Component
|
||||||
// ========================================
|
// ========================================
|
||||||
// Tree view for session groups with drag-and-drop support.
|
// Tree view for CLI sessions grouped by tag.
|
||||||
// Sessions can be dragged between groups. Groups are expandable sections.
|
// Sessions are automatically grouped by their tag (e.g., "gemini-143052").
|
||||||
// Uses @hello-pangea/dnd for drag-and-drop, sessionManagerStore for state.
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
|
||||||
DragDropContext,
|
|
||||||
Droppable,
|
|
||||||
Draggable,
|
|
||||||
type DropResult,
|
|
||||||
} from '@hello-pangea/dnd';
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FolderOpen,
|
|
||||||
Folder,
|
|
||||||
Plus,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
GripVertical,
|
Tag,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSessionManagerStore, selectGroups, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
|
import { useSessionManagerStore, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
|
||||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||||
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
@@ -36,17 +26,15 @@ const statusDotStyles: Record<TerminalStatus, string> = {
|
|||||||
error: 'bg-red-500',
|
error: 'bg-red-500',
|
||||||
paused: 'bg-yellow-500',
|
paused: 'bg-yellow-500',
|
||||||
resuming: 'bg-blue-400 animate-pulse',
|
resuming: 'bg-blue-400 animate-pulse',
|
||||||
|
locked: 'bg-purple-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== SessionGroupTree Component ==========
|
// ========== SessionGroupTree Component ==========
|
||||||
|
|
||||||
export function SessionGroupTree() {
|
export function SessionGroupTree() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const groups = useSessionManagerStore(selectGroups);
|
|
||||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||||
const createGroup = useSessionManagerStore((s) => s.createGroup);
|
|
||||||
const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup);
|
|
||||||
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
||||||
const sessions = useCliSessionStore((s) => s.sessions);
|
const sessions = useCliSessionStore((s) => s.sessions);
|
||||||
|
|
||||||
@@ -55,25 +43,20 @@ export function SessionGroupTree() {
|
|||||||
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
||||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||||
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggleGroup = useCallback((groupId: string) => {
|
const toggleTag = useCallback((tag: string) => {
|
||||||
setExpandedGroups((prev) => {
|
setExpandedTags((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(groupId)) {
|
if (next.has(tag)) {
|
||||||
next.delete(groupId);
|
next.delete(tag);
|
||||||
} else {
|
} else {
|
||||||
next.add(groupId);
|
next.add(tag);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateGroup = useCallback(() => {
|
|
||||||
const name = formatMessage({ id: 'terminalDashboard.sessionTree.defaultGroupName' });
|
|
||||||
createGroup(name);
|
|
||||||
}, [createGroup, formatMessage]);
|
|
||||||
|
|
||||||
const handleSessionClick = useCallback(
|
const handleSessionClick = useCallback(
|
||||||
(sessionId: string) => {
|
(sessionId: string) => {
|
||||||
// Set active terminal in session manager
|
// Set active terminal in session manager
|
||||||
@@ -100,44 +83,55 @@ export function SessionGroupTree() {
|
|||||||
[setActiveTerminal, panes, setFocused, assignSession]
|
[setActiveTerminal, panes, setFocused, assignSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
// Group sessions by tag
|
||||||
(result: DropResult) => {
|
const sessionsByTag = useMemo(() => {
|
||||||
const { draggableId, destination } = result;
|
const groups: Record<string, { tag: string; sessionIds: string[] }> = {};
|
||||||
if (!destination) return;
|
const untagged: string[] = [];
|
||||||
|
|
||||||
// destination.droppableId is the target group ID
|
for (const sessionKey of Object.keys(sessions)) {
|
||||||
const targetGroupId = destination.droppableId;
|
const meta = terminalMetas[sessionKey];
|
||||||
moveSessionToGroup(draggableId, targetGroupId);
|
const tag = meta?.tag;
|
||||||
},
|
if (tag) {
|
||||||
[moveSessionToGroup]
|
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
|
// Build a lookup for session display names
|
||||||
const sessionNames = useMemo(() => {
|
const sessionNames = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const [key, meta] of Object.entries(sessions)) {
|
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;
|
return map;
|
||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
|
|
||||||
if (groups.length === 0) {
|
if (Object.keys(sessions).length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-3 py-2 border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={handleCreateGroup}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-1.5 text-muted-foreground p-4">
|
<div className="flex-1 flex flex-col items-center justify-center gap-1.5 text-muted-foreground p-4">
|
||||||
<Folder className="w-6 h-6 opacity-30" />
|
<Terminal className="w-6 h-6 opacity-30" />
|
||||||
<p className="text-xs text-center">
|
<p className="text-xs text-center">
|
||||||
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
|
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
|
Click "New Session" to create one
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -145,117 +139,69 @@ export function SessionGroupTree() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Create group button */}
|
{/* Session list grouped by tag */}
|
||||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={handleCreateGroup}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Groups with drag-and-drop */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
{sessionsByTag.map((group) => {
|
||||||
{groups.map((group) => {
|
const isExpanded = expandedTags.has(group.tag);
|
||||||
const isExpanded = expandedGroups.has(group.id);
|
const isUntagged = group.tag === '__untagged__';
|
||||||
return (
|
const displayName = isUntagged ? 'Other Sessions' : group.tag;
|
||||||
<div key={group.id} className="border-b border-border/50 last:border-b-0">
|
|
||||||
{/* Group header */}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleGroup(group.id)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
|
|
||||||
'hover:bg-muted/50 transition-colors text-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
|
|
||||||
isExpanded && 'rotate-90'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isExpanded ? (
|
|
||||||
<FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 truncate font-medium">{group.name}</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
||||||
{group.sessionIds.length}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded: droppable session list */}
|
return (
|
||||||
{isExpanded && (
|
<div key={group.tag} className="border-b border-border/50 last:border-b-0">
|
||||||
<Droppable droppableId={group.id}>
|
{/* Tag header */}
|
||||||
{(provided, snapshot) => (
|
<button
|
||||||
<div
|
onClick={() => toggleTag(group.tag)}
|
||||||
ref={provided.innerRef}
|
className={cn(
|
||||||
{...provided.droppableProps}
|
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
|
||||||
className={cn(
|
'hover:bg-muted/50 transition-colors text-sm'
|
||||||
'min-h-[32px] pb-1',
|
|
||||||
snapshot.isDraggingOver && 'bg-primary/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{group.sessionIds.length === 0 ? (
|
|
||||||
<p className="px-8 py-2 text-xs text-muted-foreground italic">
|
|
||||||
{formatMessage({ id: 'terminalDashboard.sessionTree.emptyGroup' })}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
group.sessionIds.map((sessionId, index) => {
|
|
||||||
const meta = terminalMetas[sessionId];
|
|
||||||
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
|
|
||||||
return (
|
|
||||||
<Draggable
|
|
||||||
key={sessionId}
|
|
||||||
draggableId={sessionId}
|
|
||||||
index={index}
|
|
||||||
>
|
|
||||||
{(dragProvided, dragSnapshot) => (
|
|
||||||
<div
|
|
||||||
ref={dragProvided.innerRef}
|
|
||||||
{...dragProvided.draggableProps}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
|
|
||||||
'hover:bg-muted/50 transition-colors text-sm',
|
|
||||||
activeTerminalId === sessionId && 'bg-primary/10 text-primary',
|
|
||||||
dragSnapshot.isDragging && 'bg-muted shadow-md'
|
|
||||||
)}
|
|
||||||
onClick={() => handleSessionClick(sessionId)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
{...dragProvided.dragHandleProps}
|
|
||||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0"
|
|
||||||
>
|
|
||||||
<GripVertical className="w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
{/* Status indicator dot */}
|
|
||||||
<span
|
|
||||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
|
|
||||||
title={sessionStatus}
|
|
||||||
/>
|
|
||||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<span className="flex-1 truncate text-xs">
|
|
||||||
{sessionNames[sessionId] ?? sessionId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
);
|
<ChevronRight
|
||||||
})}
|
className={cn(
|
||||||
</DragDropContext>
|
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="flex-1 truncate font-medium text-xs">{displayName}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
{group.sessionIds.length}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded: session list */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pb-1">
|
||||||
|
{group.sessionIds.map((sessionId) => {
|
||||||
|
const meta = terminalMetas[sessionId];
|
||||||
|
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sessionId}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
|
||||||
|
'hover:bg-muted/50 transition-colors text-sm',
|
||||||
|
activeTerminalId === sessionId && 'bg-primary/10 text-primary'
|
||||||
|
)}
|
||||||
|
onClick={() => handleSessionClick(sessionId)}
|
||||||
|
>
|
||||||
|
{/* Status indicator dot */}
|
||||||
|
<span
|
||||||
|
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
|
||||||
|
title={sessionStatus}
|
||||||
|
/>
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="flex-1 truncate text-xs">
|
||||||
|
{sessionNames[sessionId] ?? sessionId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AlertDialog open={isOpen}>
|
||||||
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<Lock className="w-5 h-5 text-amber-500" />
|
||||||
|
{formatMessage({ id: 'sessionLock.title', defaultMessage: '会话正在执行任务' })}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'sessionLock.description',
|
||||||
|
defaultMessage: '此会话当前正在执行工作流,手动输入可能会中断执行。'
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
{/* Execution info */}
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'sessionLock.workflow', defaultMessage: '工作流:' })}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{lockInfo.executionName || lockInfo.reason}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lockInfo.currentStep && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'sessionLock.currentStep', defaultMessage: '当前步骤:' })}
|
||||||
|
</span>
|
||||||
|
<span>{lockInfo.currentStep}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lockInfo.progress !== undefined && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={lockInfo.progress} className="h-2" />
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{lockInfo.progress}% {formatMessage({ id: 'sessionLock.completed', defaultMessage: '完成' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning alert */}
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
{formatMessage({ id: 'sessionLock.warning', defaultMessage: '注意' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'sessionLock.warningMessage',
|
||||||
|
defaultMessage: '继续输入将解锁会话,可能会影响正在执行的工作流。'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
|
<AlertDialogCancel onClick={onClose} className="w-full sm:w-auto">
|
||||||
|
{formatMessage({ id: 'sessionLock.cancel', defaultMessage: '取消,等待完成' })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="w-full sm:w-auto bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'sessionLock.confirm', defaultMessage: '解锁并继续输入' })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionLockConfirmDialog;
|
||||||
154
ccw/frontend/src/hooks/useOrchestratorExecution.ts
Normal file
154
ccw/frontend/src/hooks/useOrchestratorExecution.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<ExecuteInSessionResponse> => {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Orchestrator",
|
"title": "Workflow Template Editor",
|
||||||
"description": "Manage and execute workflow flows",
|
"description": "Create and edit workflow templates",
|
||||||
"flow": {
|
"flow": {
|
||||||
"title": "Flow",
|
"title": "Flow",
|
||||||
"flows": "Flows",
|
"flows": "Flows",
|
||||||
@@ -98,6 +98,9 @@
|
|||||||
"couldNotDuplicate": "Could not duplicate the flow",
|
"couldNotDuplicate": "Could not duplicate the flow",
|
||||||
"flowExported": "Flow exported as JSON file",
|
"flowExported": "Flow exported as JSON file",
|
||||||
"noFlowToExport": "Create or load a flow first",
|
"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",
|
"executionFailed": "Execution Failed",
|
||||||
"couldNotExecute": "Could not start flow execution"
|
"couldNotExecute": "Could not start flow execution"
|
||||||
},
|
},
|
||||||
@@ -152,8 +155,7 @@
|
|||||||
"export": "Export Flow",
|
"export": "Export Flow",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"importTemplate": "Import Template",
|
"importTemplate": "Import Template",
|
||||||
"runWorkflow": "Run Workflow",
|
"sendToTerminal": "Send to Terminal",
|
||||||
"monitor": "Monitor",
|
|
||||||
"savedFlows": "Saved Flows ({count})",
|
"savedFlows": "Saved Flows ({count})",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noSavedFlows": "No saved flows",
|
"noSavedFlows": "No saved flows",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "编排器",
|
"title": "工作流模板编辑器",
|
||||||
"description": "管理和执行工作流",
|
"description": "创建和编辑工作流模板",
|
||||||
"flow": {
|
"flow": {
|
||||||
"title": "流程",
|
"title": "流程",
|
||||||
"flows": "流程列表",
|
"flows": "流程列表",
|
||||||
@@ -98,6 +98,9 @@
|
|||||||
"couldNotDuplicate": "无法复制流程",
|
"couldNotDuplicate": "无法复制流程",
|
||||||
"flowExported": "流程已导出为 JSON 文件",
|
"flowExported": "流程已导出为 JSON 文件",
|
||||||
"noFlowToExport": "请先创建或加载流程",
|
"noFlowToExport": "请先创建或加载流程",
|
||||||
|
"saveBeforeExecute": "请先保存流程",
|
||||||
|
"flowSent": "流程已发送",
|
||||||
|
"sentToTerminal": "\"{name}\" 已发送到终端执行",
|
||||||
"executionFailed": "执行失败",
|
"executionFailed": "执行失败",
|
||||||
"couldNotExecute": "无法启动流程执行"
|
"couldNotExecute": "无法启动流程执行"
|
||||||
},
|
},
|
||||||
@@ -152,8 +155,7 @@
|
|||||||
"export": "导出流程",
|
"export": "导出流程",
|
||||||
"templates": "模板",
|
"templates": "模板",
|
||||||
"importTemplate": "导入模板",
|
"importTemplate": "导入模板",
|
||||||
"runWorkflow": "运行流程",
|
"sendToTerminal": "发送到终端执行",
|
||||||
"monitor": "监控",
|
|
||||||
"savedFlows": "已保存的流程 ({count})",
|
"savedFlows": "已保存的流程 ({count})",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noSavedFlows": "无已保存的流程",
|
"noSavedFlows": "无已保存的流程",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
|||||||
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 { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
||||||
|
import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel';
|
||||||
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||||
@@ -128,6 +129,16 @@ export function TerminalDashboardPage() {
|
|||||||
>
|
>
|
||||||
<InspectorContent />
|
<InspectorContent />
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
|
|
||||||
|
<FloatingPanel
|
||||||
|
isOpen={activePanel === 'execution'}
|
||||||
|
onClose={closePanel}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.toolbar.executionMonitor', defaultMessage: 'Execution Monitor' })}
|
||||||
|
side="right"
|
||||||
|
width={380}
|
||||||
|
>
|
||||||
|
<ExecutionMonitorPanel />
|
||||||
|
</FloatingPanel>
|
||||||
</AssociationHighlightProvider>
|
</AssociationHighlightProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Flow Toolbar Component
|
// 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 { useState, useCallback, useEffect } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -15,8 +16,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Library,
|
Library,
|
||||||
Play,
|
Terminal,
|
||||||
Activity,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -24,8 +24,6 @@ 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';
|
||||||
import { useFlowStore, toast } from '@/stores';
|
import { useFlowStore, toast } from '@/stores';
|
||||||
import { useExecutionStore } from '@/stores/executionStore';
|
|
||||||
import { useExecuteFlow } from '@/hooks/useFlows';
|
|
||||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||||
import type { Flow } from '@/types/flow';
|
import type { Flow } from '@/types/flow';
|
||||||
|
|
||||||
@@ -36,6 +34,7 @@ interface FlowToolbarProps {
|
|||||||
|
|
||||||
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
||||||
const [flowName, setFlowName] = useState('');
|
const [flowName, setFlowName] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -55,18 +54,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
||||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
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
|
// Load flows on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFlows();
|
fetchFlows();
|
||||||
@@ -194,24 +181,26 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
toast.success(formatMessage({ id: 'orchestrator.notifications.flowExported' }), formatMessage({ id: 'orchestrator.notifications.flowExported' }));
|
toast.success(formatMessage({ id: 'orchestrator.notifications.flowExported' }), formatMessage({ id: 'orchestrator.notifications.flowExported' }));
|
||||||
}, [currentFlow]);
|
}, [currentFlow]);
|
||||||
|
|
||||||
// Handle run workflow
|
// Handle send to terminal execution
|
||||||
const handleRun = useCallback(async () => {
|
const handleSendToTerminal = useCallback(async () => {
|
||||||
if (!currentFlow) return;
|
if (!currentFlow) {
|
||||||
try {
|
toast.error(formatMessage({ id: 'orchestrator.notifications.noFlow' }), formatMessage({ id: 'orchestrator.notifications.saveBeforeExecute' }));
|
||||||
// Open monitor panel automatically
|
return;
|
||||||
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' }));
|
|
||||||
}
|
}
|
||||||
}, [currentFlow, executeFlow, startExecution, setMonitorPanelOpen]);
|
|
||||||
|
|
||||||
// Handle monitor toggle
|
// Save flow first if modified
|
||||||
const handleToggleMonitor = useCallback(() => {
|
if (isModified) {
|
||||||
setMonitorPanelOpen(!isMonitorPanelOpen);
|
const saved = await saveFlow();
|
||||||
}, [isMonitorPanelOpen, setMonitorPanelOpen]);
|
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 (
|
return (
|
||||||
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
||||||
@@ -346,29 +335,15 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
{/* Run & Monitor Group */}
|
{/* Execute in Terminal */}
|
||||||
<Button
|
|
||||||
variant={isMonitorPanelOpen ? 'secondary' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleToggleMonitor}
|
|
||||||
title={formatMessage({ id: 'orchestrator.monitor.toggleMonitor' })}
|
|
||||||
>
|
|
||||||
<Activity className={cn('w-4 h-4 mr-1', (isExecuting || isPaused) && 'text-primary animate-pulse')} />
|
|
||||||
{formatMessage({ id: 'orchestrator.toolbar.monitor' })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRun}
|
onClick={handleSendToTerminal}
|
||||||
disabled={!currentFlow || isExecuting || isPaused || executeFlow.isPending}
|
disabled={!currentFlow}
|
||||||
>
|
>
|
||||||
{executeFlow.isPending ? (
|
<Terminal className="w-4 h-4 mr-1" />
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
{formatMessage({ id: 'orchestrator.toolbar.sendToTerminal' })}
|
||||||
) : (
|
|
||||||
<Play className="w-4 h-4 mr-1" />
|
|
||||||
)}
|
|
||||||
{formatMessage({ id: 'orchestrator.toolbar.runWorkflow' })}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Orchestrator Page
|
// 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 { useEffect, useState, useCallback } from 'react';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { useFlowStore } from '@/stores';
|
import { useFlowStore } from '@/stores';
|
||||||
import { useExecutionStore } from '@/stores/executionStore';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { FlowCanvas } from './FlowCanvas';
|
import { FlowCanvas } from './FlowCanvas';
|
||||||
import { LeftSidebar } from './LeftSidebar';
|
import { LeftSidebar } from './LeftSidebar';
|
||||||
import { PropertyPanel } from './PropertyPanel';
|
import { PropertyPanel } from './PropertyPanel';
|
||||||
import { FlowToolbar } from './FlowToolbar';
|
import { FlowToolbar } from './FlowToolbar';
|
||||||
import { TemplateLibrary } from './TemplateLibrary';
|
import { TemplateLibrary } from './TemplateLibrary';
|
||||||
import { ExecutionMonitor } from './ExecutionMonitor';
|
|
||||||
|
|
||||||
export function OrchestratorPage() {
|
export function OrchestratorPage() {
|
||||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||||
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||||
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||||
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
|
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
|
||||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
|
||||||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||||
|
|
||||||
// Load flows on mount
|
// Load flows on mount
|
||||||
@@ -60,15 +58,12 @@ export function OrchestratorPage() {
|
|||||||
<FlowCanvas className="absolute inset-0" />
|
<FlowCanvas className="absolute inset-0" />
|
||||||
|
|
||||||
{/* Property Panel as overlay - only shown when a node is selected */}
|
{/* Property Panel as overlay - only shown when a node is selected */}
|
||||||
{!isMonitorPanelOpen && isPropertyPanelOpen && (
|
{isPropertyPanelOpen && (
|
||||||
<div className="absolute top-2 right-2 bottom-2 z-10">
|
<div className="absolute top-2 right-2 bottom-2 z-10">
|
||||||
<PropertyPanel className="h-full" />
|
<PropertyPanel className="h-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Execution Monitor Panel (Right) */}
|
|
||||||
<ExecutionMonitor />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template Library Dialog */}
|
{/* Template Library Dialog */}
|
||||||
|
|||||||
291
ccw/frontend/src/stores/executionMonitorStore.ts
Normal file
291
ccw/frontend/src/stores/executionMonitorStore.ts
Normal file
@@ -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<string, ExecutionInfo>;
|
||||||
|
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<ExecutionMonitorStore>()(
|
||||||
|
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;
|
||||||
@@ -28,6 +28,8 @@ export interface TerminalMeta {
|
|||||||
status: TerminalStatus;
|
status: TerminalStatus;
|
||||||
/** Number of unread alerts (errors, warnings) */
|
/** Number of unread alerts (errors, warnings) */
|
||||||
alertCount: number;
|
alertCount: number;
|
||||||
|
/** Session tag for grouping (e.g., "gemini-143052") */
|
||||||
|
tag?: string;
|
||||||
/** Whether the session is locked (executing a workflow) */
|
/** Whether the session is locked (executing a workflow) */
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
/** Reason for the lock (e.g., workflow name) */
|
/** Reason for the lock (e.g., workflow name) */
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
* - POST /api/orchestrator/flows/:id/duplicate - Duplicate flow
|
* - POST /api/orchestrator/flows/:id/duplicate - Duplicate flow
|
||||||
*
|
*
|
||||||
* Execution Control Endpoints:
|
* Execution Control Endpoints:
|
||||||
* - POST /api/orchestrator/flows/:id/execute - Start flow execution
|
* - POST /api/orchestrator/flows/:id/execute - Start flow execution
|
||||||
* - POST /api/orchestrator/executions/:execId/pause - Pause execution
|
* - POST /api/orchestrator/flows/:id/execute-in-session - Start flow execution in PTY session
|
||||||
* - POST /api/orchestrator/executions/:execId/resume - Resume execution
|
* - POST /api/orchestrator/executions/:execId/pause - Pause execution
|
||||||
* - POST /api/orchestrator/executions/:execId/stop - Stop execution
|
* - POST /api/orchestrator/executions/:execId/resume - Resume execution
|
||||||
* - GET /api/orchestrator/executions/:execId - Get execution state
|
* - POST /api/orchestrator/executions/:execId/stop - Stop execution
|
||||||
* - GET /api/orchestrator/executions/:execId/logs - Get execution logs
|
* - GET /api/orchestrator/executions/:execId - Get execution state
|
||||||
|
* - GET /api/orchestrator/executions/:execId/logs - Get execution logs
|
||||||
*
|
*
|
||||||
* Template Management Endpoints:
|
* Template Management Endpoints:
|
||||||
* - GET /api/orchestrator/templates - List local + builtin templates
|
* - GET /api/orchestrator/templates - List local + builtin templates
|
||||||
@@ -1277,6 +1278,134 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==== EXECUTE FLOW IN SESSION ====
|
||||||
|
// POST /api/orchestrator/flows/:id/execute-in-session
|
||||||
|
if (pathname.match(/^\/api\/orchestrator\/flows\/[^/]+\/execute-in-session$/) && req.method === 'POST') {
|
||||||
|
const flowId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!flowId || !isValidFlowId(flowId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid flow ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const {
|
||||||
|
sessionConfig,
|
||||||
|
sessionKey: existingSessionKey,
|
||||||
|
variables: inputVariables,
|
||||||
|
stepTimeout,
|
||||||
|
errorStrategy = 'pause'
|
||||||
|
} = body as {
|
||||||
|
sessionConfig?: {
|
||||||
|
tool?: string;
|
||||||
|
model?: string;
|
||||||
|
preferredShell?: string;
|
||||||
|
};
|
||||||
|
sessionKey?: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
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<string, NodeExecutionState> = {};
|
||||||
|
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 ====
|
// ==== PAUSE EXECUTION ====
|
||||||
// POST /api/orchestrator/executions/:execId/pause
|
// POST /api/orchestrator/executions/:execId/pause
|
||||||
if (pathname.match(/^\/api\/orchestrator\/executions\/[^/]+\/pause$/) && req.method === 'POST') {
|
if (pathname.match(/^\/api\/orchestrator\/executions\/[^/]+\/pause$/) && req.method === 'POST') {
|
||||||
|
|||||||
Reference in New Issue
Block a user