Add orchestrator types and error handling configurations

- Introduced new TypeScript types for orchestrator functionality, including `SessionStrategy`, `ErrorHandlingStrategy`, and `OrchestrationStep`.
- Defined interfaces for `OrchestrationPlan` and `ManualOrchestrationParams` to facilitate orchestration management.
- Added a new PNG image file for visual representation.
- Created a placeholder file named 'nul' for future use.
This commit is contained in:
catlog22
2026-02-14 12:54:08 +08:00
parent cdb240d2c2
commit 4d22ae4b2f
56 changed files with 4767 additions and 425 deletions

View File

@@ -18,7 +18,6 @@ import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
useQueueExecutionStore,
selectExecutionStats,
useTerminalPanelStore,
} from '@/stores';
import type { QueueExecution } from '@/stores/queueExecutionStore';
@@ -47,7 +46,16 @@ function ExecutionEmptyState() {
function ExecutionStatsCards() {
const { formatMessage } = useIntl();
const stats = useQueueExecutionStore(selectExecutionStats);
const executions = useQueueExecutionStore((s) => s.executions);
const stats = useMemo(() => {
const all = Object.values(executions);
return {
running: all.filter((e) => e.status === 'running').length,
completed: all.filter((e) => e.status === 'completed').length,
failed: all.filter((e) => e.status === 'failed').length,
total: all.length,
};
}, [executions]);
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">

View File

@@ -3,15 +3,17 @@
// ========================================
// Right-side issue detail drawer with Overview/Solutions/History tabs
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal } from 'lucide-react';
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal, Play } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
import { createCliSession } from '@/lib/api';
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// ========== Types ==========
export interface IssueDrawerProps {
@@ -44,8 +46,15 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
const { formatMessage } = useIntl();
const openTerminal = useOpenTerminalPanel();
const projectPath = useWorkflowStore(selectProjectPath);
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
// Execution binding state
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
const [selectedTool, setSelectedTool] = useState<string>('claude');
const [selectedMode, setSelectedMode] = useState<'analysis' | 'write'>('analysis');
const [isLaunching, setIsLaunching] = useState(false);
// Reset to initial tab when opening/switching issues
useEffect(() => {
if (!isOpen || !issue) return;
@@ -62,6 +71,23 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
const handleLaunchSession = useCallback(async () => {
if (!projectPath || !issue || isLaunching) return;
setIsLaunching(true);
try {
const created = await createCliSession(
{ workingDir: projectPath, tool: selectedTool },
projectPath
);
openTerminal(created.session.sessionKey);
onClose();
} catch (err) {
console.error('[IssueDrawer] createCliSession failed:', err);
} finally {
setIsLaunching(false);
}
}, [projectPath, issue, isLaunching, selectedTool, openTerminal, onClose]);
if (!issue || !isOpen) {
return null;
}
@@ -225,20 +251,70 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
)}
</TabsContent>
{/* Terminal Tab - Link to Terminal Panel */}
{/* Terminal Tab - Execution Binding */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Terminal className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
<Button
onClick={() => {
openTerminal(issue.id);
onClose();
}}
>
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
</Button>
<div className="space-y-5">
{/* Tool Selection */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'issues.terminal.exec.tool' })}
</h3>
<div className="flex flex-wrap gap-2">
{CLI_TOOLS.map((t) => (
<Button
key={t}
variant={selectedTool === t ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedTool(t)}
>
{t}
</Button>
))}
</div>
</div>
{/* Mode Selection */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'issues.terminal.exec.mode' })}
</h3>
<div className="flex gap-2">
{(['analysis', 'write'] as const).map((m) => (
<Button
key={m}
variant={selectedMode === m ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMode(m)}
>
{m}
</Button>
))}
</div>
</div>
{/* Launch Action */}
<div className="pt-2 space-y-2">
<Button
className="w-full gap-2"
disabled={isLaunching || !projectPath}
onClick={handleLaunchSession}
>
{isLaunching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{formatMessage({ id: 'issues.terminal.launch' })}
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
openTerminal(issue.id);
onClose();
}}
>
<Terminal className="h-4 w-4" />
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
</Button>
</div>
</div>
</TabsContent>

View File

@@ -210,7 +210,7 @@ export function QueueItemExecutor({ item, className }: QueueItemExecutorProps) {
const flowForStore: Flow = {
...flowDto,
version: Number.isFinite(parsedVersion) ? parsedVersion : 1,
} as Flow;
} as unknown as Flow;
useFlowStore.getState().setCurrentFlow(flowForStore);
// Execute the flow

View File

@@ -17,6 +17,8 @@ import {
Terminal,
Bell,
Clock,
Monitor,
SquareTerminal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -25,6 +27,7 @@ import { useTheme } from '@/hooks';
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
import { useNotificationStore } from '@/stores';
import { useTerminalPanelStore, selectTerminalCount } from '@/stores/terminalPanelStore';
export interface HeaderProps {
/** Callback for refresh action */
@@ -43,6 +46,8 @@ export function Header({
const { formatMessage } = useIntl();
const { isDark, toggleTheme } = useTheme();
const activeCliCount = useCliStreamStore(selectActiveExecutionCount);
const terminalCount = useTerminalPanelStore(selectTerminalCount);
const toggleTerminalPanel = useTerminalPanelStore((s) => s.togglePanel);
// Notification state for badge
const persistentNotifications = useNotificationStore((state) => state.persistentNotifications);
@@ -106,6 +111,35 @@ export function Header({
)}
</Button>
{/* Terminal Panel toggle */}
<Button
variant="ghost"
size="sm"
onClick={toggleTerminalPanel}
className="gap-2"
>
<SquareTerminal className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'home.terminalPanel.title' })}</span>
{terminalCount > 0 && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
{terminalCount}
</Badge>
)}
</Button>
{/* CLI Viewer page link */}
<Button
variant="ghost"
size="sm"
asChild
className="gap-2"
>
<Link to="/cli-viewer" className="inline-flex items-center gap-2">
<Monitor className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.main.cliViewer' })}</span>
</Link>
</Button>
{/* Workspace selector */}
<WorkspaceSelector />

View File

@@ -0,0 +1,411 @@
// ========================================
// Orchestrator Control Panel Component
// ========================================
// Displays orchestration plan progress with step list, status badges,
// and conditional control buttons (pause/resume/retry/skip/stop).
import { memo, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Circle,
Loader2,
CheckCircle2,
XCircle,
SkipForward,
Pause,
Play,
Square,
RotateCcw,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import {
useOrchestratorStore,
selectPlan,
type StepRunState,
} from '@/stores/orchestratorStore';
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
// ========== Props ==========
interface OrchestratorControlPanelProps {
/** Identifies which orchestration plan to display/control */
planId: string;
}
// ========== Status Badge ==========
const statusBadgeConfig: Record<
OrchestrationStatus,
{ labelKey: string; className: string }
> = {
pending: {
labelKey: 'orchestrator.status.pending',
className: 'bg-muted text-muted-foreground border-border',
},
running: {
labelKey: 'orchestrator.status.running',
className: 'bg-primary/10 text-primary border-primary/50',
},
paused: {
labelKey: 'orchestrator.status.paused',
className: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
},
completed: {
labelKey: 'orchestrator.status.completed',
className: 'bg-green-500/10 text-green-500 border-green-500/50',
},
failed: {
labelKey: 'orchestrator.status.failed',
className: 'bg-destructive/10 text-destructive border-destructive/50',
},
cancelled: {
labelKey: 'orchestrator.controlPanel.cancelled',
className: 'bg-muted text-muted-foreground border-border',
},
};
function StatusBadge({ status }: { status: OrchestrationStatus }) {
const { formatMessage } = useIntl();
const config = statusBadgeConfig[status];
return (
<span
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium border',
config.className
)}
>
{formatMessage({ id: config.labelKey })}
</span>
);
}
// ========== Step Status Icon ==========
function StepStatusIcon({ status }: { status: StepStatus }) {
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 'skipped':
return <SkipForward className="w-4 h-4 text-muted-foreground" />;
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 {
stepId: string;
name: string;
runState: StepRunState;
isCurrent: boolean;
}
function StepListItem({ stepId: _stepId, name, runState, isCurrent }: StepListItemProps) {
return (
<div
className={cn(
'flex items-start gap-3 px-3 py-2 rounded-md transition-colors',
isCurrent && runState.status === 'running' && 'bg-primary/5 border border-primary/20',
runState.status === 'failed' && 'bg-destructive/5',
!isCurrent && runState.status !== 'failed' && 'hover:bg-muted/50'
)}
>
{/* Status icon */}
<div className="pt-0.5 shrink-0">
<StepStatusIcon status={runState.status} />
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<span
className={cn(
'text-sm font-medium truncate block',
runState.status === 'completed' && 'text-muted-foreground',
runState.status === 'skipped' && 'text-muted-foreground line-through',
runState.status === 'running' && 'text-foreground',
runState.status === 'failed' && 'text-destructive'
)}
>
{name}
</span>
{/* Error message inline */}
{runState.status === 'failed' && runState.error && (
<div className="flex items-start gap-1.5 mt-1">
<AlertCircle className="w-3.5 h-3.5 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive/80 break-words">
{runState.error}
</span>
</div>
)}
{/* Retry count indicator */}
{runState.retryCount > 0 && (
<span className="text-xs text-muted-foreground mt-0.5 block">
Retry #{runState.retryCount}
</span>
)}
</div>
</div>
);
}
// ========== Control Buttons ==========
interface ControlBarProps {
planId: string;
status: OrchestrationStatus;
failedStepId: string | null;
}
function ControlBar({ planId, status, failedStepId }: ControlBarProps) {
const { formatMessage } = useIntl();
const pauseOrchestration = useOrchestratorStore((s) => s.pauseOrchestration);
const resumeOrchestration = useOrchestratorStore((s) => s.resumeOrchestration);
const stopOrchestration = useOrchestratorStore((s) => s.stopOrchestration);
const retryStep = useOrchestratorStore((s) => s.retryStep);
const skipStep = useOrchestratorStore((s) => s.skipStep);
if (status === 'completed') {
return (
<div className="px-4 py-3 border-t border-border">
<p className="text-sm text-green-500 text-center">
{formatMessage({ id: 'orchestrator.controlPanel.completedMessage' })}
</p>
</div>
);
}
if (status === 'failed' || status === 'cancelled') {
return (
<div className="px-4 py-3 border-t border-border">
<p className="text-sm text-destructive text-center">
{formatMessage({ id: 'orchestrator.controlPanel.failedMessage' })}
</p>
</div>
);
}
// Determine if paused due to error (has a failed step)
const isPausedOnError = status === 'paused' && failedStepId !== null;
const isPausedByUser = status === 'paused' && failedStepId === null;
return (
<div className="px-4 py-3 border-t border-border flex items-center gap-2">
{/* Running state: Pause + Stop */}
{status === 'running' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => pauseOrchestration(planId)}
className="gap-1.5"
>
<Pause className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.execution.pause' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => stopOrchestration(planId)}
className="gap-1.5"
>
<Square className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.execution.stop' })}
</Button>
</>
)}
{/* User-paused state: Resume + Stop */}
{isPausedByUser && (
<>
<Button
variant="outline"
size="sm"
onClick={() => resumeOrchestration(planId)}
className="gap-1.5"
>
<Play className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.execution.resume' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => stopOrchestration(planId)}
className="gap-1.5"
>
<Square className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.execution.stop' })}
</Button>
</>
)}
{/* Error-paused state: Retry + Skip + Stop */}
{isPausedOnError && failedStepId && (
<>
<Button
variant="outline"
size="sm"
onClick={() => retryStep(planId, failedStepId)}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.controlPanel.retry' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skipStep(planId, failedStepId)}
className="gap-1.5"
>
<SkipForward className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.controlPanel.skip' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => stopOrchestration(planId)}
className="gap-1.5"
>
<Square className="w-3.5 h-3.5" />
{formatMessage({ id: 'orchestrator.execution.stop' })}
</Button>
</>
)}
</div>
);
}
// ========== Main Component ==========
/**
* OrchestratorControlPanel displays plan progress and provides
* pause/resume/retry/skip/stop controls for active orchestrations.
*
* Layout:
* - Header: Plan name + status badge + progress count
* - Progress bar: Completed/total ratio
* - Step list: Scrollable with status icons and error messages
* - Control bar: Conditional buttons based on orchestration status
*/
export const OrchestratorControlPanel = memo(function OrchestratorControlPanel({
planId,
}: OrchestratorControlPanelProps) {
const { formatMessage } = useIntl();
const runState = useOrchestratorStore(selectPlan(planId));
// Compute progress counts
const { completedCount, totalCount, progress } = useMemo(() => {
if (!runState) return { completedCount: 0, totalCount: 0, progress: 0 };
const statuses = Object.values(runState.stepStatuses);
const total = statuses.length;
const completed = statuses.filter(
(s) => s.status === 'completed' || s.status === 'skipped'
).length;
return {
completedCount: completed,
totalCount: total,
progress: total > 0 ? (completed / total) * 100 : 0,
};
}, [runState]);
// Find the first failed step ID (for error-paused controls)
const failedStepId = useMemo(() => {
if (!runState) return null;
for (const [stepId, stepState] of Object.entries(runState.stepStatuses)) {
if (stepState.status === 'failed') return stepId;
}
return null;
}, [runState]);
// No plan found
if (!runState) {
return (
<div className="p-4 border rounded-lg border-border">
<p className="text-sm text-muted-foreground text-center">
{formatMessage({ id: 'orchestrator.controlPanel.noPlan' })}
</p>
</div>
);
}
const { plan, status, currentStepIndex } = runState;
return (
<div className="border rounded-lg border-border bg-card flex flex-col">
{/* Header: Plan name + Status badge + Progress count */}
<div className="p-4 border-b border-border space-y-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-foreground truncate flex-1">
{plan.name}
</h3>
<StatusBadge status={status} />
<span className="text-sm text-muted-foreground tabular-nums shrink-0">
{formatMessage(
{ id: 'orchestrator.controlPanel.progress' },
{ completed: completedCount, total: totalCount }
)}
</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-300 ease-out',
status === 'failed' && 'bg-destructive',
status === 'completed' && 'bg-green-500',
status === 'cancelled' && 'bg-muted-foreground',
(status === 'running' || status === 'pending') && 'bg-primary',
status === 'paused' && 'bg-amber-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Step list */}
<div className="flex-1 overflow-y-auto max-h-80 p-2 space-y-1">
{plan.steps.map((step, index) => {
const stepState = runState.stepStatuses[step.id];
if (!stepState) return null;
return (
<StepListItem
key={step.id}
stepId={step.id}
name={step.name}
runState={stepState}
isCurrent={index === currentStepIndex && status === 'running'}
/>
);
})}
</div>
{/* Control bar */}
<ControlBar
planId={planId}
status={status}
failedStepId={failedStepId}
/>
</div>
);
});
OrchestratorControlPanel.displayName = 'OrchestratorControlPanel';

View File

@@ -4,5 +4,6 @@
export { ExecutionHeader } from './ExecutionHeader';
export { NodeExecutionChain } from './NodeExecutionChain';
export { OrchestratorControlPanel } from './OrchestratorControlPanel';
export { ToolCallCard, type ToolCallCardProps } from './ToolCallCard';
export { ToolCallsTimeline, type ToolCallsTimelineProps } from './ToolCallsTimeline';

View File

@@ -10,14 +10,14 @@ import { Flowchart } from './Flowchart';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/Tabs';
import type { NormalizedTask } from '@/lib/api';
import type { NormalizedTask, LiteTask } from '@/lib/api';
import { buildFlowControl } from '@/lib/api';
import type { TaskData } from '@/types/store';
// ========== Types ==========
export interface TaskDrawerProps {
task: NormalizedTask | TaskData | null;
task: NormalizedTask | TaskData | LiteTask | null;
isOpen: boolean;
onClose: () => void;
}

View File

@@ -0,0 +1,285 @@
// ========================================
// TeamArtifacts Component
// ========================================
// Displays team artifacts grouped by pipeline phase (plan/impl/test/review)
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
FileText,
ClipboardList,
Code2,
TestTube2,
SearchCheck,
Database,
Package,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import MarkdownModal from '@/components/shared/MarkdownModal';
import { fetchFileContent } from '@/lib/api';
import { cn } from '@/lib/utils';
import type { TeamMessage } from '@/types/team';
// ========================================
// Types
// ========================================
type ArtifactPhase = 'plan' | 'impl' | 'test' | 'review';
interface Artifact {
id: string;
message: TeamMessage;
phase: ArtifactPhase;
ref?: string;
}
interface TeamArtifactsProps {
messages: TeamMessage[];
}
// ========================================
// Constants
// ========================================
const PHASE_MESSAGE_MAP: Record<string, ArtifactPhase> = {
plan_ready: 'plan',
plan_approved: 'plan',
plan_revision: 'plan',
impl_complete: 'impl',
impl_progress: 'impl',
test_result: 'test',
review_result: 'review',
};
const PHASE_CONFIG: Record<ArtifactPhase, { icon: typeof FileText; color: string }> = {
plan: { icon: ClipboardList, color: 'text-blue-500' },
impl: { icon: Code2, color: 'text-green-500' },
test: { icon: TestTube2, color: 'text-amber-500' },
review: { icon: SearchCheck, color: 'text-purple-500' },
};
const PHASE_ORDER: ArtifactPhase[] = ['plan', 'impl', 'test', 'review'];
// ========================================
// Helpers
// ========================================
function extractArtifacts(messages: TeamMessage[]): Artifact[] {
const artifacts: Artifact[] = [];
for (const msg of messages) {
const phase = PHASE_MESSAGE_MAP[msg.type];
if (!phase) continue;
// Include messages that have ref OR data (inline artifacts)
if (!msg.ref && !msg.data) continue;
artifacts.push({
id: msg.id,
message: msg,
phase,
ref: msg.ref,
});
}
return artifacts;
}
function groupByPhase(artifacts: Artifact[]): Record<ArtifactPhase, Artifact[]> {
const groups: Record<ArtifactPhase, Artifact[]> = {
plan: [],
impl: [],
test: [],
review: [],
};
for (const a of artifacts) {
groups[a.phase].push(a);
}
return groups;
}
function getContentType(ref: string): 'markdown' | 'json' | 'text' {
if (ref.endsWith('.json')) return 'json';
if (ref.endsWith('.md')) return 'markdown';
return 'text';
}
function formatTimestamp(ts: string): string {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
}
// ========================================
// Sub-components
// ========================================
function ArtifactCard({
artifact,
onView,
}: {
artifact: Artifact;
onView: (artifact: Artifact) => void;
}) {
const { formatMessage } = useIntl();
const config = PHASE_CONFIG[artifact.phase];
const Icon = config.icon;
return (
<Card
className="cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => onView(artifact)}
>
<CardContent className="p-3 flex items-start gap-3">
<div className={cn('mt-0.5', config.color)}>
{artifact.ref ? (
<Icon className="w-4 h-4" />
) : (
<Database className="w-4 h-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{artifact.message.summary}</p>
<div className="flex items-center gap-2 mt-1">
{artifact.ref ? (
<span className="text-xs text-muted-foreground font-mono truncate">
{artifact.ref.split('/').pop()}
</span>
) : (
<Badge variant="outline" className="text-[10px]">
{formatMessage({ id: 'team.artifacts.noRef' })}
</Badge>
)}
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{formatTimestamp(artifact.message.ts)}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
function PhaseGroup({
phase,
artifacts,
onView,
}: {
phase: ArtifactPhase;
artifacts: Artifact[];
onView: (artifact: Artifact) => void;
}) {
const { formatMessage } = useIntl();
if (artifacts.length === 0) return null;
const config = PHASE_CONFIG[phase];
const Icon = config.icon;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className={cn('w-4 h-4', config.color)} />
<h4 className="text-sm font-medium">
{formatMessage({ id: `team.artifacts.${phase}` })}
</h4>
<Badge variant="secondary" className="text-[10px]">
{artifacts.length}
</Badge>
</div>
<div className="space-y-2 pl-6">
{artifacts.map((artifact) => (
<ArtifactCard key={artifact.id} artifact={artifact} onView={onView} />
))}
</div>
</div>
);
}
// ========================================
// Main Component
// ========================================
export function TeamArtifacts({ messages }: TeamArtifactsProps) {
const { formatMessage } = useIntl();
const [selectedArtifact, setSelectedArtifact] = React.useState<Artifact | null>(null);
const [modalContent, setModalContent] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const artifacts = React.useMemo(() => extractArtifacts(messages), [messages]);
const grouped = React.useMemo(() => groupByPhase(artifacts), [artifacts]);
const handleView = React.useCallback(async (artifact: Artifact) => {
setSelectedArtifact(artifact);
if (artifact.ref) {
setIsLoading(true);
setModalContent('');
try {
const result = await fetchFileContent(artifact.ref);
setModalContent(result.content);
} catch {
setModalContent(`Failed to load: ${artifact.ref}`);
} finally {
setIsLoading(false);
}
} else if (artifact.message.data) {
setModalContent(JSON.stringify(artifact.message.data, null, 2));
} else {
setModalContent(artifact.message.summary);
}
}, []);
const handleClose = React.useCallback(() => {
setSelectedArtifact(null);
setModalContent('');
}, []);
// Empty state
if (artifacts.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'team.artifacts.noArtifacts' })}
</h3>
</div>
);
}
// Determine content type for modal
const modalContentType = selectedArtifact?.ref
? getContentType(selectedArtifact.ref)
: selectedArtifact?.message.data
? 'json'
: 'text';
const modalTitle = selectedArtifact?.ref
? selectedArtifact.ref.split('/').pop() || 'File'
: selectedArtifact?.message.summary || 'Data';
return (
<>
<div className="space-y-6">
{PHASE_ORDER.map((phase) => (
<PhaseGroup
key={phase}
phase={phase}
artifacts={grouped[phase]}
onView={handleView}
/>
))}
</div>
{selectedArtifact && (
<MarkdownModal
isOpen={!!selectedArtifact}
onClose={handleClose}
title={modalTitle}
content={modalContent}
contentType={modalContentType}
maxWidth="3xl"
isLoading={isLoading}
/>
)}
</>
);
}

