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:
catlog22
2026-02-20 21:49:05 +08:00
parent b38750f0cf
commit f8ff9eaa7f
13 changed files with 1156 additions and 234 deletions

View File

@@ -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 (
<>

View File

@@ -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;

View File

@@ -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<TerminalStatus, string> = {
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<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(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<string, { tag: string; sessionIds: string[] }> = {};
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<string, string> = {};
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 (
<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">
<Folder className="w-6 h-6 opacity-30" />
<Terminal className="w-6 h-6 opacity-30" />
<p className="text-xs text-center">
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
</p>
<p className="text-[10px] text-muted-foreground/70">
Click "New Session" to create one
</p>
</div>
</div>
);
@@ -145,27 +139,18 @@ export function SessionGroupTree() {
return (
<div className="flex flex-col h-full">
{/* Create group button */}
<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 */}
{/* Session list grouped by tag */}
<div className="flex-1 overflow-y-auto">
<DragDropContext onDragEnd={handleDragEnd}>
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.id);
{sessionsByTag.map((group) => {
const isExpanded = expandedTags.has(group.tag);
const isUntagged = group.tag === '__untagged__';
const displayName = isUntagged ? 'Other Sessions' : group.tag;
return (
<div key={group.id} className="border-b border-border/50 last:border-b-0">
{/* Group header */}
<div key={group.tag} className="border-b border-border/50 last:border-b-0">
{/* Tag header */}
<button
onClick={() => toggleGroup(group.id)}
onClick={() => toggleTag(group.tag)}
className={cn(
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
'hover:bg-muted/50 transition-colors text-sm'
@@ -177,61 +162,29 @@ export function SessionGroupTree() {
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>
<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: droppable session list */}
{/* Expanded: session list */}
{isExpanded && (
<Droppable droppableId={group.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
'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) => {
<div className="pb-1">
{group.sessionIds.map((sessionId) => {
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}
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',
dragSnapshot.isDragging && 'bg-muted shadow-md'
activeTerminalId === sessionId && 'bg-primary/10 text-primary'
)}
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])}
@@ -242,20 +195,13 @@ export function SessionGroupTree() {
{sessionNames[sessionId] ?? sessionId}
</span>
</div>
)}
</Draggable>
);
})
)}
{provided.placeholder}
})}
</div>
)}
</Droppable>
)}
</div>
);
})}
</DragDropContext>
</div>
</div>
);

View File

@@ -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;

View 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();
},
});
}

View File

@@ -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",

View File

@@ -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": "无已保存的流程",

View File

@@ -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() {
>
<InspectorContent />
</FloatingPanel>
<FloatingPanel
isOpen={activePanel === 'execution'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.executionMonitor', defaultMessage: 'Execution Monitor' })}
side="right"
width={380}
>
<ExecutionMonitorPanel />
</FloatingPanel>
</AssociationHighlightProvider>
</div>
);

View File

@@ -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 (
<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" />
{/* Run & Monitor Group */}
<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>
{/* Execute in Terminal */}
<Button
variant="default"
size="sm"
onClick={handleRun}
disabled={!currentFlow || isExecuting || isPaused || executeFlow.isPending}
onClick={handleSendToTerminal}
disabled={!currentFlow}
>
{executeFlow.isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Play className="w-4 h-4 mr-1" />
)}
{formatMessage({ id: 'orchestrator.toolbar.runWorkflow' })}
<Terminal className="w-4 h-4 mr-1" />
{formatMessage({ id: 'orchestrator.toolbar.sendToTerminal' })}
</Button>
<div className="w-px h-6 bg-border" />

View File

@@ -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() {
<FlowCanvas className="absolute inset-0" />
{/* 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">
<PropertyPanel className="h-full" />
</div>
)}
</div>
{/* Execution Monitor Panel (Right) */}
<ExecutionMonitor />
</div>
{/* Template Library Dialog */}

View 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;

View File

@@ -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) */

View File

@@ -12,6 +12,7 @@
*
* Execution Control Endpoints:
* - 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
@@ -1277,6 +1278,134 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
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 ====
// POST /api/orchestrator/executions/:execId/pause
if (pathname.match(/^\/api\/orchestrator\/executions\/[^/]+\/pause$/) && req.method === 'POST') {