View File

@@ -0,0 +1,235 @@
// ========================================
// TeamCard Component
// ========================================
// Team card with status badge and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import {
Users,
MessageSquare,
MoreVertical,
Eye,
Archive,
ArchiveRestore,
Trash2,
Clock,
GitBranch,
} from 'lucide-react';
import type { TeamSummaryExtended, TeamStatus } from '@/types/team';
export interface TeamCardProps {
team: TeamSummaryExtended;
onClick?: (name: string) => void;
onArchive?: (name: string) => void;
onUnarchive?: (name: string) => void;
onDelete?: (name: string) => void;
showActions?: boolean;
actionsDisabled?: boolean;
className?: string;
}
const statusVariantConfig: Record<
TeamStatus,
{ variant: 'default' | 'secondary' | 'success' | 'info' }
> = {
active: { variant: 'info' },
completed: { variant: 'success' },
archived: { variant: 'secondary' },
};
const statusLabelKeys: Record<TeamStatus, string> = {
active: 'team.status.active',
completed: 'team.status.completed',
archived: 'team.status.archived',
};
function formatDate(dateString: string | undefined): string {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
}
export function TeamCard({
team,
onClick,
onArchive,
onUnarchive,
onDelete,
showActions = true,
actionsDisabled = false,
className,
}: TeamCardProps) {
const { formatMessage } = useIntl();
const { variant: statusVariant } = statusVariantConfig[team.status] || { variant: 'default' as const };
const statusLabel = statusLabelKeys[team.status]
? formatMessage({ id: statusLabelKeys[team.status] })
: team.status;
const isArchived = team.status === 'archived';
const handleCardClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-radix-popper-content-wrapper]')) return;
onClick?.(team.name);
};
const handleAction = (e: React.MouseEvent, action: 'view' | 'archive' | 'unarchive' | 'delete') => {
e.stopPropagation();
switch (action) {
case 'view':
onClick?.(team.name);
break;
case 'archive':
onArchive?.(team.name);
break;
case 'unarchive':
onUnarchive?.(team.name);
break;
case 'delete':
onDelete?.(team.name);
break;
}
};
return (
<Card
className={cn(
'group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/30',
className
)}
onClick={handleCardClick}
>
<CardContent className="p-4">
{/* Header: team name + status + actions */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-card-foreground text-sm tracking-wide truncate">
{team.name}
</h3>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={statusVariant}>{statusLabel}</Badge>
{team.pipeline_mode && (
<Badge variant="default" className="gap-1">
<GitBranch className="h-3 w-3" />
{team.pipeline_mode}
</Badge>
)}
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'view')}>
<Eye className="mr-2 h-4 w-4" />
{formatMessage({ id: 'team.actions.viewDetails' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
{isArchived ? (
<DropdownMenuItem onClick={(e) => handleAction(e, 'unarchive')}>
<ArchiveRestore className="mr-2 h-4 w-4" />
{formatMessage({ id: 'team.actions.unarchive' })}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleAction(e, 'archive')}>
<Archive className="mr-2 h-4 w-4" />
{formatMessage({ id: 'team.actions.archive' })}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'delete')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'team.actions.delete' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Meta info row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{team.messageCount} {formatMessage({ id: 'team.card.messages' })}
</span>
{team.lastActivity && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{formatDate(team.lastActivity)}
</span>
)}
</div>
{/* Members row */}
{team.members && team.members.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{team.members.map((name) => (
<Badge key={name} variant="outline" className="text-[10px] px-1.5 py-0 font-normal">
{name}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
}
/**
* Skeleton loader for TeamCard
*/
export function TeamCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-5 w-32 rounded bg-muted" />
</div>
<div className="h-5 w-16 rounded-full bg-muted" />
</div>
<div className="mt-3 flex gap-4">
<div className="h-4 w-24 rounded bg-muted" />
<div className="h-4 w-20 rounded bg-muted" />
<div className="h-4 w-20 rounded bg-muted" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,26 +1,19 @@
// ========================================
// TeamHeader Component
// ========================================
// Team selector, stats chips, and controls
// Detail view header with back button, stats, and controls
import { useIntl } from 'react-intl';
import { Users, MessageSquare, RefreshCw } from 'lucide-react';
import { Users, MessageSquare, RefreshCw, ArrowLeft } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import type { TeamSummary, TeamMember } from '@/types/team';
import type { TeamMember } from '@/types/team';
interface TeamHeaderProps {
teams: TeamSummary[];
selectedTeam: string | null;
onSelectTeam: (name: string | null) => void;
onBack: () => void;
members: TeamMember[];
totalMessages: number;
autoRefresh: boolean;
@@ -28,9 +21,8 @@ interface TeamHeaderProps {
}
export function TeamHeader({
teams,
selectedTeam,
onSelectTeam,
onBack,
members,
totalMessages,
autoRefresh,
@@ -41,35 +33,29 @@ export function TeamHeader({
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-wrap">
{/* Team Selector */}
<Select
value={selectedTeam ?? ''}
onValueChange={(v) => onSelectTeam(v || null)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={formatMessage({ id: 'team.selectTeam' })} />
</SelectTrigger>
<SelectContent>
{teams.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Back button */}
<Button variant="ghost" size="sm" onClick={onBack} className="gap-1">
<ArrowLeft className="h-4 w-4" />
{formatMessage({ id: 'team.detail.backToList' })}
</Button>
{/* Stats chips */}
{/* Team name */}
{selectedTeam && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Users className="w-3 h-3" />
{formatMessage({ id: 'team.members' })}: {members.length}
</Badge>
<Badge variant="secondary" className="gap-1">
<MessageSquare className="w-3 h-3" />
{formatMessage({ id: 'team.messages' })}: {totalMessages}
</Badge>
</div>
<>
<h2 className="text-lg font-semibold">{selectedTeam}</h2>
{/* Stats chips */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Users className="w-3 h-3" />
{formatMessage({ id: 'team.members' })}: {members.length}
</Badge>
<Badge variant="secondary" className="gap-1">
<MessageSquare className="w-3 h-3" />
{formatMessage({ id: 'team.messages' })}: {totalMessages}
</Badge>
</div>
</>
)}
</div>

View File

@@ -0,0 +1,230 @@
// ========================================
// TeamListView Component
// ========================================
// Team card grid with tabs, search, and actions
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
RefreshCw,
Search,
Users,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { cn } from '@/lib/utils';
import { TeamCard, TeamCardSkeleton } from './TeamCard';
import { useTeamStore } from '@/stores/teamStore';
import { useTeams, useArchiveTeam, useUnarchiveTeam, useDeleteTeam } from '@/hooks/useTeamData';
export function TeamListView() {
const { formatMessage } = useIntl();
const {
locationFilter,
setLocationFilter,
searchQuery,
setSearchQuery,
selectTeamAndShowDetail,
} = useTeamStore();
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [teamToDelete, setTeamToDelete] = React.useState<string | null>(null);
// Data
const { teams, isLoading, isFetching, refetch } = useTeams(locationFilter);
const { archiveTeam, isArchiving } = useArchiveTeam();
const { unarchiveTeam, isUnarchiving } = useUnarchiveTeam();
const { deleteTeam, isDeleting } = useDeleteTeam();
const isMutating = isArchiving || isUnarchiving || isDeleting;
// Client-side search filter
const filteredTeams = React.useMemo(() => {
if (!searchQuery) return teams;
const q = searchQuery.toLowerCase();
return teams.filter((t) => t.name.toLowerCase().includes(q));
}, [teams, searchQuery]);
// Handlers
const handleArchive = async (name: string) => {
try {
await archiveTeam(name);
} catch (err) {
console.error('Failed to archive team:', err);
}
};
const handleUnarchive = async (name: string) => {
try {
await unarchiveTeam(name);
} catch (err) {
console.error('Failed to unarchive team:', err);
}
};
const handleDeleteClick = (name: string) => {
setTeamToDelete(name);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!teamToDelete) return;
try {
await deleteTeam(teamToDelete);
setDeleteDialogOpen(false);
setTeamToDelete(null);
} catch (err) {
console.error('Failed to delete team:', err);
}
};
const handleClearSearch = () => setSearchQuery('');
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'team.title' })}
</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'team.description' })}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Location tabs */}
<TabsNavigation
value={locationFilter}
onValueChange={(v) => setLocationFilter(v as 'active' | 'archived' | 'all')}
tabs={[
{ value: 'active', label: formatMessage({ id: 'team.filters.active' }) },
{ value: 'archived', label: formatMessage({ id: 'team.filters.archived' }) },
{ value: 'all', label: formatMessage({ id: 'team.filters.all' }) },
]}
/>
{/* Search input */}
<div className="flex-1 max-w-sm relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'team.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Team grid */}
{isLoading ? (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<TeamCardSkeleton key={i} />
))}
</div>
) : filteredTeams.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-1">
{searchQuery
? formatMessage({ id: 'team.emptyState.noMatching' })
: formatMessage({ id: 'team.emptyState.noTeams' })}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
{searchQuery
? formatMessage({ id: 'team.emptyState.noMatchingDescription' })
: formatMessage({ id: 'team.emptyState.noTeamsDescription' })}
</p>
{searchQuery && (
<Button variant="outline" onClick={handleClearSearch}>
{formatMessage({ id: 'common.actions.clearFilters' })}
</Button>
)}
</div>
) : (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{filteredTeams.map((team) => (
<TeamCard
key={team.name}
team={team}
onClick={selectTeamAndShowDetail}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
onDelete={handleDeleteClick}
actionsDisabled={isMutating}
/>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'team.dialog.deleteTeam' })}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'team.dialog.deleteConfirm' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setTeamToDelete(null);
}}
>
{formatMessage({ id: 'team.dialog.cancel' })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting
? formatMessage({ id: 'team.dialog.deleting' })
: formatMessage({ id: 'team.actions.delete' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -109,32 +109,34 @@ export function TeamPipeline({ messages }: TeamPipelineProps) {
const stageStatus = derivePipelineStatus(messages);
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{formatMessage({ id: 'team.pipeline.title' })}
</h3>
<div className="flex flex-col h-full">
<div className="space-y-3 flex-1">
<h3 className="text-sm font-medium text-muted-foreground">
{formatMessage({ id: 'team.pipeline.title' })}
</h3>
{/* Desktop: horizontal layout */}
<div className="hidden sm:flex items-center gap-0">
<StageNode stage="plan" status={stageStatus.plan} />
<Arrow />
<StageNode stage="impl" status={stageStatus.impl} />
<Arrow />
<div className="flex flex-col gap-2">
<StageNode stage="test" status={stageStatus.test} />
<StageNode stage="review" status={stageStatus.review} />
{/* Desktop: horizontal layout */}
<div className="hidden sm:flex items-center gap-0">
<StageNode stage="plan" status={stageStatus.plan} />
<Arrow />
<StageNode stage="impl" status={stageStatus.impl} />
<Arrow />
<div className="flex flex-col gap-2">
<StageNode stage="test" status={stageStatus.test} />
<StageNode stage="review" status={stageStatus.review} />
</div>
</div>
{/* Mobile: vertical layout */}
<div className="flex sm:hidden flex-col items-center gap-2">
{STAGES.map((stage) => (
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
))}
</div>
</div>
{/* Mobile: vertical layout */}
<div className="flex sm:hidden flex-col items-center gap-2">
{STAGES.map((stage) => (
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground pt-1">
{/* Legend - pinned to bottom */}
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground mt-auto pt-3 border-t border-border">
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => {
const cfg = statusConfig[s];
const Icon = cfg.icon;

View File

@@ -6,19 +6,27 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { X, Terminal as TerminalIcon } from 'lucide-react';
import {
X,
Terminal as TerminalIcon,
Plus,
Trash2,
RotateCcw,
Loader2,
} from 'lucide-react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { QueueExecutionListView } from './QueueExecutionListView';
import {
createCliSession,
fetchCliSessionBuffer,
sendCliSessionText,
resizeCliSession,
executeInCliSession,
closeCliSession,
} from '@/lib/api';
// ========== Types ==========
@@ -33,11 +41,15 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const { formatMessage } = useIntl();
const panelView = useTerminalPanelStore((s) => s.panelView);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
const removeTerminal = useTerminalPanelStore((s) => s.removeTerminal);
const sessions = useCliSessionStore((s) => s.sessions);
const outputChunks = useCliSessionStore((s) => s.outputChunks);
const setBuffer = useCliSessionStore((s) => s.setBuffer);
const clearOutput = useCliSessionStore((s) => s.clearOutput);
const upsertSession = useCliSessionStore((s) => s.upsertSession);
const removeSessionFromStore = useCliSessionStore((s) => s.removeSession);
const projectPath = useWorkflowStore(selectProjectPath);
@@ -56,9 +68,12 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const pendingInputRef = useRef<string>('');
const flushTimerRef = useRef<number | null>(null);
// Command execution
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
// Toolbar state
const [isCreating, setIsCreating] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Available CLI tools
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
const flushInput = useCallback(async () => {
const sessionKey = activeTerminalId;
@@ -187,34 +202,44 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
return () => ro.disconnect();
}, [activeTerminalId, projectPath]);
// ========== Command Execution ==========
// ========== CLI Session Actions ==========
const handleExecute = async () => {
if (!activeTerminalId || !prompt.trim()) return;
setIsExecuting(true);
const sessionTool = (activeSession?.tool || 'claude') as 'claude' | 'codex' | 'gemini';
const handleCreateSession = useCallback(async (tool: string) => {
if (!projectPath || isCreating) return;
setIsCreating(true);
try {
await executeInCliSession(activeTerminalId, {
tool: sessionTool,
prompt: prompt.trim(),
mode: 'analysis',
category: 'user',
}, projectPath || undefined);
setPrompt('');
const created = await createCliSession(
{ workingDir: projectPath, tool },
projectPath
);
upsertSession(created.session);
openTerminal(created.session.sessionKey);
} catch (err) {
// Error shown in terminal output; log for DevTools debugging
console.error('[TerminalMainArea] executeInCliSession failed:', err);
console.error('[TerminalMainArea] createCliSession failed:', err);
} finally {
setIsExecuting(false);
setIsCreating(false);
}
};
}, [projectPath, isCreating, upsertSession, openTerminal]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void handleExecute();
const handleCloseSession = useCallback(async () => {
if (!activeTerminalId || isClosing) return;
setIsClosing(true);
try {
await closeCliSession(activeTerminalId, projectPath || undefined);
removeTerminal(activeTerminalId);
removeSessionFromStore(activeTerminalId);
} catch (err) {
console.error('[TerminalMainArea] closeCliSession failed:', err);
} finally {
setIsClosing(false);
}
};
}, [activeTerminalId, isClosing, projectPath, removeTerminal, removeSessionFromStore]);
const handleClearTerminal = useCallback(() => {
const term = xtermRef.current;
if (!term) return;
term.clear();
}, []);
// ========== Render ==========
@@ -242,59 +267,90 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
</Button>
</div>
{/* Toolbar */}
{panelView === 'terminal' && (
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border bg-muted/30">
{/* New CLI session buttons */}
{CLI_TOOLS.map((tool) => (
<Button
key={tool}
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
disabled={isCreating || !projectPath}
onClick={() => handleCreateSession(tool)}
title={`New ${tool} session`}
>
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
{tool}
</Button>
))}
<div className="flex-1" />
{/* Terminal actions */}
{activeTerminalId && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleClearTerminal}
title="Clear terminal"
>
<RotateCcw className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
disabled={isClosing}
onClick={handleCloseSession}
title="Close session"
>
{isClosing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
</>
)}
</div>
)}
{/* Content */}
{panelView === 'queue' ? (
/* Queue View - Placeholder */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}</p>
</div>
</div>
/* Queue View */
<QueueExecutionListView />
) : activeTerminalId ? (
/* Terminal View */
<div className="flex-1 flex flex-col min-h-0">
{/* xterm container */}
<div className="flex-1 min-h-0">
<div
ref={terminalHostRef}
className="h-full w-full bg-black/90 rounded-none"
/>
</div>
{/* Command Input */}
<div className="border-t border-border p-3 bg-card">
<div className="space-y-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'home.terminalPanel.commandPlaceholder' })}
className={cn(
'w-full min-h-[60px] p-2 bg-background border border-input rounded-md text-sm resize-none',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleExecute}
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
>
{formatMessage({ id: 'home.terminalPanel.execute' })}
</Button>
</div>
</div>
</div>
<div className="flex-1 min-h-0">
<div
ref={terminalHostRef}
className="h-full w-full bg-black/90 rounded-none"
/>
</div>
) : (
/* Empty State */
/* Empty State - with quick launch */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
<p className="text-xs mt-1 mb-4">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
{projectPath && (
<div className="flex items-center justify-center gap-2">
{CLI_TOOLS.map((tool) => (
<Button
key={tool}
variant="outline"
size="sm"
className="gap-1"
disabled={isCreating}
onClick={() => handleCreateSession(tool)}
>
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
{tool}
</Button>
))}
</div>
)}
</div>
</div>
)}

View File

@@ -4,11 +4,14 @@
// Left-side icon navigation bar (w-16) inside TerminalPanel.
// Shows fixed queue entry icon + dynamic terminal icons with status badges.
import { useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { ClipboardList, Terminal, Loader2, CheckCircle, XCircle, Circle } from 'lucide-react';
import { ClipboardList, Terminal, Loader2, CheckCircle, XCircle, Circle, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
import { useCliSessionStore, type CliSessionMeta, type CliSessionOutputChunk } from '@/stores/cliSessionStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { createCliSession } from '@/lib/api';
// ========== Status Badge Mapping ==========
@@ -48,11 +51,37 @@ export function TerminalNavBar() {
const terminalOrder = useTerminalPanelStore((s) => s.terminalOrder);
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
const setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
const sessions = useCliSessionStore((s) => s.sessions);
const outputChunks = useCliSessionStore((s) => s.outputChunks);
const upsertSession = useCliSessionStore((s) => s.upsertSession);
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const [isCreating, setIsCreating] = useState(false);
const [showToolMenu, setShowToolMenu] = useState(false);
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
const handleCreateSession = useCallback(async (tool: string) => {
if (!projectPath || isCreating) return;
setIsCreating(true);
setShowToolMenu(false);
try {
const created = await createCliSession(
{ workingDir: projectPath, tool },
projectPath
);
upsertSession(created.session);
openTerminal(created.session.sessionKey);
} catch (err) {
console.error('[TerminalNavBar] createCliSession failed:', err);
} finally {
setIsCreating(false);
}
}, [projectPath, isCreating, upsertSession, openTerminal]);
const handleQueueClick = () => {
setPanelView('queue');
};
@@ -120,6 +149,44 @@ export function TerminalNavBar() {
);
})}
</div>
{/* New Terminal Button - Fixed at bottom */}
<div className="relative">
<div className="w-8 border-t border-border mb-2" />
<button
className={cn(
'w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
!projectPath && 'opacity-40 cursor-not-allowed'
)}
onClick={() => projectPath && setShowToolMenu(!showToolMenu)}
disabled={isCreating || !projectPath}
title={formatMessage({ id: 'home.terminalPanel.newSession' })}
>
{isCreating ? (
<Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
) : (
<Plus className="h-5 w-5 text-muted-foreground" />
)}
</button>
{/* Tool Selection Popup */}
{showToolMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowToolMenu(false)} />
<div className="absolute left-full bottom-0 ml-1 z-50 bg-card border border-border rounded-md shadow-lg py-1 min-w-[120px]">
{CLI_TOOLS.map((tool) => (
<button
key={tool}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-accent transition-colors"
onClick={() => handleCreateSession(tool)}
>
{tool}
</button>
))}
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -20,6 +20,8 @@ export type { UseWebSocketOptions, UseWebSocketReturn } from './useWebSocket';
export { useWebSocketNotifications } from './useWebSocketNotifications';
export { useCompletionCallbackChain } from './useCompletionCallbackChain';
export { useSystemNotifications } from './useSystemNotifications';
export type { UseSystemNotificationsReturn, SystemNotificationOptions } from './useSystemNotifications';
@@ -54,7 +56,6 @@ export {
useUpdateLoopStatus,
useDeleteLoop,
useLoopMutations,
loopsKeys,
} from './useLoops';
export type {
LoopsFilter,
@@ -118,7 +119,6 @@ export {
useCommands,
useCommandSearch,
useCommandMutations,
commandsKeys,
} from './useCommands';
export type {
CommandsFilter,

View File

@@ -104,16 +104,13 @@ export function useActiveCliExecutions(
enabled: boolean,
refetchInterval: number = 5000
) {
const upsertExecution = useCliStreamStore(state => state.upsertExecution);
const removeExecution = useCliStreamStore(state => state.removeExecution);
const executions = useCliStreamStore(state => state.executions);
const setCurrentExecution = useCliStreamStore(state => state.setCurrentExecution);
const isExecutionClosedByUser = useCliStreamStore(state => state.isExecutionClosedByUser);
const cleanupUserClosedExecutions = useCliStreamStore(state => state.cleanupUserClosedExecutions);
return useQuery({
queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY,
queryFn: async () => {
// Access store state at execution time to avoid stale closures
const store = useCliStreamStore.getState();
const currentExecutions = store.executions;
const response = await fetch('/api/cli/active');
if (!response.ok) {
throw new Error(`Failed to fetch active executions: ${response.statusText}`);
@@ -124,16 +121,16 @@ export function useActiveCliExecutions(
const serverIds = new Set(data.executions.map(e => e.id));
// Clean up userClosedExecutions - remove those no longer on server
cleanupUserClosedExecutions(serverIds);
store.cleanupUserClosedExecutions(serverIds);
// Remove executions that are no longer on server and were closed by user
for (const [id, exec] of Object.entries(executions)) {
if (isExecutionClosedByUser(id)) {
for (const [id, exec] of Object.entries(currentExecutions)) {
if (store.isExecutionClosedByUser(id)) {
// User closed this execution, remove from local state
removeExecution(id);
store.removeExecution(id);
} else if (exec.status !== 'running' && !serverIds.has(id) && exec.recovered) {
// Not running, not on server, and was recovered (not user-created)
removeExecution(id);
store.removeExecution(id);
}
}
@@ -143,11 +140,11 @@ export function useActiveCliExecutions(
for (const exec of data.executions) {
// Skip if user closed this execution
if (isExecutionClosedByUser(exec.id)) {
if (store.isExecutionClosedByUser(exec.id)) {
continue;
}
const existing = executions[exec.id];
const existing = currentExecutions[exec.id];
const historicalOutput = parseHistoricalOutput(exec.output || '', exec.startTime);
if (!existing) {
@@ -187,7 +184,7 @@ export function useActiveCliExecutions(
];
}
upsertExecution(exec.id, {
store.upsertExecution(exec.id, {
tool: exec.tool || 'cli',
mode: exec.mode || 'analysis',
status: exec.status || 'running',
@@ -200,9 +197,9 @@ export function useActiveCliExecutions(
// Set current execution to first running execution if none selected
if (hasNewExecution) {
const runningExec = data.executions.find(e => e.status === 'running' && !isExecutionClosedByUser(e.id));
if (runningExec && !executions[runningExec.id]) {
setCurrentExecution(runningExec.id);
const runningExec = data.executions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id));
if (runningExec && !currentExecutions[runningExec.id]) {
store.setCurrentExecution(runningExec.id);
}
}

View File

@@ -23,7 +23,7 @@ export interface UseCliSessionCoreOptions {
/** Default resumeKey used when creating sessions via ensureSession/handleCreateSession. */
resumeKey?: string;
/** Shell to use when creating new sessions. Defaults to 'bash'. */
preferredShell?: string;
preferredShell?: 'bash' | 'pwsh';
/** Additional createCliSession fields (cols, rows, tool, model). */
createSessionDefaults?: {
cols?: number;

View File

@@ -14,13 +14,7 @@ import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useNotifications } from './useNotifications';
import { sanitizeErrorMessage } from '@/utils/errorSanitizer';
import { formatMessage } from '@/lib/i18n';
// Query key factory
export const commandsKeys = {
all: ['commands'] as const,
lists: () => [...commandsKeys.all, 'list'] as const,
list: (filters?: CommandsFilter) => [...commandsKeys.lists(), filters] as const,
};
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Default stale time: 10 minutes (commands are static)
const STALE_TIME = 10 * 60 * 1000;
@@ -84,7 +78,7 @@ export function useCommandMutations(): UseCommandMutationsReturn {
const { loadingId } = context ?? { loadingId: '' };
if (loadingId) removeToast(loadingId);
success(formatMessage('feedback.commandToggle.success'));
queryClient.invalidateQueries({ queryKey: commandsKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
},
onError: (err, __, context) => {
const { loadingId } = context ?? { loadingId: '' };
@@ -105,7 +99,7 @@ export function useCommandMutations(): UseCommandMutationsReturn {
const { loadingId } = context ?? { loadingId: '' };
if (loadingId) removeToast(loadingId);
success(formatMessage('feedback.commandToggle.success'));
queryClient.invalidateQueries({ queryKey: commandsKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
},
onError: (err, __, context) => {
const { loadingId } = context ?? { loadingId: '' };
@@ -129,10 +123,10 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
const projectPath = useWorkflowStore(selectProjectPath);
const query = useQuery({
queryKey: commandsKeys.list(filter),
queryKey: workspaceQueryKeys.commandsList(projectPath),
queryFn: () => fetchCommands(projectPath),
staleTime,
enabled: enabled, // Remove projectPath requirement
enabled: enabled,
retry: 2,
});
@@ -213,7 +207,7 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: commandsKeys.all });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
};
return {

View File

@@ -0,0 +1,199 @@
// ========================================
// useCompletionCallbackChain Hook
// ========================================
// Watches for WebSocket CLI_COMPLETED and CLI_ERROR events and connects
// them to the orchestratorStore for automated step advancement.
//
// This hook bridges the WebSocket event layer with the orchestration
// state machine, enabling the feedback loop:
// CLI execution completes -> WebSocket event -> store update -> next step
//
// Usage: Mount this hook once at the App level to enable callback chain processing.
import { useEffect } from 'react';
import { useNotificationStore } from '@/stores';
import { useOrchestratorStore } from '@/stores/orchestratorStore';
import type { WebSocketMessage } from '@/types/store';
import type { ErrorHandlingStrategy } from '@/types/orchestrator';
// ========== Payload Types ==========
interface CliCompletedPayload {
executionId: string;
success: boolean;
duration?: number;
result?: unknown;
}
interface CliErrorPayload {
executionId: string;
error: string;
exitCode?: number;
}
// ========== Hook ==========
/**
* Hook that subscribes to WebSocket completion events and updates
* the orchestratorStore accordingly. Enables automated step advancement
* by connecting CLI execution results to the orchestration state machine.
*
* On CLI_COMPLETED:
* 1. Look up executionId in all active plans' executionIdMap
* 2. Call updateStepStatus(planId, stepId, 'completed')
* 3. Call _advanceToNextStep(planId) to identify the next ready step
*
* On CLI_ERROR / CLI_COMPLETED with success=false:
* 1. Look up executionId in all active plans' executionIdMap
* 2. Call updateStepStatus(planId, stepId, 'failed', error)
* 3. Apply error handling strategy from the step or plan defaults
*/
export function useCompletionCallbackChain(): void {
const wsLastMessage = useNotificationStore((state) => state.wsLastMessage);
useEffect(() => {
if (!wsLastMessage) return;
const { type, payload } = wsLastMessage as WebSocketMessage & {
payload?: unknown;
};
// Only process CLI completion/error events
if (type !== 'CLI_COMPLETED' && type !== 'CLI_ERROR') return;
// Access store state directly (zustand pattern for non-rendering access)
const store = useOrchestratorStore.getState();
if (type === 'CLI_COMPLETED') {
handleCliCompleted(store, payload as CliCompletedPayload | undefined);
} else if (type === 'CLI_ERROR') {
handleCliError(store, payload as CliErrorPayload | undefined);
}
}, [wsLastMessage]);
}
// ========== Event Handlers ==========
function handleCliCompleted(
store: ReturnType<typeof useOrchestratorStore.getState>,
payload: CliCompletedPayload | undefined
): void {
if (!payload?.executionId) return;
const { executionId, success, result } = payload;
// Find which plan/step this execution belongs to
const match = findPlanStepByExecutionId(store, executionId);
if (!match) return; // Not an orchestrated execution
const { planId, stepId } = match;
if (success) {
// Step completed successfully
store.updateStepStatus(planId, stepId, 'completed', { data: result });
// Advance to the next ready step (does not execute, only identifies)
store._advanceToNextStep(planId);
} else {
// CLI_COMPLETED with success=false is treated as a failure
handleStepFailure(store, planId, stepId, 'CLI execution completed with failure status');
}
}
function handleCliError(
store: ReturnType<typeof useOrchestratorStore.getState>,
payload: CliErrorPayload | undefined
): void {
if (!payload?.executionId) return;
const { executionId, error } = payload;
// Find which plan/step this execution belongs to
const match = findPlanStepByExecutionId(store, executionId);
if (!match) return; // Not an orchestrated execution
const { planId, stepId } = match;
handleStepFailure(store, planId, stepId, error);
}
// ========== Helpers ==========
/**
* Look up which plan and step an executionId belongs to by scanning
* all active plans' executionIdMap.
*/
function findPlanStepByExecutionId(
store: ReturnType<typeof useOrchestratorStore.getState>,
executionId: string
): { planId: string; stepId: string } | undefined {
for (const [planId, runState] of Object.entries(store.activePlans)) {
const stepId = runState.executionIdMap[executionId];
if (stepId) {
return { planId, stepId };
}
}
return undefined;
}
/**
* Resolve the effective error handling strategy for a step.
* Step-level errorHandling overrides plan-level defaults.
*/
function getEffectiveErrorStrategy(
store: ReturnType<typeof useOrchestratorStore.getState>,
planId: string,
stepId: string
): ErrorHandlingStrategy {
const runState = store.activePlans[planId];
if (!runState) return 'pause_on_error';
// Find the step definition
const stepDef = runState.plan.steps.find((s) => s.id === stepId);
// Step-level strategy overrides plan-level default
return (
stepDef?.errorHandling?.strategy ??
runState.plan.defaultErrorHandling.strategy ??
'pause_on_error'
);
}
/**
* Handle step failure by applying the appropriate error handling strategy.
*/
function handleStepFailure(
store: ReturnType<typeof useOrchestratorStore.getState>,
planId: string,
stepId: string,
errorMessage: string
): void {
// Mark the step as failed
store.updateStepStatus(planId, stepId, 'failed', { error: errorMessage });
// Determine error handling strategy
const strategy = getEffectiveErrorStrategy(store, planId, stepId);
switch (strategy) {
case 'pause_on_error':
// Pause the orchestration for user intervention
store.pauseOrchestration(planId);
break;
case 'skip':
// Skip this step and advance to the next
store.skipStep(planId, stepId);
store._advanceToNextStep(planId);
break;
case 'stop':
// Stop the entire orchestration
store.stopOrchestration(planId, `Step "${stepId}" failed: ${errorMessage}`);
break;
default:
// Fallback: pause on error
store.pauseOrchestration(planId);
break;
}
}
export default useCompletionCallbackChain;

View File

@@ -13,13 +13,7 @@ import {
type HistoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Query key factory
export const historyKeys = {
all: ['history'] as const,
lists: () => [...historyKeys.all, 'list'] as const,
list: (filter?: HistoryFilter) => [...historyKeys.lists(), filter] as const,
};
import { workspaceQueryKeys } from '@/lib/queryKeys';
export interface HistoryFilter {
search?: string;
@@ -75,7 +69,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: historyKeys.list(filter),
queryKey: workspaceQueryKeys.cliHistoryList(projectPath),
queryFn: () => fetchHistory(projectPath),
staleTime,
enabled: queryEnabled,
@@ -113,7 +107,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
const deleteSingleMutation = useMutation({
mutationFn: deleteExecution,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: historyKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
},
});
@@ -121,7 +115,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
const deleteByToolMutation = useMutation({
mutationFn: deleteExecutionsByTool,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: historyKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
},
});
@@ -129,7 +123,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
const deleteAllMutation = useMutation({
mutationFn: deleteAllHistory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: historyKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
},
});

View File

@@ -11,13 +11,7 @@ import {
type IndexRebuildRequest,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// ========== Query Keys ==========
export const indexKeys = {
all: ['index'] as const,
status: () => [...indexKeys.all, 'status'] as const,
};
import { workspaceQueryKeys } from '@/lib/queryKeys';
// ========== Stale Time ==========
@@ -57,7 +51,7 @@ export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexSta
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: indexKeys.status(),
queryKey: workspaceQueryKeys.indexStatus(projectPath),
queryFn: () => fetchIndexStatus(projectPath),
staleTime,
enabled: queryEnabled,
@@ -70,7 +64,7 @@ export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexSta
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: indexKeys.all });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.index(projectPath) });
};
return {
@@ -105,12 +99,13 @@ export interface UseRebuildIndexReturn {
*/
export function useRebuildIndex(): UseRebuildIndexReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: rebuildIndex,
onSuccess: (updatedStatus) => {
// Update the status query cache
queryClient.setQueryData(indexKeys.status(), updatedStatus);
queryClient.setQueryData(workspaceQueryKeys.indexStatus(projectPath), updatedStatus);
},
});

View File

@@ -14,15 +14,7 @@ import {
type LoopsResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Query key factory
export const loopsKeys = {
all: ['loops'] as const,
lists: () => [...loopsKeys.all, 'list'] as const,
list: (filters?: LoopsFilter) => [...loopsKeys.lists(), filters] as const,
details: () => [...loopsKeys.all, 'detail'] as const,
detail: (id: string) => [...loopsKeys.details(), id] as const,
};
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Default stale time: 10 seconds (loops update frequently)
const STALE_TIME = 10 * 1000;
@@ -63,7 +55,7 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: loopsKeys.list(filter),
queryKey: workspaceQueryKeys.loopsList(projectPath),
queryFn: () => fetchLoops(projectPath),
staleTime,
enabled: queryEnabled,
@@ -112,7 +104,7 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: loopsKeys.all });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
};
return {
@@ -137,7 +129,7 @@ export function useLoop(loopId: string, options: { enabled?: boolean } = {}) {
const queryEnabled = (options.enabled ?? !!loopId) && !!projectPath;
return useQuery({
queryKey: loopsKeys.detail(loopId),
queryKey: workspaceQueryKeys.loopDetail(projectPath, loopId),
queryFn: () => fetchLoop(loopId, projectPath),
enabled: queryEnabled,
staleTime: STALE_TIME,
@@ -154,11 +146,12 @@ export interface UseCreateLoopReturn {
export function useCreateLoop(): UseCreateLoopReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: createLoop,
onSuccess: (newLoop) => {
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
if (!old) return { loops: [newLoop], total: 1 };
return {
loops: [newLoop, ...old.loops],
@@ -183,19 +176,20 @@ export interface UseUpdateLoopStatusReturn {
export function useUpdateLoopStatus(): UseUpdateLoopStatusReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: ({ loopId, action }: { loopId: string; action: 'pause' | 'resume' | 'stop' }) =>
updateLoopStatus(loopId, action),
onSuccess: (updatedLoop) => {
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
if (!old) return old;
return {
...old,
loops: old.loops.map((l) => (l.id === updatedLoop.id ? updatedLoop : l)),
};
});
queryClient.setQueryData(loopsKeys.detail(updatedLoop.id), updatedLoop);
queryClient.setQueryData(workspaceQueryKeys.loopDetail(projectPath, updatedLoop.id), updatedLoop);
},
});
@@ -214,14 +208,15 @@ export interface UseDeleteLoopReturn {
export function useDeleteLoop(): UseDeleteLoopReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: deleteLoop,
onMutate: async (loopId) => {
await queryClient.cancelQueries({ queryKey: loopsKeys.all });
const previousLoops = queryClient.getQueryData<LoopsResponse>(loopsKeys.list());
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
const previousLoops = queryClient.getQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath));
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
if (!old) return old;
return {
...old,
@@ -234,11 +229,11 @@ export function useDeleteLoop(): UseDeleteLoopReturn {
},
onError: (_error, _loopId, context) => {
if (context?.previousLoops) {
queryClient.setQueryData(loopsKeys.list(), context.previousLoops);
queryClient.setQueryData(workspaceQueryKeys.loopsList(projectPath), context.previousLoops);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: loopsKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
},
});

View File

@@ -18,15 +18,7 @@ import {
type InsightsHistoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Query key factory
export const promptHistoryKeys = {
all: ['promptHistory'] as const,
lists: () => [...promptHistoryKeys.all, 'list'] as const,
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
insightsHistory: () => [...promptHistoryKeys.all, 'insightsHistory'] as const,
};
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Default stale time: 30 seconds (prompts update less frequently)
const STALE_TIME = 30 * 1000;
@@ -78,7 +70,7 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: promptHistoryKeys.list(filter),
queryKey: workspaceQueryKeys.promptsList(projectPath),
queryFn: () => fetchPrompts(projectPath),
staleTime,
enabled: queryEnabled,
@@ -179,7 +171,7 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
};
return {
@@ -214,7 +206,7 @@ export function usePromptInsights(options: { enabled?: boolean; staleTime?: numb
const queryEnabled = enabled && !!projectPath;
return useQuery({
queryKey: promptHistoryKeys.insights(),
queryKey: workspaceQueryKeys.promptsInsights(projectPath),
queryFn: () => fetchPromptInsights(projectPath),
staleTime,
enabled: queryEnabled,
@@ -236,7 +228,7 @@ export function useInsightsHistory(options: {
const queryEnabled = enabled && !!projectPath;
return useQuery({
queryKey: promptHistoryKeys.insightsHistory(),
queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath),
queryFn: () => fetchInsightsHistory(projectPath, limit),
staleTime,
enabled: queryEnabled,
@@ -254,12 +246,12 @@ export interface UseAnalyzePromptsReturn {
export function useAnalyzePrompts(): UseAnalyzePromptsReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: analyzePrompts,
onSuccess: () => {
// Invalidate insights query after analysis
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insights() });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.promptsInsights(projectPath) });
},
});
@@ -278,14 +270,15 @@ export interface UseDeletePromptReturn {
export function useDeletePrompt(): UseDeletePromptReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: deletePrompt,
onMutate: async (promptId) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath));
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
queryClient.setQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath), (old) => {
if (!old) return old;
return {
...old,
@@ -298,11 +291,11 @@ export function useDeletePrompt(): UseDeletePromptReturn {
},
onError: (_error, _promptId, context) => {
if (context?.previousPrompts) {
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
queryClient.setQueryData(workspaceQueryKeys.promptsList(projectPath), context.previousPrompts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
},
});
@@ -321,14 +314,15 @@ export interface UseBatchDeletePromptsReturn {
export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: batchDeletePrompts,
onMutate: async (promptIds) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath));
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
queryClient.setQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath), (old) => {
if (!old) return old;
return {
...old,
@@ -341,11 +335,11 @@ export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
},
onError: (_error, _promptIds, context) => {
if (context?.previousPrompts) {
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
queryClient.setQueryData(workspaceQueryKeys.promptsList(projectPath), context.previousPrompts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
},
});
@@ -369,10 +363,10 @@ export function useDeleteInsight(): UseDeleteInsightReturn {
const mutation = useMutation({
mutationFn: (insightId: string) => deleteInsight(insightId, projectPath),
onMutate: async (insightId) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.insightsHistory() });
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory());
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath) });
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(workspaceQueryKeys.promptsInsightsHistory(projectPath));
queryClient.setQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory(), (old) => {
queryClient.setQueryData<InsightsHistoryResponse>(workspaceQueryKeys.promptsInsightsHistory(projectPath), (old) => {
if (!old) return old;
return {
...old,
@@ -384,11 +378,11 @@ export function useDeleteInsight(): UseDeleteInsightReturn {
},
onError: (_error, _insightId, context) => {
if (context?.previousInsights) {
queryClient.setQueryData(promptHistoryKeys.insightsHistory(), context.previousInsights);
queryClient.setQueryData(workspaceQueryKeys.promptsInsightsHistory(projectPath), context.previousInsights);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insightsHistory() });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath) });
},
});

View File

@@ -3,11 +3,11 @@
// ========================================
// TanStack Query hooks for team execution visualization
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchTeams, fetchTeamMessages, fetchTeamStatus } from '@/lib/api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchTeams, fetchTeamMessages, fetchTeamStatus, archiveTeam, unarchiveTeam, deleteTeam } from '@/lib/api';
import { useTeamStore } from '@/stores/teamStore';
import type {
TeamSummary,
TeamSummaryExtended,
TeamMessage,
TeamMember,
TeamMessageFilter,
@@ -20,30 +20,33 @@ import type {
export const teamKeys = {
all: ['teams'] as const,
lists: () => [...teamKeys.all, 'list'] as const,
listByLocation: (location: string) => [...teamKeys.lists(), location] as const,
messages: (team: string, filter?: TeamMessageFilter) =>
[...teamKeys.all, 'messages', team, filter] as const,
status: (team: string) => [...teamKeys.all, 'status', team] as const,
};
/**
* Hook: list all teams
* Hook: list all teams with location filter
*/
export function useTeams() {
export function useTeams(location?: string) {
const autoRefresh = useTeamStore((s) => s.autoRefresh);
const effectiveLocation = location || 'active';
const query = useQuery({
queryKey: teamKeys.lists(),
queryKey: teamKeys.listByLocation(effectiveLocation),
queryFn: async (): Promise<TeamsListResponse> => {
const data = await fetchTeams();
return { teams: data.teams ?? [] };
const data = await fetchTeams(effectiveLocation);
return { teams: (data.teams ?? []) as TeamSummaryExtended[] };
},
staleTime: 10_000,
refetchInterval: autoRefresh ? 10_000 : false,
});
return {
teams: (query.data?.teams ?? []) as TeamSummary[],
teams: (query.data?.teams ?? []) as TeamSummaryExtended[],
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: query.refetch,
};
@@ -125,3 +128,65 @@ export function useInvalidateTeamData() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: teamKeys.all });
}
// ========== Mutation Hooks ==========
/**
* Hook: archive a team
*/
export function useArchiveTeam() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: archiveTeam,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.all });
},
});
return {
archiveTeam: mutation.mutateAsync,
isArchiving: mutation.isPending,
error: mutation.error,
};
}
/**
* Hook: unarchive a team
*/
export function useUnarchiveTeam() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: unarchiveTeam,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.all });
},
});
return {
unarchiveTeam: mutation.mutateAsync,
isUnarchiving: mutation.isPending,
error: mutation.error,
};
}
/**
* Hook: delete a team
*/
export function useDeleteTeam() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deleteTeam,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.all });
},
});
return {
deleteTeam: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}

View File

@@ -434,8 +434,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
scheduleReconnect();
};
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
ws.onerror = () => {
console.warn('[WebSocket] Connection error');
getStoreState().setWsStatus('error');
};
} catch (error) {

View File

@@ -2039,6 +2039,14 @@ export interface NormalizedTask extends TaskData {
_raw?: unknown;
}
/**
* Normalize files field: handles both old string[] format and new {path}[] format.
*/
function normalizeFilesField(files: unknown): Array<{ path: string; name?: string }> | undefined {
if (!Array.isArray(files) || files.length === 0) return undefined;
return files.map((f: unknown) => typeof f === 'string' ? { path: f } : f) as Array<{ path: string; name?: string }>;
}
/**
* Normalize a raw task object (old 6-field or new unified flat) into NormalizedTask.
* Reads new flat fields first, falls back to old nested paths.
@@ -2049,18 +2057,23 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
return { task_id: 'N/A', status: 'pending', _raw: raw } as NormalizedTask;
}
// Type-safe access helpers
const rawContext = raw.context as LiteTask['context'] | undefined;
// Type-safe access helpers (use intersection for broad compat with old/new schemas)
const rawContext = raw.context as (LiteTask['context'] & { requirements?: string[] }) | undefined;
const rawFlowControl = raw.flow_control as FlowControl | undefined;
const rawMeta = raw.meta as LiteTask['meta'] | undefined;
const rawConvergence = raw.convergence as NormalizedTask['convergence'] | undefined;
// Description: new flat field first, then join old context.requirements
// Description: new flat field first, then join old context.requirements, then old details/scope
const rawRequirements = rawContext?.requirements;
const rawDetails = raw.details as string[] | undefined;
const description = (raw.description as string | undefined)
|| (Array.isArray(rawRequirements) && rawRequirements.length > 0
? rawRequirements.join('; ')
: undefined);
: undefined)
|| (Array.isArray(rawDetails) && rawDetails.length > 0
? rawDetails.join('; ')
: undefined)
|| (raw.scope as string | undefined);
return {
// Identity
@@ -2084,7 +2097,7 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
// Promoted from flow_control (new first, old fallback)
pre_analysis: (raw.pre_analysis as PreAnalysisStep[]) || rawFlowControl?.pre_analysis,
implementation: (raw.implementation as (ImplementationStep | string)[]) || rawFlowControl?.implementation_approach,
files: (raw.files as Array<{ path: string; name?: string }>) || rawFlowControl?.target_files,
files: normalizeFilesField(raw.files) || rawFlowControl?.target_files,
// Promoted from meta (new first, old fallback)
type: (raw.type as string) || rawMeta?.type,
@@ -5964,8 +5977,23 @@ export async function fetchCcwTools(): Promise<CcwToolInfo[]> {
// ========== Team API ==========
export async function fetchTeams(): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string }> }> {
return fetchApi('/api/teams');
export async function fetchTeams(location?: string): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string; status: string; created_at: string; updated_at: string; archived_at?: string; pipeline_mode?: string; memberCount: number; members?: string[] }> }> {
const params = new URLSearchParams();
if (location) params.set('location', location);
const qs = params.toString();
return fetchApi(`/api/teams${qs ? `?${qs}` : ''}`);
}
export async function archiveTeam(teamName: string): Promise<{ success: boolean; team: string; status: string }> {
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/archive`, { method: 'POST' });
}
export async function unarchiveTeam(teamName: string): Promise<{ success: boolean; team: string; status: string }> {
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/unarchive`, { method: 'POST' });
}
export async function deleteTeam(teamName: string): Promise<void> {
return fetchApi<void>(`/api/teams/${encodeURIComponent(teamName)}`, { method: 'DELETE' });
}
export async function fetchTeamMessages(

View File

@@ -92,6 +92,7 @@ export const workspaceQueryKeys = {
prompts: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'prompts'] as const,
promptsList: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'list'] as const,
promptsInsights: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insights'] as const,
promptsInsightsHistory: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insightsHistory'] as const,
// ========== Index ==========
index: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'index'] as const,

View File

@@ -0,0 +1,203 @@
// ========================================
// Unified Execution Dispatcher
// ========================================
// Stateless dispatcher that resolves session strategy and dispatches
// OrchestrationStep execution to the CLI session API.
import type { OrchestrationStep, SessionStrategy } from '../types/orchestrator';
import { createCliSession, executeInCliSession } from './api';
import type { ExecuteInCliSessionInput } from './api';
// ========== Types ==========
/**
* Options for dispatch execution.
* These supplement the step's own configuration with runtime context.
*/
export interface DispatchOptions {
/** Working directory for the CLI session (used when creating new sessions). */
workingDir?: string;
/** Execution category for tracking/filtering. */
category?: ExecuteInCliSessionInput['category'];
/** Resume key for session continuity. */
resumeKey?: string;
/** Resume strategy for the CLI execution. */
resumeStrategy?: ExecuteInCliSessionInput['resumeStrategy'];
/** Project path for API routing. */
projectPath?: string;
}
/**
* Result of a dispatched execution.
* Provides the execution ID for callback registration and the resolved session key.
*/
export interface DispatchResult {
/** Unique execution ID returned by the API, used for tracking and callback chains. */
executionId: string;
/** The session key used for execution (may differ from input if strategy created a new session). */
sessionKey: string;
/** Whether a new CLI session was created for this dispatch. */
isNewSession: boolean;
}
// ========== Session Strategy Resolution ==========
interface ResolvedSession {
sessionKey: string;
isNewSession: boolean;
}
/**
* Resolve the session key based on the step's session strategy.
*
* - 'reuse_default': Use the provided defaultSessionKey directly.
* - 'new_session': Create a new PTY session via the API.
* - 'specific_session': Use the step's targetSessionKey (must be provided).
*/
async function resolveSessionKey(
strategy: SessionStrategy,
defaultSessionKey: string,
step: OrchestrationStep,
options: DispatchOptions
): Promise<ResolvedSession> {
switch (strategy) {
case 'reuse_default':
return { sessionKey: defaultSessionKey, isNewSession: false };
case 'new_session': {
const result = await createCliSession(
{
workingDir: options.workingDir,
tool: step.tool,
},
options.projectPath
);
return { sessionKey: result.session.sessionKey, isNewSession: true };
}
case 'specific_session': {
const targetKey = step.targetSessionKey;
if (!targetKey) {
throw new DispatchError(
`Step "${step.id}" uses 'specific_session' strategy but no targetSessionKey is provided.`,
'MISSING_TARGET_SESSION_KEY'
);
}
return { sessionKey: targetKey, isNewSession: false };
}
default:
throw new DispatchError(
`Unknown session strategy: "${strategy}" on step "${step.id}".`,
'UNKNOWN_SESSION_STRATEGY'
);
}
}
// ========== Error Type ==========
/**
* Typed error for dispatch failures with an error code for programmatic handling.
*/
export class DispatchError extends Error {
constructor(
message: string,
public readonly code: DispatchErrorCode
) {
super(message);
this.name = 'DispatchError';
}
}
export type DispatchErrorCode =
| 'MISSING_TARGET_SESSION_KEY'
| 'UNKNOWN_SESSION_STRATEGY'
| 'SESSION_CREATION_FAILED'
| 'EXECUTION_FAILED';
// ========== Dispatcher ==========
/**
* Dispatch an orchestration step for execution in a CLI session.
*
* This is a stateless utility function that:
* 1. Resolves the session key based on the step's sessionStrategy.
* 2. Calls executeInCliSession() with the resolved session and step parameters.
* 3. Returns the executionId for callback chain registration.
*
* @param step - The orchestration step to execute.
* @param sessionKey - The default session key (used when strategy is 'reuse_default').
* @param options - Additional dispatch options.
* @returns The dispatch result containing executionId and resolved sessionKey.
* @throws {DispatchError} When session resolution or execution fails.
*/
export async function dispatch(
step: OrchestrationStep,
sessionKey: string,
options: DispatchOptions = {}
): Promise<DispatchResult> {
const strategy: SessionStrategy = step.sessionStrategy ?? 'reuse_default';
// Step 1: Resolve session key
let resolved: ResolvedSession;
try {
resolved = await resolveSessionKey(strategy, sessionKey, step, options);
} catch (err) {
if (err instanceof DispatchError) throw err;
throw new DispatchError(
`Failed to resolve session for step "${step.id}": ${err instanceof Error ? err.message : String(err)}`,
'SESSION_CREATION_FAILED'
);
}
// Step 2: Build execution input from step + options
const executionInput: ExecuteInCliSessionInput = {
tool: step.tool ?? 'gemini',
prompt: step.instruction,
mode: mapExecutionMode(step.mode),
workingDir: options.workingDir,
category: options.category,
resumeKey: options.resumeKey ?? step.resumeKey,
resumeStrategy: options.resumeStrategy,
};
// Step 3: Execute in the resolved session
try {
const result = await executeInCliSession(
resolved.sessionKey,
executionInput,
options.projectPath
);
return {
executionId: result.executionId,
sessionKey: resolved.sessionKey,
isNewSession: resolved.isNewSession,
};
} catch (err) {
throw new DispatchError(
`Execution failed for step "${step.id}" in session "${resolved.sessionKey}": ${err instanceof Error ? err.message : String(err)}`,
'EXECUTION_FAILED'
);
}
}
/**
* Map the orchestrator's ExecutionMode to the API's mode parameter.
* The API accepts 'analysis' | 'write' | 'auto', while the orchestrator
* uses a broader set including 'mainprocess' and 'async'.
*/
function mapExecutionMode(
mode?: OrchestrationStep['mode']
): ExecuteInCliSessionInput['mode'] {
if (!mode) return undefined;
switch (mode) {
case 'analysis':
return 'analysis';
case 'write':
return 'write';
default:
// 'mainprocess', 'async', and any future modes default to 'auto'
return 'auto';
}
}

View File

@@ -112,15 +112,22 @@
"executionQueueDesc": "Execution Queue Management",
"executionQueuePhase2": "Coming in Phase 2",
"noTerminalSelected": "No terminal selected",
"selectTerminalHint": "Select a terminal from the sidebar",
"selectTerminalHint": "Select a terminal from the sidebar, or click + to create one",
"commandPlaceholder": "Enter command... (Ctrl+Enter to execute)",
"execute": "Execute",
"openInPanel": "Open in Terminal Panel",
"newSession": "New Terminal",
"status": {
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"idle": "Idle"
},
"queueView": {
"session": "Session",
"orchestrator": "Orchestrator",
"emptyTitle": "No executions yet",
"emptyDesc": "Executions started from the issue queue will appear here"
}
}
}

View File

@@ -114,6 +114,7 @@
}
},
"terminal": {
"launch": "Launch Session",
"session": {
"select": "Select session",
"none": "No sessions",

View File

@@ -29,6 +29,15 @@
"completed": "Completed",
"failed": "Failed"
},
"controlPanel": {
"progress": "{completed}/{total} steps",
"noPlan": "No orchestration plan found",
"completedMessage": "Orchestration completed successfully",
"failedMessage": "Orchestration stopped",
"cancelled": "Cancelled",
"retry": "Retry",
"skip": "Skip"
},
"node": {
"title": "Node",
"nodes": "Nodes",

View File

@@ -10,6 +10,58 @@
"filterByType": "Filter by type",
"filterAll": "All Types",
"stage": "Stage",
"status": {
"active": "Active",
"completed": "Completed",
"archived": "Archived"
},
"filters": {
"active": "Active",
"archived": "Archived",
"all": "All"
},
"searchPlaceholder": "Search teams...",
"card": {
"members": "Members",
"messages": "Messages",
"lastActivity": "Last Activity",
"created": "Created"
},
"actions": {
"viewDetails": "View Details",
"archive": "Archive",
"unarchive": "Unarchive",
"delete": "Delete Team"
},
"dialog": {
"deleteTeam": "Delete Team",
"deleteConfirm": "This action cannot be undone. This will permanently delete the team and all its messages.",
"cancel": "Cancel",
"deleting": "Deleting..."
},
"detail": {
"backToList": "Back to Teams"
},
"tabs": {
"artifacts": "Artifacts",
"messages": "Messages"
},
"artifacts": {
"title": "Team Artifacts",
"plan": "Plan",
"impl": "Implementation",
"test": "Test",
"review": "Review",
"noArtifacts": "No artifacts found",
"viewFile": "View File",
"noRef": "Inline data"
},
"emptyState": {
"noTeams": "No Teams",
"noTeamsDescription": "Use /team:coordinate to create a team and start collaborating",
"noMatching": "No Matching Teams",
"noMatchingDescription": "Try adjusting your search or filter criteria"
},
"empty": {
"title": "No Active Teams",
"description": "Use /team:coordinate to create a team and start collaborating",

View File

@@ -112,15 +112,22 @@
"executionQueueDesc": "执行队列管理",
"executionQueuePhase2": "将在 Phase 2 实现",
"noTerminalSelected": "未选择终端",
"selectTerminalHint": "从侧边栏选择一个终端",
"selectTerminalHint": "从侧边栏选择一个终端,或点击 + 新建",
"commandPlaceholder": "输入命令... (Ctrl+Enter 执行)",
"execute": "执行",
"openInPanel": "在终端面板中查看",
"newSession": "新建终端",
"status": {
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"idle": "空闲"
},
"queueView": {
"session": "会话",
"orchestrator": "编排器",
"emptyTitle": "暂无执行任务",
"emptyDesc": "从问题队列发起执行后将在此显示"
}
}
}

View File

@@ -114,6 +114,7 @@
}
},
"terminal": {
"launch": "启动会话",
"session": {
"select": "选择会话",
"none": "暂无会话",

View File

@@ -29,6 +29,15 @@
"completed": "已完成",
"failed": "失败"
},
"controlPanel": {
"progress": "{completed}/{total} 步",
"noPlan": "未找到编排计划",
"completedMessage": "编排已成功完成",
"failedMessage": "编排已停止",
"cancelled": "已取消",
"retry": "重试",
"skip": "跳过"
},
"node": {
"title": "节点",
"nodes": "节点列表",

View File

@@ -10,6 +10,58 @@
"filterByType": "按类型筛选",
"filterAll": "所有类型",
"stage": "阶段",
"status": {
"active": "活跃",
"completed": "已完成",
"archived": "已归档"
},
"filters": {
"active": "活跃",
"archived": "已归档",
"all": "全部"
},
"searchPlaceholder": "搜索团队...",
"card": {
"members": "成员",
"messages": "消息",
"lastActivity": "最近活动",
"created": "创建时间"
},
"actions": {
"viewDetails": "查看详情",
"archive": "归档",
"unarchive": "取消归档",
"delete": "删除团队"
},
"dialog": {
"deleteTeam": "删除团队",
"deleteConfirm": "此操作不可撤销。这将永久删除该团队及其所有消息。",
"cancel": "取消",
"deleting": "删除中..."
},
"detail": {
"backToList": "返回团队列表"
},
"tabs": {
"artifacts": "产物",
"messages": "消息"
},
"artifacts": {
"title": "团队产物",
"plan": "计划",
"impl": "实现",
"test": "测试",
"review": "审查",
"noArtifacts": "暂无产物",
"viewFile": "查看文件",
"noRef": "内联数据"
},
"emptyState": {
"noTeams": "暂无团队",
"noTeamsDescription": "使用 /team:coordinate 创建团队以开始协作",
"noMatching": "没有匹配的团队",
"noMatchingDescription": "尝试调整搜索条件或筛选条件"
},
"empty": {
"title": "暂无活跃团队",
"description": "使用 /team:coordinate 创建团队以开始协作",

View File

@@ -0,0 +1,344 @@
import {
OrchestrationPlan,
OrchestrationStep,
SessionStrategy,
ErrorHandling,
ExecutionType,
OrchestrationMetadata,
ManualOrchestrationParams,
} from '../types/orchestrator';
import { Flow, FlowNode, PromptTemplateNodeData } from '../types/flow';
import { IssueQueue } from '../lib/api';
import { buildQueueItemContext } from '../lib/queue-prompt'; // Assuming this function is available
/**
* Builds OrchestrationPlan objects from various sources (Flow, IssueQueue, Manual Input).
* This class is responsible for transforming source data into a standardized OrchestrationPlan,
* including dependency resolution, context mapping, and basic plan metadata generation.
*/
export class OrchestrationPlanBuilder {
private static DEFAULT_SESSION_STRATEGY: SessionStrategy = 'reuse_default';
private static DEFAULT_ERROR_HANDLING: ErrorHandling = {
strategy: 'pause_on_error',
maxRetries: 0,
retryDelayMs: 0,
};
/**
* Converts a Flow DAG into a topologically-sorted OrchestrationPlan.
*
* @param flow The Flow object to convert.
* @returns An OrchestrationPlan.
*/
public static fromFlow(flow: Flow): OrchestrationPlan {
const steps: OrchestrationStep[] = [];
const nodeMap = new Map<string, FlowNode>(flow.nodes.map((node) => [node.id, node]));
const adjacencyList = new Map<string, string[]>(); // node.id -> list of dependent node.ids
const inDegree = new Map<string, number>(); // node.id -> count of incoming edges
// Initialize in-degrees and adjacency list
for (const node of flow.nodes) {
inDegree.set(node.id, 0);
adjacencyList.set(node.id, []);
}
for (const edge of flow.edges) {
// Ensure the edge target node exists before incrementing in-degree
if (inDegree.has(edge.target)) {
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
// Ensure the adjacency list source node exists before adding
adjacencyList.get(edge.source)?.push(edge.target);
}
}
// Kahn's algorithm for topological sort
const queue: string[] = [];
for (const [nodeId, degree] of inDegree.entries()) {
if (degree === 0) {
queue.push(nodeId);
}
}
const sortedNodeIds: string[] = [];
while (queue.length > 0) {
const nodeId = queue.shift()!;
sortedNodeIds.push(nodeId);
for (const neighborId of adjacencyList.get(nodeId) || []) {
inDegree.set(neighborId, (inDegree.get(neighborId) || 0) - 1);
if (inDegree.get(neighborId) === 0) {
queue.push(neighborId);
}
}
}
// Cycle detection
if (sortedNodeIds.length !== flow.nodes.length) {
// This should ideally be a more specific error or an exception
console.error('Cycle detected in flow graph. Topological sort failed.');
throw new Error('Cycle detected in flow graph. Cannot build orchestration plan from cyclic flow.');
}
// Convert sorted nodes to OrchestrationSteps
for (const nodeId of sortedNodeIds) {
const node = nodeMap.get(nodeId)!;
const nodeData = node.data as PromptTemplateNodeData; // Assuming all nodes are PromptTemplateNodeData
const dependsOn = flow.edges
.filter((edge) => edge.target === node.id)
.map((edge) => edge.source);
// Map delivery to sessionStrategy
let sessionStrategy: SessionStrategy | undefined;
if (nodeData.delivery === 'newExecution') {
sessionStrategy = 'new_session';
} else if (nodeData.delivery === 'sendToSession' && nodeData.targetSessionKey) {
sessionStrategy = 'specific_session';
} else if (nodeData.delivery === 'sendToSession' && !nodeData.targetSessionKey) {
// Fallback or explicit default if targetSessionKey is missing for sendToSession
sessionStrategy = 'reuse_default';
}
// Determine execution type
let executionType: ExecutionType = 'frontend-cli'; // Default
if (nodeData.slashCommand) {
executionType = 'slash-command';
} else if (nodeData.tool && nodeData.mode) {
// More sophisticated logic might be needed here to differentiate backend-flow
// For now, if tool/mode are present, assume frontend-cli or backend-flow
// depending on whether it's a direct CLI call or a backend orchestrator call.
// Assuming CLI tools are frontend-cli for now unless specified otherwise.
executionType = 'frontend-cli';
}
steps.push({
id: node.id,
name: nodeData.label || `Step ${node.id}`,
instruction: nodeData.instruction || '',
tool: nodeData.tool,
mode: nodeData.mode,
sessionStrategy: sessionStrategy,
targetSessionKey: nodeData.targetSessionKey,
resumeKey: nodeData.resumeKey,
dependsOn: dependsOn,
condition: nodeData.condition,
contextRefs: nodeData.contextRefs,
outputName: nodeData.outputName,
// Error handling can be added at node level if flow nodes support it
errorHandling: undefined,
executionType: executionType,
sourceNodeId: node.id,
});
}
const metadata: OrchestrationMetadata = {
totalSteps: steps.length,
hasParallelGroups: OrchestrationPlanBuilder.detectParallelGroups(steps), // Implement this
estimatedComplexity: OrchestrationPlanBuilder.estimateComplexity(steps), // Implement this
};
return {
id: flow.id,
name: flow.name,
source: 'flow',
sourceId: flow.id,
steps: steps,
variables: flow.variables,
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
status: 'pending',
createdAt: flow.created_at,
updatedAt: flow.updated_at,
metadata: metadata,
};
}
/**
* Converts an IssueQueue with execution groups into an OrchestrationPlan.
*
* @param queue The IssueQueue object.
* @param issues A map of issue IDs to Issue objects, needed for context.
* @returns An OrchestrationPlan.
*/
public static fromQueue(queue: IssueQueue, issues: Map<string, any>): OrchestrationPlan {
const steps: OrchestrationStep[] = [];
const groupIdToSteps = new Map<string, string[]>(); // Maps group ID to list of step IDs in that group
const allStepIds = new Set<string>();
let previousGroupStepIds: string[] = [];
for (const groupId of queue.execution_groups) {
const groupItems = queue.grouped_items[groupId] || [];
const currentGroupStepIds: string[] = [];
const groupDependsOn: string[] = []; // Dependencies for the current group
if (groupId.startsWith('S*') || groupId.startsWith('P*')) {
// Sequential or parallel groups: depend on all steps from the previous group
groupDependsOn.push(...previousGroupStepIds);
}
for (const item of groupItems) {
const stepId = `queue-item-${item.item_id}`;
allStepIds.add(stepId);
currentGroupStepIds.push(stepId);
// Fetch the associated issue
const issue = issues.get(item.issue_id);
const instruction = issue ? buildQueueItemContext(item, issue) : `Execute queue item ${item.item_id}`;
// Queue items are typically frontend-cli executions
const executionType: ExecutionType = 'frontend-cli';
steps.push({
id: stepId,
name: `Queue Item: ${item.item_id}`,
instruction: instruction,
tool: undefined, // Queue items don't typically specify tool/mode directly
mode: undefined,
sessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
targetSessionKey: undefined,
resumeKey: undefined,
dependsOn: groupDependsOn, // All items in the current group depend on the previous group's steps
condition: undefined,
contextRefs: undefined,
outputName: `queueItemOutput_${item.item_id}`,
errorHandling: undefined,
executionType: executionType,
sourceItemId: item.item_id,
});
}
groupIdToSteps.set(groupId, currentGroupStepIds);
previousGroupStepIds = currentGroupStepIds;
}
const metadata: OrchestrationMetadata = {
totalSteps: steps.length,
hasParallelGroups: queue.execution_groups.some((id) => id.startsWith('P*')),
estimatedComplexity: OrchestrationPlanBuilder.estimateComplexity(steps),
};
return {
id: queue.id || `queue-${Date.now()}`,
name: `Queue Plan: ${queue.id || 'Untitled'}`,
source: 'queue',
sourceId: queue.id,
steps: steps,
variables: {}, // Queue plans might not have global variables in the same way flows do
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: metadata,
};
}
/**
* Creates a single-step OrchestrationPlan from manual user input.
*
* @param params Parameters for the manual orchestration.
* @returns An OrchestrationPlan.
*/
public static fromManual(params: ManualOrchestrationParams): OrchestrationPlan {
const stepId = `manual-step-${Date.now()}`;
const manualStep: OrchestrationStep = {
id: stepId,
name: 'Manual Execution',
instruction: params.prompt,
tool: params.tool,
mode: params.mode,
sessionStrategy: params.sessionStrategy || OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
targetSessionKey: params.targetSessionKey,
resumeKey: undefined,
dependsOn: [],
condition: undefined,
contextRefs: undefined,
outputName: params.outputName,
errorHandling: params.errorHandling,
executionType: 'frontend-cli', // Manual commands are typically frontend CLI
sourceNodeId: undefined,
sourceItemId: undefined,
};
const metadata: OrchestrationMetadata = {
totalSteps: 1,
hasParallelGroups: false,
estimatedComplexity: 'low',
};
return {
id: `manual-plan-${Date.now()}`,
name: 'Manual Orchestration',
source: 'manual',
sourceId: undefined,
steps: [manualStep],
variables: {},
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: metadata,
};
}
/**
* Helper function to detect if the plan contains parallel groups.
* @param steps The steps of the orchestration plan.
* @returns True if parallel groups are detected, false otherwise.
*/
private static detectParallelGroups(steps: OrchestrationStep[]): boolean {
// A simple heuristic: check if any two steps have the same 'dependsOn' set
// but are not explicitly dependent on each other, implying they can run in parallel.
// This is a basic check and might need refinement.
const dependencySets = new Map<string, Set<string>>();
for (const step of steps) {
const depKey = JSON.stringify(step.dependsOn.sort());
if (!dependencySets.has(depKey)) {
dependencySets.set(depKey, new Set());
}
dependencySets.get(depKey)!.add(step.id);
}
for (const [, stepIds] of dependencySets.entries()) {
if (stepIds.size > 1) {
// If multiple steps share the same dependencies, they might be parallel
// Need to ensure they don't have implicit dependencies among themselves
let isParallelGroup = true;
for (const id1 of stepIds) {
for (const id2 of stepIds) {
if (id1 !== id2) {
const step1 = steps.find(s => s.id === id1);
const step2 = steps.find(s => s.id === id2);
// If step1 depends on step2 or vice-versa, they are not parallel
if (step1?.dependsOn.includes(id2) || step2?.dependsOn.includes(id1)) {
isParallelGroup = false;
break;
}
}
}
if (!isParallelGroup) break;
}
if (isParallelGroup) return true;
}
}
return false;
}
/**
* Helper function to estimate the complexity of the orchestration plan.
* @param steps The steps of the orchestration plan.
* @returns 'low', 'medium', or 'high'.
*/
private static estimateComplexity(steps: OrchestrationStep[]): 'low' | 'medium' | 'high' {
if (steps.length <= 1) {
return 'low';
}
// Heuristic: More steps or presence of parallel groups increases complexity
if (steps.length > 5 || OrchestrationPlanBuilder.detectParallelGroups(steps)) {
return 'high';
}
return 'medium';
}
}

View File

@@ -0,0 +1,478 @@
// ========================================
// Sequential Runner
// ========================================
// Manages PTY session lifecycle and step-by-step command dispatch for
// orchestration plans. Creates/reuses CLI sessions and dispatches
// steps sequentially, resolving runtime variables between steps.
//
// Integration pattern:
// 1. SequentialRunner.start() -> dispatches first step
// 2. WebSocket CLI_COMPLETED -> useCompletionCallbackChain -> store update
// 3. Store subscription detects step completion -> executeStep(nextStepId)
// 4. Repeat until all steps complete
//
// Uses store subscription (Option B) for clean separation between
// the callback chain (which updates the store) and the runner
// (which reacts to store changes by dispatching the next step).
import type { OrchestrationPlan } from '../types/orchestrator';
import { dispatch } from '../lib/unifiedExecutionDispatcher';
import type { DispatchOptions } from '../lib/unifiedExecutionDispatcher';
import { createCliSession } from '../lib/api';
import { useOrchestratorStore } from '../stores/orchestratorStore';
import type { OrchestrationRunState, StepRunState } from '../stores/orchestratorStore';
// ========== Types ==========
/** Configuration options for starting an orchestration plan */
export interface StartOptions {
/** Working directory for session creation */
workingDir?: string;
/** Project path for API routing */
projectPath?: string;
/** Execution category for tracking */
category?: DispatchOptions['category'];
}
/** Tracks active subscriptions per plan for cleanup */
interface PlanSubscription {
/** Zustand unsubscribe function */
unsubscribe: () => void;
/** The plan ID being tracked */
planId: string;
/** Set of step IDs that have already been dispatched (prevents double-dispatch) */
dispatchedSteps: Set<string>;
/** Options passed at start() for reuse during step dispatches */
options: StartOptions;
}
// ========== Module State ==========
/** Active subscriptions keyed by plan ID */
const activeSubscriptions = new Map<string, PlanSubscription>();
// ========== Public API ==========
/**
* Start executing an orchestration plan.
*
* 1. Registers the plan in the orchestratorStore
* 2. Creates a new CLI session if needed (based on plan's defaultSessionStrategy)
* 3. Subscribes to store changes for automated step advancement
* 4. Dispatches the first ready step
*
* @param plan - The orchestration plan to execute
* @param sessionKey - Optional existing session key to reuse
* @param options - Additional options for session creation and dispatch
*/
export async function start(
plan: OrchestrationPlan,
sessionKey?: string,
options: StartOptions = {}
): Promise<void> {
const store = useOrchestratorStore.getState();
// Clean up any existing subscription for this plan
stop(plan.id);
// Resolve session key
let resolvedSessionKey = sessionKey;
if (!resolvedSessionKey && plan.defaultSessionStrategy === 'new_session') {
const result = await createCliSession(
{
workingDir: options.workingDir,
tool: plan.steps[0]?.tool,
},
options.projectPath
);
resolvedSessionKey = result.session.sessionKey;
}
// Initialize plan in the store
store.startOrchestration(plan, resolvedSessionKey);
// Subscribe to store changes for automated step advancement
const subscription = subscribeToStepAdvancement(plan.id, options);
activeSubscriptions.set(plan.id, subscription);
// Dispatch the first ready step
const firstStepId = store.getNextReadyStep(plan.id);
if (firstStepId) {
subscription.dispatchedSteps.add(firstStepId);
await executeStep(plan.id, firstStepId, options);
}
}
/**
* Execute a specific step within a plan.
*
* 1. Resolves runtime variables in the step instruction
* 2. Resolves contextRefs from previous step outputs
* 3. Updates step status to 'running'
* 4. Dispatches execution via UnifiedExecutionDispatcher
* 5. Registers the executionId for callback chain matching
*
* @param planId - The plan containing the step
* @param stepId - The step to execute
* @param options - Dispatch options (workingDir, projectPath, etc.)
*/
export async function executeStep(
planId: string,
stepId: string,
options: StartOptions = {}
): Promise<void> {
const store = useOrchestratorStore.getState();
const runState = store.activePlans[planId];
if (!runState) {
console.error(`[SequentialRunner] Plan "${planId}" not found in store`);
return;
}
// Find the step definition
const step = runState.plan.steps.find((s) => s.id === stepId);
if (!step) {
console.error(`[SequentialRunner] Step "${stepId}" not found in plan "${planId}"`);
return;
}
// Collect previous step outputs for variable interpolation
const stepOutputs = collectStepOutputs(runState);
// Resolve runtime variables in the instruction
const resolvedInstruction = interpolateInstruction(
step.instruction,
runState.plan.variables,
stepOutputs
);
// Resolve contextRefs - append previous step outputs as context
const contextSuffix = resolveContextRefs(step.contextRefs, stepOutputs);
const finalInstruction = contextSuffix
? `${resolvedInstruction}\n\n--- Context from previous steps ---\n${contextSuffix}`
: resolvedInstruction;
// Create a modified step with the resolved instruction for dispatch
const resolvedStep = { ...step, instruction: finalInstruction };
// Mark step as running
store.updateStepStatus(planId, stepId, 'running');
try {
// Dispatch via UnifiedExecutionDispatcher
const result = await dispatch(resolvedStep, runState.sessionKey ?? '', {
workingDir: options.workingDir,
projectPath: options.projectPath,
category: options.category,
resumeKey: step.resumeKey,
});
// Register executionId for callback chain matching
store.registerExecution(planId, stepId, result.executionId);
// If dispatch created a new session and plan had no session, update the run state
if (result.isNewSession && !runState.sessionKey) {
// Update session key on the run state by re-reading store
// The session key is now tracked on the dispatch result
// Future steps in this plan will use this session via the store's sessionKey
const currentState = useOrchestratorStore.getState();
const currentRunState = currentState.activePlans[planId];
if (currentRunState && !currentRunState.sessionKey) {
// The store does not expose a setSessionKey action, so we rely on
// the dispatch result's sessionKey being used by subsequent steps.
// This is handled by resolveSessionKey in the dispatcher using
// the step's sessionStrategy or reuse_default with the plan's sessionKey.
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(`[SequentialRunner] Failed to dispatch step "${stepId}":`, errorMessage);
store.updateStepStatus(planId, stepId, 'failed', { error: errorMessage });
// Error handling (pause/skip/stop) is handled by useCompletionCallbackChain
// but since this is a dispatch failure (not a CLI completion failure),
// we need to apply error handling here too
applyDispatchErrorHandling(planId, stepId);
}
}
/**
* Called when _advanceToNextStep identifies a next step.
* If nextStepId is not null, dispatches execution for that step.
* If null, orchestration is complete (store already updated).
*
* @param planId - The plan ID
* @param nextStepId - The next step to execute, or null if complete
* @param options - Dispatch options
*/
export async function onStepAdvanced(
planId: string,
nextStepId: string | null,
options: StartOptions = {}
): Promise<void> {
if (nextStepId) {
await executeStep(planId, nextStepId, options);
}
// If null, orchestration is complete - store already updated by _advanceToNextStep
}
/**
* Stop tracking a plan and clean up its store subscription.
*
* @param planId - The plan to stop tracking
*/
export function stop(planId: string): void {
const subscription = activeSubscriptions.get(planId);
if (subscription) {
subscription.unsubscribe();
activeSubscriptions.delete(planId);
}
}
/**
* Stop all active plan subscriptions.
*/
export function stopAll(): void {
for (const [planId] of activeSubscriptions) {
stop(planId);
}
}
// ========== Variable Interpolation ==========
/**
* Replace {{variableName}} placeholders in an instruction string with
* values from the plan variables and previous step outputs.
*
* Supports:
* - Simple replacement: {{variableName}} -> value from variables map
* - Step output reference: {{stepOutputName}} -> value from step outputs
* - Nested dot-notation: {{step1.output.field}} -> nested property access
*
* @param instruction - The instruction template string
* @param variables - Plan-level variables
* @param stepOutputs - Collected outputs from completed steps
* @returns The interpolated instruction string
*/
export function interpolateInstruction(
instruction: string,
variables: Record<string, unknown>,
stepOutputs: Record<string, unknown>
): string {
return instruction.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
const trimmedKey = key.trim();
// Try plan variables first (simple key)
if (trimmedKey in variables) {
return formatValue(variables[trimmedKey]);
}
// Try step outputs (simple key)
if (trimmedKey in stepOutputs) {
return formatValue(stepOutputs[trimmedKey]);
}
// Try nested dot-notation in step outputs
const nestedValue = resolveNestedPath(trimmedKey, stepOutputs);
if (nestedValue !== undefined) {
return formatValue(nestedValue);
}
// Try nested dot-notation in plan variables
const nestedVarValue = resolveNestedPath(trimmedKey, variables);
if (nestedVarValue !== undefined) {
return formatValue(nestedVarValue);
}
// Unresolved placeholder - leave as-is
return `{{${trimmedKey}}}`;
});
}
// ========== Internal Helpers ==========
/**
* Subscribe to orchestratorStore changes for a specific plan.
* When a step transitions to 'completed' or 'skipped', check if there is
* a new ready step and dispatch it.
*
* This implements Option B (store subscription) for clean separation
* between the callback chain (store updates) and the runner (step dispatch).
*/
function subscribeToStepAdvancement(
planId: string,
options: StartOptions
): PlanSubscription {
const dispatchedSteps = new Set<string>();
// Track previous step statuses to detect transitions
let previousStatuses: Record<string, StepRunState> | undefined;
const unsubscribe = useOrchestratorStore.subscribe((state) => {
const runState = state.activePlans[planId];
if (!runState || runState.status !== 'running') return;
const currentStatuses = runState.stepStatuses;
// On first call, just capture the initial state
if (!previousStatuses) {
previousStatuses = currentStatuses;
return;
}
// Detect if any step just transitioned to a terminal state (completed/skipped)
let hasNewCompletion = false;
for (const [stepId, stepState] of Object.entries(currentStatuses)) {
const prevState = previousStatuses[stepId];
if (!prevState) continue;
const wasTerminal =
prevState.status === 'completed' || prevState.status === 'skipped';
const isTerminal =
stepState.status === 'completed' || stepState.status === 'skipped';
if (!wasTerminal && isTerminal) {
hasNewCompletion = true;
break;
}
}
previousStatuses = currentStatuses;
if (!hasNewCompletion) return;
// A step just completed - check if _advanceToNextStep was already called
// (by useCompletionCallbackChain). We detect the next ready step.
const store = useOrchestratorStore.getState();
const nextStepId = store.getNextReadyStep(planId);
if (nextStepId && !dispatchedSteps.has(nextStepId)) {
dispatchedSteps.add(nextStepId);
// Dispatch asynchronously to avoid blocking the subscription callback
onStepAdvanced(planId, nextStepId, options).catch((err) => {
console.error(
`[SequentialRunner] Failed to advance to step "${nextStepId}":`,
err
);
});
}
});
return {
unsubscribe,
planId,
dispatchedSteps,
options,
};
}
/**
* Collect output results from completed steps, keyed by outputName.
*/
function collectStepOutputs(
runState: OrchestrationRunState
): Record<string, unknown> {
const outputs: Record<string, unknown> = {};
for (const step of runState.plan.steps) {
if (!step.outputName) continue;
const stepState = runState.stepStatuses[step.id];
if (stepState && (stepState.status === 'completed' || stepState.status === 'skipped')) {
outputs[step.outputName] = stepState.result;
}
}
return outputs;
}
/**
* Resolve contextRefs by looking up output values from previous steps
* and formatting them as a context string to append to the instruction.
*/
function resolveContextRefs(
contextRefs: string[] | undefined,
stepOutputs: Record<string, unknown>
): string {
if (!contextRefs || contextRefs.length === 0) return '';
const parts: string[] = [];
for (const ref of contextRefs) {
const value = stepOutputs[ref];
if (value !== undefined) {
parts.push(`[${ref}]:\n${formatValue(value)}`);
}
}
return parts.join('\n\n');
}
/**
* Resolve a dot-notation path against an object.
* E.g., "step1.output.field" resolves by traversing the nested structure.
*/
function resolveNestedPath(
path: string,
obj: Record<string, unknown>
): unknown | undefined {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Format a value for insertion into an instruction string.
*/
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
// For objects/arrays, produce a JSON string
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Apply error handling for dispatch-level failures (not CLI completion failures).
* This mirrors the error handling in useCompletionCallbackChain but is needed
* when the dispatch itself fails before a CLI execution starts.
*/
function applyDispatchErrorHandling(planId: string, stepId: string): void {
const store = useOrchestratorStore.getState();
const runState = store.activePlans[planId];
if (!runState) return;
const stepDef = runState.plan.steps.find((s) => s.id === stepId);
const strategy =
stepDef?.errorHandling?.strategy ??
runState.plan.defaultErrorHandling.strategy ??
'pause_on_error';
switch (strategy) {
case 'pause_on_error':
store.pauseOrchestration(planId);
break;
case 'skip':
store.skipStep(planId, stepId);
store._advanceToNextStep(planId);
break;
case 'stop':
store.stopOrchestration(
planId,
`Dispatch failed for step "${stepId}"`
);
break;
default:
store.pauseOrchestration(planId);
break;
}
}

View File

@@ -0,0 +1,430 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { OrchestrationPlanBuilder } from '../OrchestrationPlanBuilder';
import { Flow, FlowNode, FlowEdge, PromptTemplateNodeData } from '../../types/flow';
import { IssueQueue, QueueItem } from '../../lib/api';
import {
OrchestrationStep,
ManualOrchestrationParams,
} from '../../types/orchestrator';
// Mock buildQueueItemContext as it's an external dependency
vi.mock('../../lib/queue-prompt', () => ({
buildQueueItemContext: vi.fn((item: QueueItem, issue: any) => `Instruction for ${item.item_id} from issue ${issue?.id}`),
}));
import { buildQueueItemContext } from '../../lib/queue-prompt';
describe('OrchestrationPlanBuilder', () => {
const MOCKED_CREATED_AT = '2026-02-14T10:00:00.000Z';
const MOCKED_UPDATED_AT = '2026-02-14T11:00:00.000Z';
beforeAll(() => {
// Mock Date.now() to ensure consistent IDs and timestamps
vi.spyOn(Date, 'now').mockReturnValue(new Date(MOCKED_CREATED_AT).getTime());
vi.spyOn(Date.prototype, 'toISOString').mockReturnValue(MOCKED_CREATED_AT);
});
afterAll(() => {
vi.restoreAllMocks();
});
describe('fromFlow', () => {
it('should correctly convert a simple linear flow into an OrchestrationPlan', () => {
const flow: Flow = {
id: 'flow-123',
name: 'Test Flow',
description: 'A simple linear flow',
version: '1.0.0',
created_at: MOCKED_CREATED_AT,
updated_at: MOCKED_UPDATED_AT,
nodes: [
{ id: 'nodeA', type: 'prompt-template', data: { label: 'Step A', instruction: 'Do A', outputName: 'outputA' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
{ id: 'nodeB', type: 'prompt-template', data: { label: 'Step B', instruction: 'Do B', contextRefs: ['outputA'] } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
{ id: 'nodeC', type: 'prompt-template', data: { label: 'Step C', instruction: 'Do C' } as PromptTemplateNodeData, position: { x: 2, y: 2 } },
] as FlowNode[],
edges: [
{ id: 'edge-ab', source: 'nodeA', target: 'nodeB' },
{ id: 'edge-bc', source: 'nodeB', target: 'nodeC' },
] as FlowEdge[],
variables: { var1: 'value1' },
metadata: {},
};
const plan = OrchestrationPlanBuilder.fromFlow(flow);
expect(plan).toBeDefined();
expect(plan.id).toBe('flow-123');
expect(plan.name).toBe('Test Flow');
expect(plan.source).toBe('flow');
expect(plan.sourceId).toBe('flow-123');
expect(plan.variables).toEqual({ var1: 'value1' });
expect(plan.steps).toHaveLength(3);
expect(plan.metadata.totalSteps).toBe(3);
expect(plan.metadata.estimatedComplexity).toBe('medium'); // 3 steps is medium
// Verify topological sort and dependencies
expect(plan.steps[0].id).toBe('nodeA');
expect(plan.steps[0].dependsOn).toEqual([]);
expect(plan.steps[1].id).toBe('nodeB');
expect(plan.steps[1].dependsOn).toEqual(['nodeA']);
expect(plan.steps[2].id).toBe('nodeC');
expect(plan.steps[2].dependsOn).toEqual(['nodeB']);
// Verify step details
expect(plan.steps[0].name).toBe('Step A');
expect(plan.steps[0].instruction).toBe('Do A');
expect(plan.steps[0].outputName).toBe('outputA');
expect(plan.steps[0].executionType).toBe('frontend-cli');
expect(plan.steps[1].name).toBe('Step B');
expect(plan.steps[1].instruction).toBe('Do B');
expect(plan.steps[1].contextRefs).toEqual(['outputA']);
});
it('should handle a more complex flow with branching and merging', () => {
const flow: Flow = {
id: 'flow-complex',
name: 'Complex Flow',
description: 'Branching and merging flow',
version: '1.0.0',
created_at: MOCKED_CREATED_AT,
updated_at: MOCKED_UPDATED_AT,
nodes: [
{ id: 'start', type: 'prompt-template', data: { label: 'Start', instruction: 'Start here' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
{ id: 'branchA', type: 'prompt-template', data: { label: 'Branch A', instruction: 'Path A' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
{ id: 'branchB', type: 'prompt-template', data: { label: 'Branch B', instruction: 'Path B' } as PromptTemplateNodeData, position: { x: 1, y: 2 } },
{ id: 'merge', type: 'prompt-template', data: { label: 'Merge', instruction: 'Merge results' } as PromptTemplateNodeData, position: { x: 2, y: 1 } },
{ id: 'end', type: 'prompt-template', data: { label: 'End', instruction: 'Finish' } as PromptTemplateNodeData, position: { x: 3, y: 1 } },
] as FlowNode[],
edges: [
{ id: 'e-start-a', source: 'start', target: 'branchA' },
{ id: 'e-start-b', source: 'start', target: 'branchB' },
{ id: 'e-a-merge', source: 'branchA', target: 'merge' },
{ id: 'e-b-merge', source: 'branchB', target: 'merge' },
{ id: 'e-merge-end', source: 'merge', target: 'end' },
] as FlowEdge[],
variables: {},
metadata: {},
};
const plan = OrchestrationPlanBuilder.fromFlow(flow);
expect(plan).toBeDefined();
expect(plan.steps).toHaveLength(5);
expect(plan.metadata.totalSteps).toBe(5);
expect(plan.metadata.hasParallelGroups).toBe(true); // branchA and branchB can run in parallel
expect(plan.metadata.estimatedComplexity).toBe('high'); // >5 steps, or parallel groups
// Verify topological sort (order might vary for parallel steps, but dependencies must be correct)
const startStep = plan.steps.find(s => s.id === 'start');
const branchAStep = plan.steps.find(s => s.id === 'branchA');
const branchBStep = plan.steps.find(s => s.id === 'branchB');
const mergeStep = plan.steps.find(s => s.id === 'merge');
const endStep = plan.steps.find(s => s.id === 'end');
expect(startStep?.dependsOn).toEqual([]);
expect(branchAStep?.dependsOn).toEqual(['start']);
expect(branchBStep?.dependsOn).toEqual(['start']);
expect(mergeStep?.dependsOn).toEqual(expect.arrayContaining(['branchA', 'branchB']));
expect(endStep?.dependsOn).toEqual(['merge']);
// Ensure 'merge' step comes after 'branchA' and 'branchB'
const indexA = plan.steps.indexOf(branchAStep!);
const indexB = plan.steps.indexOf(branchBStep!);
const indexMerge = plan.steps.indexOf(mergeStep!);
expect(indexMerge).toBeGreaterThan(indexA);
expect(indexMerge).toBeGreaterThan(indexB);
});
it('should detect cycles and throw an error', () => {
const flow: Flow = {
id: 'flow-cycle',
name: 'Cyclic Flow',
description: 'A flow with a cycle',
version: '1.0.0',
created_at: MOCKED_CREATED_AT,
updated_at: MOCKED_UPDATED_AT,
nodes: [
{ id: 'nodeA', type: 'prompt-template', data: { label: 'A', instruction: 'Do A' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
{ id: 'nodeB', type: 'prompt-template', data: { label: 'B', instruction: 'Do B' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
] as FlowNode[],
edges: [
{ id: 'e-ab', source: 'nodeA', target: 'nodeB' },
{ id: 'e-ba', source: 'nodeB', target: 'nodeA' }, // Cycle
] as FlowEdge[],
variables: {},
metadata: {},
};
expect(() => OrchestrationPlanBuilder.fromFlow(flow)).toThrow('Cycle detected in flow graph. Cannot build orchestration plan from cyclic flow.');
});
it('should correctly map sessionStrategy and executionType from node data', () => {
const flow: Flow = {
id: 'flow-delivery',
name: 'Delivery Flow',
description: 'Flow with different delivery types',
version: '1.0.0',
created_at: MOCKED_CREATED_AT,
updated_at: MOCKED_UPDATED_AT,
nodes: [
{ id: 'node1', type: 'prompt-template', data: { label: 'New Session', instruction: 'New', delivery: 'newExecution' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
{ id: 'node2', type: 'prompt-template', data: { label: 'Specific Session', instruction: 'Specific', delivery: 'sendToSession', targetSessionKey: 'sessionX' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
{ id: 'node3', type: 'prompt-template', data: { label: 'Slash Cmd', instruction: 'Slash', slashCommand: 'test:cmd', mode: 'mainprocess' } as PromptTemplateNodeData, position: { x: 2, y: 2 } },
{ id: 'node4', type: 'prompt-template', data: { label: 'Frontend CLI', instruction: 'CLI', tool: 'gemini', mode: 'analysis' } as PromptTemplateNodeData, position: { x: 3, y: 3 } },
] as FlowNode[],
edges: [
{ id: 'e1', source: 'node1', target: 'node2' },
{ id: 'e2', source: 'node2', target: 'node3' },
{ id: 'e3', source: 'node3', target: 'node4' },
],
variables: {},
metadata: {},
};
const plan = OrchestrationPlanBuilder.fromFlow(flow);
expect(plan.steps).toHaveLength(4);
expect(plan.steps[0].id).toBe('node1');
expect(plan.steps[0].sessionStrategy).toBe('new_session');
expect(plan.steps[0].executionType).toBe('frontend-cli'); // default as no slash command/tool specified
expect(plan.steps[1].id).toBe('node2');
expect(plan.steps[1].sessionStrategy).toBe('specific_session');
expect(plan.steps[1].targetSessionKey).toBe('sessionX');
expect(plan.steps[1].executionType).toBe('frontend-cli');
expect(plan.steps[2].id).toBe('node3');
expect(plan.steps[2].executionType).toBe('slash-command');
expect(plan.steps[3].id).toBe('node4');
expect(plan.steps[3].tool).toBe('gemini');
expect(plan.steps[3].mode).toBe('analysis');
expect(plan.steps[3].executionType).toBe('frontend-cli');
});
});
describe('fromQueue', () => {
it('should correctly convert an IssueQueue with S* groups into an OrchestrationPlan', () => {
const issue1 = { id: 'issue-1', title: 'Fix bug A', description: 'desc A' };
const issue2 = { id: 'issue-2', title: 'Implement feature B', description: 'desc B' };
const issue3 = { id: 'issue-3', title: 'Refactor C', description: 'desc C' };
const item1: QueueItem = { item_id: 'qi-1', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group1', depends_on: [], status: 'pending', execution_order: 0, semantic_priority: 0 };
const item2: QueueItem = { item_id: 'qi-2', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group1', depends_on: [], status: 'pending', execution_order: 1, semantic_priority: 0 };
const item3: QueueItem = { item_id: 'qi-3', issue_id: 'issue-2', solution_id: 'sol-2', execution_group: 'S*group2', depends_on: [], status: 'pending', execution_order: 2, semantic_priority: 0 };
const item4: QueueItem = { item_id: 'qi-4', issue_id: 'issue-3', solution_id: 'sol-3', execution_group: 'S*group3', depends_on: [], status: 'pending', execution_order: 3, semantic_priority: 0 };
const queue: IssueQueue = {
id: 'queue-abc',
execution_groups: ['S*group1', 'S*group2', 'S*group3'],
grouped_items: {
'S*group1': [item1, item2],
'S*group2': [item3],
'S*group3': [item4],
},
conflicts: [],
};
const issues = new Map<string, any>();
issues.set('issue-1', issue1);
issues.set('issue-2', issue2);
issues.set('issue-3', issue3);
const plan = OrchestrationPlanBuilder.fromQueue(queue, issues);
expect(plan).toBeDefined();
expect(plan.source).toBe('queue');
expect(plan.sourceId).toBe('queue-abc');
expect(plan.steps).toHaveLength(4);
expect(plan.metadata.totalSteps).toBe(4);
// fromQueue uses explicit P*/S* prefix check for hasParallelGroups (not DAG heuristic).
// All groups here are S* (sequential), so hasParallelGroups is false.
expect(plan.metadata.hasParallelGroups).toBe(false);
// estimateComplexity uses detectParallelGroups (DAG heuristic), which sees qi-1 and qi-2
// sharing dependsOn=[] without mutual dependencies. But estimateComplexity also checks
// step count (<=1 => low, >5 => high), so 4 steps with no DAG-detected parallelism
// (fromQueue passes the already-computed hasParallelGroups=false to metadata, not the
// DAG heuristic) means medium. Actually estimateComplexity is a separate static call
// that does its own DAG-level check.
// With 4 steps and qi-1/qi-2 sharing empty dependsOn: detectParallelGroups returns true,
// so estimateComplexity returns 'high'.
expect(plan.metadata.estimatedComplexity).toBe('high');
// Verify sequential dependencies
// S*group1 items have no dependencies (first group)
expect(plan.steps.find(s => s.id === 'queue-item-qi-1')?.dependsOn).toEqual([]);
expect(plan.steps.find(s => s.id === 'queue-item-qi-2')?.dependsOn).toEqual([]);
// S*group2 items depend on all items from S*group1
expect(plan.steps.find(s => s.id === 'queue-item-qi-3')?.dependsOn).toEqual(expect.arrayContaining(['queue-item-qi-1', 'queue-item-qi-2']));
// S*group3 items depend on all items from S*group2
expect(plan.steps.find(s => s.id === 'queue-item-qi-4')?.dependsOn).toEqual(['queue-item-qi-3']);
// Verify instruction context via mock
const mockedBuild = vi.mocked(buildQueueItemContext);
expect(plan.steps[0].instruction).toBe('Instruction for qi-1 from issue issue-1');
expect(mockedBuild).toHaveBeenCalledWith(item1, issue1);
});
it('should correctly convert an IssueQueue with P* groups into an OrchestrationPlan', () => {
const issue1 = { id: 'issue-1', title: 'Fix bug A', description: 'desc A' };
const issue2 = { id: 'issue-2', title: 'Implement feature B', description: 'desc B' };
const item1: QueueItem = { item_id: 'qi-1', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'P*group1', depends_on: [], status: 'pending', execution_order: 0, semantic_priority: 0 };
const item2: QueueItem = { item_id: 'qi-2', issue_id: 'issue-2', solution_id: 'sol-2', execution_group: 'P*group1', depends_on: [], status: 'pending', execution_order: 1, semantic_priority: 0 };
const item3: QueueItem = { item_id: 'qi-3', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group2', depends_on: [], status: 'pending', execution_order: 2, semantic_priority: 0 };
const queue: IssueQueue = {
id: 'queue-parallel',
execution_groups: ['P*group1', 'S*group2'],
grouped_items: {
'P*group1': [item1, item2],
'S*group2': [item3],
},
conflicts: [],
};
const issues = new Map<string, any>();
issues.set('issue-1', issue1);
issues.set('issue-2', issue2);
const plan = OrchestrationPlanBuilder.fromQueue(queue, issues);
expect(plan).toBeDefined();
expect(plan.steps).toHaveLength(3);
expect(plan.metadata.hasParallelGroups).toBe(true);
expect(plan.metadata.estimatedComplexity).toBe('high');
// P*group1 items have no dependencies (first group)
expect(plan.steps.find(s => s.id === 'queue-item-qi-1')?.dependsOn).toEqual([]);
expect(plan.steps.find(s => s.id === 'queue-item-qi-2')?.dependsOn).toEqual([]);
// S*group2 items depend on all items from P*group1
expect(plan.steps.find(s => s.id === 'queue-item-qi-3')?.dependsOn).toEqual(expect.arrayContaining(['queue-item-qi-1', 'queue-item-qi-2']));
});
});
describe('fromManual', () => {
it('should create a single-step OrchestrationPlan from manual input', () => {
const params: ManualOrchestrationParams = {
prompt: 'Analyze current directory',
tool: 'gemini',
mode: 'analysis',
sessionStrategy: 'new_session',
outputName: 'analysisResult',
errorHandling: { strategy: 'stop', maxRetries: 1, retryDelayMs: 100 },
};
const plan = OrchestrationPlanBuilder.fromManual(params);
expect(plan).toBeDefined();
expect(plan.id).toMatch(/^manual-plan-/);
expect(plan.name).toBe('Manual Orchestration');
expect(plan.source).toBe('manual');
expect(plan.steps).toHaveLength(1);
expect(plan.metadata.totalSteps).toBe(1);
expect(plan.metadata.hasParallelGroups).toBe(false);
expect(plan.metadata.estimatedComplexity).toBe('low');
const step = plan.steps[0];
expect(step.id).toMatch(/^manual-step-/);
expect(step.name).toBe('Manual Execution');
expect(step.instruction).toBe('Analyze current directory');
expect(step.tool).toBe('gemini');
expect(step.mode).toBe('analysis');
expect(step.sessionStrategy).toBe('new_session');
expect(step.targetSessionKey).toBeUndefined();
expect(step.dependsOn).toEqual([]);
expect(step.outputName).toBe('analysisResult');
expect(step.errorHandling).toEqual({ strategy: 'stop', maxRetries: 1, retryDelayMs: 100 });
expect(step.executionType).toBe('frontend-cli');
});
it('should use default session strategy and error handling if not provided', () => {
const params: ManualOrchestrationParams = {
prompt: 'Simple command',
};
const plan = OrchestrationPlanBuilder.fromManual(params);
const step = plan.steps[0];
expect(step.sessionStrategy).toBe('reuse_default');
expect(step.errorHandling).toBeUndefined(); // Should be undefined if not explicitly set for step
expect(plan.defaultSessionStrategy).toBe('reuse_default');
expect(plan.defaultErrorHandling).toEqual({ strategy: 'pause_on_error', maxRetries: 0, retryDelayMs: 0 });
});
});
describe('Utility methods', () => {
it('should correctly detect parallel groups', () => {
// Linear steps, no parallel
const linearSteps: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).detectParallelGroups(linearSteps)).toBe(false);
// Parallel steps (2 and 3 depend on 1, but not on each other)
const parallelSteps: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).detectParallelGroups(parallelSteps)).toBe(true);
// Complex parallel scenario
const complexParallelSteps: OrchestrationStep[] = [
{ id: 'A', name: 'sA', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: 'B', name: 'sB', instruction: 'i', dependsOn: ['A'], executionType: 'frontend-cli' },
{ id: 'C', name: 'sC', instruction: 'i', dependsOn: ['A'], executionType: 'frontend-cli' },
{ id: 'D', name: 'sD', instruction: 'i', dependsOn: ['B', 'C'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).detectParallelGroups(complexParallelSteps)).toBe(true);
// Parallel steps with some implicit dependencies (not strictly parallel)
const nonStrictlyParallel: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).detectParallelGroups(nonStrictlyParallel)).toBe(true);
});
it('should correctly estimate complexity', () => {
const stepsLow: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsLow)).toBe('low');
const stepsMedium: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['3'], executionType: 'frontend-cli' },
{ id: '5', name: 's5', instruction: 'i', dependsOn: ['4'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsMedium)).toBe('medium');
const stepsHighByCount: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['3'], executionType: 'frontend-cli' },
{ id: '5', name: 's5', instruction: 'i', dependsOn: ['4'], executionType: 'frontend-cli' },
{ id: '6', name: 's6', instruction: 'i', dependsOn: ['5'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsHighByCount)).toBe('high');
const stepsHighByParallel: OrchestrationStep[] = [
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
];
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsHighByParallel)).toBe('high');
});
});
});

View File

@@ -0,0 +1,18 @@
// ========================================
// Orchestrator Module
// ========================================
// Barrel exports for the orchestration system.
//
// OrchestrationPlanBuilder: Builds plans from Flow, Queue, or Manual input
// SequentialRunner: Manages PTY session lifecycle and step-by-step dispatch
export { OrchestrationPlanBuilder } from './OrchestrationPlanBuilder';
export {
start,
executeStep,
onStepAdvanced,
stop,
stopAll,
interpolateInstruction,
} from './SequentialRunner';
export type { StartOptions } from './SequentialRunner';

View File

@@ -79,41 +79,65 @@ function FixProgressCarousel({ sessionId }: { sessionId: string }) {
const [currentSlide, setCurrentSlide] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
// Fetch fix progress data
const fetchFixProgress = React.useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`);
if (!response.ok) {
if (response.status === 404) {
setFixProgressData(null);
}
return;
}
const data = await response.json();
setFixProgressData(data);
} catch (err) {
console.error('Failed to fetch fix progress:', err);
} finally {
setIsLoading(false);
}
}, [sessionId]);
// Poll for fix progress updates
// Sequential polling with AbortController — no concurrent requests possible
React.useEffect(() => {
fetchFixProgress();
const abortController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let errorCount = 0;
// Stop polling if phase is completion
if (fixProgressData?.phase === 'completion') {
return;
}
const poll = async () => {
if (stopped) return;
const interval = setInterval(() => {
fetchFixProgress();
}, 5000);
setIsLoading(true);
try {
const response = await fetch(
`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`,
{ signal: abortController.signal }
);
if (!response.ok) {
errorCount += 1;
if (response.status === 404 || errorCount >= 3) {
stopped = true;
setFixProgressData(null);
return;
}
} else {
errorCount = 0;
const data = await response.json();
setFixProgressData(data);
if (data?.phase === 'completion') {
stopped = true;
return;
}
}
} catch {
if (abortController.signal.aborted) return;
errorCount += 1;
if (errorCount >= 3) {
stopped = true;
return;
}
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
return () => clearInterval(interval);
}, [fetchFixProgress, fixProgressData?.phase]);
// Schedule next poll only after current request completes
if (!stopped) {
timeoutId = setTimeout(poll, 5000);
}
};
poll();
return () => {
stopped = true;
abortController.abort();
if (timeoutId) clearTimeout(timeoutId);
};
}, [sessionId]);
// Navigate carousel
const navigateSlide = (direction: 'prev' | 'next' | number) => {

View File

@@ -1,25 +1,27 @@
// ========================================
// TeamPage
// ========================================
// Main page for team execution visualization
// Main page for team execution - list/detail dual view with tabbed detail
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Users } from 'lucide-react';
import { Package, MessageSquare } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { useTeamStore } from '@/stores/teamStore';
import { useTeams, useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
import { TeamEmptyState } from '@/components/team/TeamEmptyState';
import type { TeamDetailTab } from '@/stores/teamStore';
import { useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
import { TeamHeader } from '@/components/team/TeamHeader';
import { TeamPipeline } from '@/components/team/TeamPipeline';
import { TeamMembersPanel } from '@/components/team/TeamMembersPanel';
import { TeamMessageFeed } from '@/components/team/TeamMessageFeed';
import { TeamArtifacts } from '@/components/team/TeamArtifacts';
import { TeamListView } from '@/components/team/TeamListView';
export function TeamPage() {
const { formatMessage } = useIntl();
const {
selectedTeam,
setSelectedTeam,
viewMode,
autoRefresh,
toggleAutoRefresh,
messageFilter,
@@ -27,89 +29,92 @@ export function TeamPage() {
clearMessageFilter,
timelineExpanded,
setTimelineExpanded,
detailTab,
setDetailTab,
backToList,
} = useTeamStore();
// Data hooks
const { teams, isLoading: teamsLoading } = useTeams();
// Data hooks (only active in detail mode)
const { messages, total: messageTotal } = useTeamMessages(
selectedTeam,
viewMode === 'detail' ? selectedTeam : null,
messageFilter
);
const { members, totalMessages } = useTeamStatus(selectedTeam);
const { members, totalMessages } = useTeamStatus(
viewMode === 'detail' ? selectedTeam : null
);
// Auto-select first team if none selected
useEffect(() => {
if (!selectedTeam && teams.length > 0) {
setSelectedTeam(teams[0].name);
}
}, [selectedTeam, teams, setSelectedTeam]);
// Show empty state when no teams exist
if (!teamsLoading && teams.length === 0) {
// List view
if (viewMode === 'list' || !selectedTeam) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-6">
<Users className="w-5 h-5" />
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
</div>
<TeamEmptyState />
<TeamListView />
</div>
);
}
const tabs: TabItem[] = [
{
value: 'artifacts',
label: formatMessage({ id: 'team.tabs.artifacts' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'messages',
label: formatMessage({ id: 'team.tabs.messages' }),
icon: <MessageSquare className="h-4 w-4" />,
},
];
// Detail view
return (
<div className="p-6 space-y-6">
{/* Page title */}
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
</div>
{/* Team Header: selector + stats + controls */}
{/* Detail Header: back button + team name + stats + controls */}
<TeamHeader
teams={teams}
selectedTeam={selectedTeam}
onSelectTeam={setSelectedTeam}
onBack={backToList}
members={members}
totalMessages={totalMessages}
autoRefresh={autoRefresh}
onToggleAutoRefresh={toggleAutoRefresh}
/>
{selectedTeam ? (
<>
{/* Main content grid: Pipeline (left) + Members (right) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Pipeline visualization */}
<Card className="lg:col-span-2">
<CardContent className="p-4">
<TeamPipeline messages={messages} />
</CardContent>
</Card>
{/* Overview: Pipeline + Members (always visible) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 flex flex-col">
<CardContent className="p-4 flex-1">
<TeamPipeline messages={messages} />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<TeamMembersPanel members={members} />
</CardContent>
</Card>
</div>
{/* Members panel */}
<Card>
<CardContent className="p-4">
<TeamMembersPanel members={members} />
</CardContent>
</Card>
</div>
{/* Tab Navigation: Artifacts / Messages */}
<TabsNavigation
value={detailTab}
onValueChange={(v) => setDetailTab(v as TeamDetailTab)}
tabs={tabs}
/>
{/* Message timeline */}
<TeamMessageFeed
messages={messages}
total={messageTotal}
filter={messageFilter}
onFilterChange={setMessageFilter}
onClearFilter={clearMessageFilter}
expanded={timelineExpanded}
onExpandedChange={setTimelineExpanded}
/>
</>
) : (
<div className="text-center py-12 text-muted-foreground">
{formatMessage({ id: 'team.noTeamSelected' })}
</div>
{/* Artifacts Tab */}
{detailTab === 'artifacts' && (
<TeamArtifacts messages={messages} />
)}
{/* Messages Tab */}
{detailTab === 'messages' && (
<TeamMessageFeed
messages={messages}
total={messageTotal}
filter={messageFilter}
onFilterChange={setMessageFilter}
onClearFilter={clearMessageFilter}
expanded={timelineExpanded}
onExpandedChange={setTimelineExpanded}
/>
)}
</div>
);

View File

@@ -356,15 +356,21 @@ export const useCliStreamStore = create<CliStreamState>()(
// Also update in executions
const state = get();
if (state.executions[executionId]) {
set((state) => ({
executions: {
...state.executions,
[executionId]: {
...state.executions[executionId],
output: [...state.executions[executionId].output, line],
set((state) => {
const currentOutput = state.executions[executionId].output;
const updatedOutput = [...currentOutput, line];
return {
executions: {
...state.executions,
[executionId]: {
...state.executions[executionId],
output: updatedOutput.length > MAX_OUTPUT_LINES
? updatedOutput.slice(-MAX_OUTPUT_LINES)
: updatedOutput,
},
},
},
}), false, 'cliStream/updateExecutionOutput');
};
}, false, 'cliStream/updateExecutionOutput');
}
},
@@ -529,11 +535,14 @@ export const useCliStreamStore = create<CliStreamState>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_OUTPUTS: CliOutputLine[] = [];
/**
* Selector for getting outputs by execution ID
*/
export const selectOutputs = (state: CliStreamState, executionId: string) =>
state.outputs[executionId] || [];
state.outputs[executionId] || EMPTY_OUTPUTS;
/**
* Selector for getting addOutput action

View File

@@ -464,6 +464,7 @@ export const useExecutionStore = create<ExecutionStore>()(
);
// Selectors for common access patterns
const EMPTY_TOOL_CALLS: never[] = [];
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
export const selectLogs = (state: ExecutionStore) => state.logs;
@@ -474,7 +475,7 @@ export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollL
export const selectNodeOutputs = (state: ExecutionStore, nodeId: string) =>
state.nodeOutputs[nodeId];
export const selectNodeToolCalls = (state: ExecutionStore, nodeId: string) =>
state.nodeToolCalls[nodeId] || [];
state.nodeToolCalls[nodeId] || EMPTY_TOOL_CALLS;
export const selectSelectedNodeId = (state: ExecutionStore) => state.selectedNodeId;
// Helper to check if execution is active
@@ -489,6 +490,6 @@ export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
// Helper to get selected node's tool calls
export const selectSelectedNodeToolCalls = (state: ExecutionStore) => {
if (!state.selectedNodeId) return [];
return state.nodeToolCalls[state.selectedNodeId] || [];
if (!state.selectedNodeId) return EMPTY_TOOL_CALLS;
return state.nodeToolCalls[state.selectedNodeId] || EMPTY_TOOL_CALLS;
};

View File

@@ -111,6 +111,18 @@ export {
selectHasActiveExecution,
} from './queueExecutionStore';
// Orchestrator Store
export {
useOrchestratorStore,
selectActivePlans,
selectPlan,
selectStepStatuses,
selectStepRunState,
selectHasRunningPlan,
selectActivePlanCount,
selectPlanStepByExecutionId,
} from './orchestratorStore';
// Terminal Panel Store Types
export type {
PanelView,
@@ -131,6 +143,15 @@ export type {
QueueExecutionStore,
} from './queueExecutionStore';
// Orchestrator Store Types
export type {
StepRunState,
OrchestrationRunState,
OrchestratorState,
OrchestratorActions,
OrchestratorStore,
} from './orchestratorStore';
// Re-export types for convenience
export type {
// App Store Types

View File

@@ -0,0 +1,533 @@
// ========================================
// Orchestrator Store
// ========================================
// Zustand store for orchestration plan execution state machine.
// Manages multiple concurrent orchestration plans, step lifecycle,
// and execution-to-step mapping for WebSocket callback chain matching.
//
// NOTE: This is SEPARATE from executionStore.ts (which handles Flow DAG
// execution visualization). This store manages the plan-level state machine
// for automated step advancement.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
OrchestrationPlan,
OrchestrationStatus,
StepStatus,
} from '../types/orchestrator';
// ========== Types ==========
/** Runtime state for a single orchestration step */
export interface StepRunState {
/** Current step status */
status: StepStatus;
/** CLI execution ID assigned when the step starts executing */
executionId?: string;
/** Step execution result (populated on completion) */
result?: unknown;
/** Error message (populated on failure) */
error?: string;
/** ISO timestamp when step started executing */
startedAt?: string;
/** ISO timestamp when step completed/failed */
completedAt?: string;
/** Number of retry attempts for this step */
retryCount: number;
}
/** Runtime state for an entire orchestration plan */
export interface OrchestrationRunState {
/** The orchestration plan definition */
plan: OrchestrationPlan;
/** Current overall status of the plan */
status: OrchestrationStatus;
/** Index of the current step being executed (for sequential tracking) */
currentStepIndex: number;
/** Per-step runtime state keyed by step ID */
stepStatuses: Record<string, StepRunState>;
/** Maps executionId -> stepId for callback chain matching */
executionIdMap: Record<string, string>;
/** Optional session key for session-scoped orchestration */
sessionKey?: string;
/** Error message if the plan itself failed */
error?: string;
}
export interface OrchestratorState {
/** All active orchestration plans keyed by plan ID */
activePlans: Record<string, OrchestrationRunState>;
}
export interface OrchestratorActions {
/** Initialize and start an orchestration plan */
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => void;
/** Pause a running orchestration */
pauseOrchestration: (planId: string) => void;
/** Resume a paused orchestration */
resumeOrchestration: (planId: string) => void;
/** Stop an orchestration (marks as failed) */
stopOrchestration: (planId: string, error?: string) => void;
/** Update a step's status with optional result or error */
updateStepStatus: (
planId: string,
stepId: string,
status: StepStatus,
result?: { data?: unknown; error?: string }
) => void;
/** Register an execution ID mapping for callback chain matching */
registerExecution: (planId: string, stepId: string, executionId: string) => void;
/** Retry a failed step (reset to pending, increment retryCount) */
retryStep: (planId: string, stepId: string) => void;
/** Skip a step (mark as skipped, treated as completed for dependency resolution) */
skipStep: (planId: string, stepId: string) => void;
/**
* Internal: Find and return the next ready step ID.
* Does NOT execute the step - only identifies it and updates currentStepIndex.
* If no steps remain, marks plan as completed.
* Returns the step ID if found, null otherwise.
*/
_advanceToNextStep: (planId: string) => string | null;
/**
* Pure getter: Find the next step whose dependsOn are all completed/skipped.
* Returns the step ID or null if none are ready.
*/
getNextReadyStep: (planId: string) => string | null;
/** Remove a completed/failed plan from active tracking */
removePlan: (planId: string) => void;
/** Clear all plans */
clearAll: () => void;
}
export type OrchestratorStore = OrchestratorState & OrchestratorActions;
// ========== Helpers ==========
/**
* Check if a step's dependencies are all satisfied (completed or skipped).
*/
function areDependenciesSatisfied(
step: { dependsOn: string[] },
stepStatuses: Record<string, StepRunState>
): boolean {
return step.dependsOn.every((depId) => {
const depState = stepStatuses[depId];
return depState && (depState.status === 'completed' || depState.status === 'skipped');
});
}
/**
* Find the next ready step from a plan's steps that is pending and has all deps satisfied.
*/
function findNextReadyStep(
plan: OrchestrationPlan,
stepStatuses: Record<string, StepRunState>
): string | null {
for (const step of plan.steps) {
const state = stepStatuses[step.id];
if (state && state.status === 'pending' && areDependenciesSatisfied(step, stepStatuses)) {
return step.id;
}
}
return null;
}
/**
* Check if all steps are in a terminal state (completed, skipped, or failed).
*/
function areAllStepsTerminal(stepStatuses: Record<string, StepRunState>): boolean {
return Object.values(stepStatuses).every(
(s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed'
);
}
// ========== Initial State ==========
const initialState: OrchestratorState = {
activePlans: {},
};
// ========== Store ==========
export const useOrchestratorStore = create<OrchestratorStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Plan Lifecycle ==========
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => {
// Initialize all step statuses as pending
const stepStatuses: Record<string, StepRunState> = {};
for (const step of plan.steps) {
stepStatuses[step.id] = {
status: 'pending',
retryCount: 0,
};
}
const runState: OrchestrationRunState = {
plan,
status: 'running',
currentStepIndex: 0,
stepStatuses,
executionIdMap: {},
sessionKey,
};
set(
(state) => ({
activePlans: {
...state.activePlans,
[plan.id]: runState,
},
}),
false,
'startOrchestration'
);
},
pauseOrchestration: (planId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'paused',
},
},
};
},
false,
'pauseOrchestration'
);
},
resumeOrchestration: (planId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'paused') return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'running',
},
},
};
},
false,
'resumeOrchestration'
);
},
stopOrchestration: (planId: string, error?: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'failed',
error: error ?? 'Orchestration stopped by user',
},
},
};
},
false,
'stopOrchestration'
);
},
// ========== Step State Updates ==========
updateStepStatus: (
planId: string,
stepId: string,
status: StepStatus,
result?: { data?: unknown; error?: string }
) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
const now = new Date().toISOString();
const isStarting = status === 'running';
const isTerminal =
status === 'completed' || status === 'failed' || status === 'skipped';
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status,
startedAt: isStarting ? now : stepState.startedAt,
completedAt: isTerminal ? now : stepState.completedAt,
result: result?.data ?? stepState.result,
error: result?.error ?? stepState.error,
},
},
},
},
};
},
false,
'updateStepStatus'
);
},
registerExecution: (planId: string, stepId: string, executionId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
executionIdMap: {
...existing.executionIdMap,
[executionId]: stepId,
},
},
},
};
},
false,
'registerExecution'
);
},
retryStep: (planId: string, stepId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'running',
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status: 'pending',
error: undefined,
result: undefined,
startedAt: undefined,
completedAt: undefined,
retryCount: stepState.retryCount + 1,
},
},
},
},
};
},
false,
'retryStep'
);
},
skipStep: (planId: string, stepId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status: 'skipped',
completedAt: new Date().toISOString(),
},
},
},
},
};
},
false,
'skipStep'
);
},
// ========== Step Advancement ==========
_advanceToNextStep: (planId: string): string | null => {
const state = get();
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return null;
// Find the next step that is pending with all dependencies satisfied
const nextStepId = findNextReadyStep(existing.plan, existing.stepStatuses);
if (nextStepId) {
// Update currentStepIndex to match the found step
const stepIndex = existing.plan.steps.findIndex((s) => s.id === nextStepId);
set(
(prevState) => {
const plan = prevState.activePlans[planId];
if (!plan) return prevState;
return {
activePlans: {
...prevState.activePlans,
[planId]: {
...plan,
currentStepIndex: stepIndex >= 0 ? stepIndex : plan.currentStepIndex,
},
},
};
},
false,
'_advanceToNextStep'
);
return nextStepId;
}
// No pending steps found - check if all are terminal
if (areAllStepsTerminal(existing.stepStatuses)) {
// Check if any step failed (and was not skipped)
const hasFailed = Object.values(existing.stepStatuses).some(
(s) => s.status === 'failed'
);
set(
(prevState) => {
const plan = prevState.activePlans[planId];
if (!plan) return prevState;
return {
activePlans: {
...prevState.activePlans,
[planId]: {
...plan,
status: hasFailed ? 'failed' : 'completed',
},
},
};
},
false,
'_advanceToNextStep/complete'
);
}
return null;
},
getNextReadyStep: (planId: string): string | null => {
const state = get();
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return null;
return findNextReadyStep(existing.plan, existing.stepStatuses);
},
// ========== Cleanup ==========
removePlan: (planId: string) => {
set(
(state) => {
const { [planId]: _removed, ...remaining } = state.activePlans;
return { activePlans: remaining };
},
false,
'removePlan'
);
},
clearAll: () => {
set(initialState, false, 'clearAll');
},
}),
{ name: 'OrchestratorStore' }
)
);
// ========== Selectors ==========
/** Select all active plans */
export const selectActivePlans = (state: OrchestratorStore) => state.activePlans;
/** Select a specific plan by ID */
export const selectPlan =
(planId: string) =>
(state: OrchestratorStore): OrchestrationRunState | undefined =>
state.activePlans[planId];
/** Select the step statuses for a plan */
export const selectStepStatuses =
(planId: string) =>
(state: OrchestratorStore): Record<string, StepRunState> | undefined =>
state.activePlans[planId]?.stepStatuses;
/** Select a specific step's run state */
export const selectStepRunState =
(planId: string, stepId: string) =>
(state: OrchestratorStore): StepRunState | undefined =>
state.activePlans[planId]?.stepStatuses[stepId];
/** Check if any plan is currently running */
export const selectHasRunningPlan = (state: OrchestratorStore): boolean =>
Object.values(state.activePlans).some((p) => p.status === 'running');
/** Get the count of active (non-terminal) plans */
export const selectActivePlanCount = (state: OrchestratorStore): number =>
Object.values(state.activePlans).filter(
(p) => p.status === 'running' || p.status === 'paused'
).length;
/** Look up which plan and step an executionId belongs to */
export const selectPlanStepByExecutionId =
(executionId: string) =>
(
state: OrchestratorStore
): { planId: string; stepId: string } | undefined => {
for (const [planId, runState] of Object.entries(state.activePlans)) {
const stepId = runState.executionIdMap[executionId];
if (stepId) {
return { planId, stepId };
}
}
return undefined;
};

View File

@@ -157,21 +157,33 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_EXECUTIONS: QueueExecution[] = [];
/** Select all executions as a record */
export const selectQueueExecutions = (state: QueueExecutionStore) => state.executions;
/** Select only currently running executions */
/**
* Select only currently running executions.
* WARNING: Returns new array each call — use with useMemo in components.
*/
export const selectActiveExecutions = (state: QueueExecutionStore): QueueExecution[] => {
return Object.values(state.executions).filter((exec) => exec.status === 'running');
const all = Object.values(state.executions);
const running = all.filter((exec) => exec.status === 'running');
return running.length === 0 ? EMPTY_EXECUTIONS : running;
};
/** Select executions for a specific queue item */
/**
* Select executions for a specific queue item.
* WARNING: Returns new array each call — use with useMemo in components.
*/
export const selectByQueueItem =
(queueItemId: string) =>
(state: QueueExecutionStore): QueueExecution[] => {
return Object.values(state.executions).filter(
const matched = Object.values(state.executions).filter(
(exec) => exec.queueItemId === queueItemId
);
return matched.length === 0 ? EMPTY_EXECUTIONS : matched;
};
/** Compute execution statistics by status */

View File

@@ -7,16 +7,28 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type { TeamMessageFilter } from '@/types/team';
export type TeamDetailTab = 'artifacts' | 'messages';
interface TeamStore {
selectedTeam: string | null;
autoRefresh: boolean;
messageFilter: TeamMessageFilter;
timelineExpanded: boolean;
viewMode: 'list' | 'detail';
locationFilter: 'active' | 'archived' | 'all';
searchQuery: string;
detailTab: TeamDetailTab;
setSelectedTeam: (name: string | null) => void;
toggleAutoRefresh: () => void;
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
clearMessageFilter: () => void;
setTimelineExpanded: (expanded: boolean) => void;
setViewMode: (mode: 'list' | 'detail') => void;
setLocationFilter: (filter: 'active' | 'archived' | 'all') => void;
setSearchQuery: (query: string) => void;
setDetailTab: (tab: TeamDetailTab) => void;
selectTeamAndShowDetail: (name: string) => void;
backToList: () => void;
}
export const useTeamStore = create<TeamStore>()(
@@ -27,12 +39,22 @@ export const useTeamStore = create<TeamStore>()(
autoRefresh: true,
messageFilter: {},
timelineExpanded: true,
viewMode: 'list',
locationFilter: 'active',
searchQuery: '',
detailTab: 'artifacts',
setSelectedTeam: (name) => set({ selectedTeam: name }),
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
setMessageFilter: (filter) =>
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
clearMessageFilter: () => set({ messageFilter: {} }),
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
setViewMode: (mode) => set({ viewMode: mode }),
setLocationFilter: (filter) => set({ locationFilter: filter }),
setSearchQuery: (query) => set({ searchQuery: query }),
setDetailTab: (tab) => set({ detailTab: tab }),
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'artifacts' }),
backToList: () => set({ viewMode: 'list', detailTab: 'artifacts' }),
}),
{ name: 'ccw-team-store' }
),

View File

@@ -28,6 +28,8 @@ export interface TerminalPanelActions {
openTerminal: (sessionKey: string) => void;
/** Close the terminal panel (keeps terminal order intact) */
closePanel: () => void;
/** Toggle panel open/closed; when opening, restores last active or shows queue */
togglePanel: () => void;
/** Switch active terminal without opening/closing */
setActiveTerminal: (sessionKey: string) => void;
/** Switch panel view between 'terminal' and 'queue' */
@@ -80,6 +82,24 @@ export const useTerminalPanelStore = create<TerminalPanelStore>()(
set({ isPanelOpen: false }, false, 'closePanel');
},
togglePanel: () => {
const { isPanelOpen, activeTerminalId, terminalOrder } = get();
if (isPanelOpen) {
set({ isPanelOpen: false }, false, 'togglePanel/close');
} else {
const nextActive = activeTerminalId ?? terminalOrder[0] ?? null;
set(
{
isPanelOpen: true,
activeTerminalId: nextActive,
panelView: nextActive ? 'terminal' : 'queue',
},
false,
'togglePanel/open'
);
}
},
// ========== Terminal Selection ==========
setActiveTerminal: (sessionKey: string) => {

View File

@@ -4,6 +4,7 @@
// Zustand store for managing CLI Viewer layout and tab state
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
@@ -909,6 +910,9 @@ export const useViewerStore = create<ViewerState>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_TABS: TabState[] = [];
/**
* Select the current layout
*/
@@ -940,11 +944,12 @@ export const selectPane = (state: ViewerState, paneId: PaneId) => state.panes[pa
export const selectTab = (state: ViewerState, tabId: TabId) => state.tabs[tabId];
/**
* Select tabs for a specific pane, sorted by order
* Select tabs for a specific pane, sorted by order.
* WARNING: Returns new array each call — use with useMemo or useShallow in components.
*/
export const selectPaneTabs = (state: ViewerState, paneId: PaneId): TabState[] => {
const pane = state.panes[paneId];
if (!pane) return [];
if (!pane) return EMPTY_TABS;
return [...pane.tabs].sort((a, b) => a.order - b.order);
};
@@ -964,7 +969,7 @@ export const selectActiveTab = (state: ViewerState, paneId: PaneId): TabState |
* Useful for components that only need actions, not the full state
*/
export const useViewerActions = () => {
return useViewerStore((state) => ({
return useViewerStore(useShallow((state) => ({
setLayout: state.setLayout,
addPane: state.addPane,
removePane: state.removePane,
@@ -976,5 +981,5 @@ export const useViewerActions = () => {
setFocusedPane: state.setFocusedPane,
initializeDefaultLayout: state.initializeDefaultLayout,
reset: state.reset,
}));
})));
};

View File

@@ -105,6 +105,21 @@ export type {
TemplateExportRequest,
} from './execution';
// ========== Orchestrator Types ==========
export type {
SessionStrategy,
ErrorHandlingStrategy,
ErrorHandling,
OrchestrationStatus,
StepStatus,
ExecutionType,
OrchestrationMetadata,
OrchestrationSource,
OrchestrationStep,
OrchestrationPlan,
ManualOrchestrationParams,
} from './orchestrator';
// ========== Tool Call Types ==========
export type {
ToolCallStatus,

View File

@@ -0,0 +1,238 @@
import { CliTool, ExecutionMode } from './flow';
// ========================================
// Orchestrator Specific Types
// ========================================
/**
* Strategy for session management during step execution.
* - 'reuse_default': Use the default session specified in the plan or orchestrator settings.
* - 'new_session': Create a new session for this step.
* - 'specific_session': Use a specific session identified by `targetSessionKey`.
*/
export type SessionStrategy = 'reuse_default' | 'new_session' | 'specific_session';
/**
* Strategy for handling errors within a step or plan.
* - 'pause_on_error': Pause the orchestration and wait for user intervention.
* - 'skip': Skip the failing step and proceed with the next.
* - 'stop': Stop the entire orchestration.
*/
export type ErrorHandlingStrategy = 'pause_on_error' | 'skip' | 'stop';
/**
* Defines error handling configuration.
*/
export interface ErrorHandling {
strategy: ErrorHandlingStrategy;
maxRetries: number;
retryDelayMs: number;
}
/**
* Overall status of an orchestration plan.
*/
export type OrchestrationStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
/**
* Status of an individual orchestration step.
*/
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'paused' | 'cancelled';
/**
* Defines the type of execution for an orchestration step.
* - 'frontend-cli': Execute a command directly in the frontend CLI (e.g., via a pseudo-terminal).
* - 'backend-flow': Execute a sub-flow defined on the backend.
* - 'slash-command': Execute a predefined slash command (e.g., /workflow:plan).
*/
export type ExecutionType = 'frontend-cli' | 'backend-flow' | 'slash-command';
/**
* Metadata about the orchestration plan for display and analysis.
*/
export interface OrchestrationMetadata {
totalSteps: number;
hasParallelGroups: boolean;
estimatedComplexity: 'low' | 'medium' | 'high';
// Add any other relevant metadata here
}
/**
* Source from which the orchestration plan was created.
*/
export type OrchestrationSource = 'flow' | 'queue' | 'manual';
/**
* Represents a single executable step within an orchestration plan.
* This is a generalized step that can originate from a flow node, a queue item, or manual input.
*/
export interface OrchestrationStep {
/**
* Unique identifier for the step.
* For flow-based plans, this might correspond to the node ID.
* For queue-based, it could be item_id or a generated ID.
*/
id: string;
/**
* Display name for the step.
*/
name: string;
/**
* The core instruction for the step.
* This could be a prompt for a CLI tool, a slash command string, etc.
*/
instruction: string;
/**
* Optional CLI tool to use for execution, if applicable.
*/
tool?: CliTool;
/**
* Optional execution mode (e.g., 'analysis', 'write'), if applicable.
*/
mode?: ExecutionMode;
/**
* Session management strategy for this specific step.
* Overrides the plan's `defaultSessionStrategy` if provided.
*/
sessionStrategy?: SessionStrategy;
/**
* When `sessionStrategy` is 'specific_session', this key identifies the target session.
*/
targetSessionKey?: string;
/**
* A logical key for resuming or chaining related executions.
*/
resumeKey?: string;
/**
* An array of step IDs that this step depends on.
* This forms the DAG for execution ordering.
*/
dependsOn: string[];
/**
* An optional condition (e.g., a JavaScript expression) that must evaluate to true for the step to execute.
*/
condition?: string;
/**
* References to outputs from previous steps, used for context injection.
* E.g., `["analysisResult", "fileContent"]`
*/
contextRefs?: string[];
/**
* The name under which this step's output should be stored,
* allowing subsequent steps to reference it via `contextRefs`.
*/
outputName?: string;
/**
* Error handling configuration for this specific step.
* Overrides the plan's `defaultErrorHandling` if provided.
*/
errorHandling?: ErrorHandling;
/**
* The underlying type of execution this step represents.
*/
executionType: ExecutionType;
/**
* For flow-based plans, the ID of the source FlowNode.
*/
sourceNodeId?: string;
/**
* For queue-based plans, the ID of the source QueueItem.
*/
sourceItemId?: string;
}
/**
* Represents a complete, executable orchestration plan.
* This plan is a directed acyclic graph (DAG) of `OrchestrationStep`s.
*/
export interface OrchestrationPlan {
/**
* Unique identifier for the plan.
*/
id: string;
/**
* Display name for the plan.
*/
name: string;
/**
* The source from which this plan was generated.
*/
source: OrchestrationSource;
/**
* Optional ID of the source artifact (e.g., Flow ID, Queue ID).
*/
sourceId?: string;
/**
* The ordered list of steps to be executed.
* The actual execution order will be derived from `dependsOn` relationships,
* but this array provides a stable definition of all steps.
*/
steps: OrchestrationStep[];
/**
* Global variables that can be used within the plan (e.g., for instruction interpolation).
*/
variables: Record<string, unknown>;
/**
* Default session strategy for steps in this plan if not overridden at the step level.
*/
defaultSessionStrategy: SessionStrategy;
/**
* Default error handling for steps in this plan if not overridden at the step level.
*/
defaultErrorHandling: ErrorHandling;
/**
* Status of the overall plan.
*/
status: OrchestrationStatus;
/**
* Timestamp when the plan was created.
*/
createdAt: string;
/**
* Timestamp when the plan was last updated.
*/
updatedAt: string;
/**
* Analytical metadata about the plan.
*/
metadata: OrchestrationMetadata;
}
/**
* Defines the parameters for manually creating an orchestration plan.
*/
export interface ManualOrchestrationParams {
prompt: string;
tool?: CliTool;
mode?: ExecutionMode;
sessionStrategy?: SessionStrategy;
targetSessionKey?: string;
outputName?: string;
errorHandling?: ErrorHandling;
}

View File

@@ -35,12 +35,24 @@ export interface TeamMember {
messageCount: number;
}
export type TeamStatus = 'active' | 'completed' | 'archived';
export interface TeamSummary {
name: string;
messageCount: number;
lastActivity: string;
}
export interface TeamSummaryExtended extends TeamSummary {
status: TeamStatus;
created_at: string;
updated_at: string;
archived_at?: string;
pipeline_mode?: string;
memberCount: number;
members?: string[];
}
export interface TeamMessagesResponse {
total: number;
showing: number;
@@ -53,7 +65,7 @@ export interface TeamStatusResponse {
}
export interface TeamsListResponse {
teams: TeamSummary[];
teams: TeamSummaryExtended[];
}
export interface TeamMessageFilter {

View File

@@ -20,6 +20,7 @@ interface TaskFlowControl {
step: string;
action: string;
}>;
target_files?: Array<{ path: string }>;
}
interface NormalizedTask {
@@ -777,18 +778,28 @@ function normalizeTask(task: unknown): NormalizedTask | null {
acceptance: (context.acceptance as string[]) || [],
depends_on: (context.depends_on as string[]) || []
} : {
requirements: (taskObj.requirements as string[]) || (taskObj.description ? [taskObj.description as string] : []),
focus_paths: (taskObj.focus_paths as string[]) || modificationPoints?.map(m => m.file).filter((f): f is string => !!f) || [],
requirements: (taskObj.requirements as string[])
|| (taskObj.details as string[])
|| (taskObj.description ? [taskObj.description as string] : taskObj.scope ? [taskObj.scope as string] : []),
focus_paths: (taskObj.focus_paths as string[])
|| (Array.isArray(taskObj.files) && taskObj.files.length > 0 && typeof taskObj.files[0] === 'string'
? taskObj.files as string[] : undefined)
|| modificationPoints?.map(m => m.file).filter((f): f is string => !!f)
|| [],
acceptance: (taskObj.acceptance as string[]) || [],
depends_on: (taskObj.depends_on as string[]) || []
},
flow_control: flowControl ? {
implementation_approach: (flowControl.implementation_approach as Array<{ step: string; action: string }>) || []
implementation_approach: (flowControl.implementation_approach as Array<{ step: string; action: string }>) || [],
target_files: (flowControl.target_files as Array<{ path: string }>) || undefined
} : {
implementation_approach: implementation?.map((step, i) => ({
step: `Step ${i + 1}`,
action: step as string
})) || []
})) || [],
target_files: Array.isArray(taskObj.files) && taskObj.files.length > 0 && typeof taskObj.files[0] === 'string'
? (taskObj.files as string[]).map(f => ({ path: f }))
: undefined
},
// Keep all original fields for raw JSON view
_raw: task

View File

@@ -1,61 +1,149 @@
/**
* Team Routes - REST API for team message visualization
* Team Routes - REST API for team message visualization & management
*
* Endpoints:
* - GET /api/teams - List all teams
* - GET /api/teams/:name/messages - Get messages (with filters)
* - GET /api/teams/:name/status - Get member status summary
* - GET /api/teams - List all teams (with ?location filter)
* - GET /api/teams/:name/messages - Get messages (with filters)
* - GET /api/teams/:name/status - Get member status summary
* - POST /api/teams/:name/archive - Archive a team
* - POST /api/teams/:name/unarchive - Unarchive a team
* - DELETE /api/teams/:name - Delete a team
*/
import { existsSync, readdirSync } from 'fs';
import { existsSync, readdirSync, rmSync } from 'fs';
import { join } from 'path';
import type { RouteContext } from './types.js';
import { readAllMessages, getLogDir } from '../../tools/team-msg.js';
import { readAllMessages, getLogDir, getEffectiveTeamMeta, readTeamMeta, writeTeamMeta } from '../../tools/team-msg.js';
import type { TeamMeta } from '../../tools/team-msg.js';
import { getProjectRoot } from '../../utils/path-validator.js';
function jsonResponse(res: import('http').ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res, url } = ctx;
const { pathname, req, res, url, handlePostRequest } = ctx;
if (!pathname.startsWith('/api/teams')) return false;
if (req.method !== 'GET') return false;
// GET /api/teams - List all teams
if (pathname === '/api/teams') {
// ====== GET /api/teams - List all teams ======
if (pathname === '/api/teams' && req.method === 'GET') {
try {
const root = getProjectRoot();
const teamMsgDir = join(root, '.workflow', '.team-msg');
if (!existsSync(teamMsgDir)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ teams: [] }));
jsonResponse(res, 200, { teams: [] });
return true;
}
const locationFilter = url.searchParams.get('location') || 'active';
const entries = readdirSync(teamMsgDir, { withFileTypes: true });
const teams = entries
.filter(e => e.isDirectory())
.map(e => {
const messages = readAllMessages(e.name);
const lastMsg = messages[messages.length - 1];
const meta = getEffectiveTeamMeta(e.name);
// Count unique members from messages
const memberSet = new Set<string>();
for (const msg of messages) {
memberSet.add(msg.from);
memberSet.add(msg.to);
}
return {
name: e.name,
messageCount: messages.length,
lastActivity: lastMsg?.ts || '',
status: meta.status,
created_at: meta.created_at,
updated_at: meta.updated_at,
archived_at: meta.archived_at,
pipeline_mode: meta.pipeline_mode,
memberCount: memberSet.size,
members: Array.from(memberSet),
};
})
.filter(t => {
if (locationFilter === 'all') return true;
if (locationFilter === 'archived') return t.status === 'archived';
// 'active' = everything that's not archived
return t.status !== 'archived';
})
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ teams }));
jsonResponse(res, 200, { teams });
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
jsonResponse(res, 500, { error: (error as Error).message });
return true;
}
}
// Match /api/teams/:name/messages or /api/teams/:name/status
// ====== POST /api/teams/:name/archive ======
const archiveMatch = pathname.match(/^\/api\/teams\/([^/]+)\/archive$/);
if (archiveMatch && req.method === 'POST') {
const teamName = decodeURIComponent(archiveMatch[1]);
handlePostRequest(req, res, async () => {
const dir = getLogDir(teamName);
if (!existsSync(dir)) {
throw new Error(`Team "${teamName}" not found`);
}
const meta = getEffectiveTeamMeta(teamName);
meta.status = 'archived';
meta.archived_at = new Date().toISOString();
meta.updated_at = new Date().toISOString();
writeTeamMeta(teamName, meta);
return { success: true, team: teamName, status: 'archived' };
});
return true;
}
// ====== POST /api/teams/:name/unarchive ======
const unarchiveMatch = pathname.match(/^\/api\/teams\/([^/]+)\/unarchive$/);
if (unarchiveMatch && req.method === 'POST') {
const teamName = decodeURIComponent(unarchiveMatch[1]);
handlePostRequest(req, res, async () => {
const dir = getLogDir(teamName);
if (!existsSync(dir)) {
throw new Error(`Team "${teamName}" not found`);
}
const meta = getEffectiveTeamMeta(teamName);
meta.status = 'active';
delete meta.archived_at;
meta.updated_at = new Date().toISOString();
writeTeamMeta(teamName, meta);
return { success: true, team: teamName, status: 'active' };
});
return true;
}
// ====== DELETE /api/teams/:name ======
const deleteMatch = pathname.match(/^\/api\/teams\/([^/]+)$/);
if (deleteMatch && req.method === 'DELETE') {
const teamName = decodeURIComponent(deleteMatch[1]);
try {
const dir = getLogDir(teamName);
if (!existsSync(dir)) {
jsonResponse(res, 404, { error: `Team "${teamName}" not found` });
return true;
}
rmSync(dir, { recursive: true, force: true });
jsonResponse(res, 200, { success: true, team: teamName, deleted: true });
return true;
} catch (error) {
jsonResponse(res, 500, { error: (error as Error).message });
return true;
}
}
// ====== GET /api/teams/:name/messages or /api/teams/:name/status ======
if (req.method !== 'GET') return false;
const match = pathname.match(/^\/api\/teams\/([^/]+)\/(messages|status)$/);
if (!match) return false;
@@ -81,12 +169,10 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
const total = messages.length;
const sliced = messages.slice(Math.max(0, total - last - offset), total - offset);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ total, showing: sliced.length, messages: sliced }));
jsonResponse(res, 200, { total, showing: sliced.length, messages: sliced });
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
jsonResponse(res, 500, { error: (error as Error).message });
return true;
}
}
@@ -112,12 +198,10 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
const members = Array.from(memberMap.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ members, total_messages: messages.length }));
jsonResponse(res, 200, { members, total_messages: messages.length });
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
jsonResponse(res, 500, { error: (error as Error).message });
return true;
}
}

View File

@@ -12,10 +12,76 @@
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, rmSync } from 'fs';
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, rmSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { getProjectRoot } from '../utils/path-validator.js';
// --- Team Metadata ---
export interface TeamMeta {
status: 'active' | 'completed' | 'archived';
created_at: string;
updated_at: string;
archived_at?: string;
pipeline_mode?: string;
}
export function getMetaPath(team: string): string {
return join(getLogDir(team), 'meta.json');
}
export function readTeamMeta(team: string): TeamMeta | null {
const metaPath = getMetaPath(team);
if (!existsSync(metaPath)) return null;
try {
return JSON.parse(readFileSync(metaPath, 'utf-8')) as TeamMeta;
} catch {
return null;
}
}
export function writeTeamMeta(team: string, meta: TeamMeta): void {
const dir = getLogDir(team);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(getMetaPath(team), JSON.stringify(meta, null, 2), 'utf-8');
}
/**
* Infer team status when no meta.json exists.
* If last message is 'shutdown' → 'completed', otherwise 'active'.
*/
export function inferTeamStatus(team: string): TeamMeta['status'] {
const messages = readAllMessages(team);
if (messages.length === 0) return 'active';
const lastMsg = messages[messages.length - 1];
return lastMsg.type === 'shutdown' ? 'completed' : 'active';
}
/**
* Get effective team meta: reads meta.json or infers from messages.
*/
export function getEffectiveTeamMeta(team: string): TeamMeta {
const meta = readTeamMeta(team);
if (meta) return meta;
// Infer from messages and directory stat
const status = inferTeamStatus(team);
const dir = getLogDir(team);
let created_at = new Date().toISOString();
try {
const stat = statSync(dir);
created_at = stat.birthtime.toISOString();
} catch { /* use now as fallback */ }
const messages = readAllMessages(team);
const lastMsg = messages[messages.length - 1];
const updated_at = lastMsg?.ts || created_at;
return { status, created_at, updated_at };
}
// --- Types ---
export interface TeamMessage {