+ {/* Legend - pinned to bottom */}
+
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => {
const cfg = statusConfig[s];
const Icon = cfg.icon;
diff --git a/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx b/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
index a5263806..054ef7d7 100644
--- a/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
+++ b/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
@@ -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('');
const flushTimerRef = useRef(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) => {
- 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) {
+ {/* Toolbar */}
+ {panelView === 'terminal' && (
+
+ {/* New CLI session buttons */}
+ {CLI_TOOLS.map((tool) => (
+
+ ))}
+
+
+
+ {/* Terminal actions */}
+ {activeTerminalId && (
+ <>
+
+
+ >
+ )}
+
+ )}
+
{/* Content */}
{panelView === 'queue' ? (
- /* Queue View - Placeholder */
-
-
-
-
{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}
-
{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}
-
-
+ /* Queue View */
+
) : activeTerminalId ? (
/* Terminal View */
-
- {/* xterm container */}
-
-
- {/* Command Input */}
-
+
) : (
- /* Empty State */
+ /* Empty State - with quick launch */
{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}
-
{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}
+
{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}
+ {projectPath && (
+
+ {CLI_TOOLS.map((tool) => (
+
+ ))}
+
+ )}
)}
diff --git a/ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx b/ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx
index 327b3e45..3d9b2a33 100644
--- a/ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx
+++ b/ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx
@@ -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() {
);
})}
+
+ {/* New Terminal Button - Fixed at bottom */}
+
+
+
+
+ {/* Tool Selection Popup */}
+ {showToolMenu && (
+ <>
+
setShowToolMenu(false)} />
+
+ {CLI_TOOLS.map((tool) => (
+
+ ))}
+
+ >
+ )}
+
);
}
diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts
index 0031292e..afbed68d 100644
--- a/ccw/frontend/src/hooks/index.ts
+++ b/ccw/frontend/src/hooks/index.ts
@@ -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,
diff --git a/ccw/frontend/src/hooks/useActiveCliExecutions.ts b/ccw/frontend/src/hooks/useActiveCliExecutions.ts
index efe78973..bcdffe94 100644
--- a/ccw/frontend/src/hooks/useActiveCliExecutions.ts
+++ b/ccw/frontend/src/hooks/useActiveCliExecutions.ts
@@ -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);
}
}
diff --git a/ccw/frontend/src/hooks/useCliSessionCore.ts b/ccw/frontend/src/hooks/useCliSessionCore.ts
index 50fcd72a..b5c68db6 100644
--- a/ccw/frontend/src/hooks/useCliSessionCore.ts
+++ b/ccw/frontend/src/hooks/useCliSessionCore.ts
@@ -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;
diff --git a/ccw/frontend/src/hooks/useCommands.ts b/ccw/frontend/src/hooks/useCommands.ts
index ad14edd6..564fb0fe 100644
--- a/ccw/frontend/src/hooks/useCommands.ts
+++ b/ccw/frontend/src/hooks/useCommands.ts
@@ -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 {
diff --git a/ccw/frontend/src/hooks/useCompletionCallbackChain.ts b/ccw/frontend/src/hooks/useCompletionCallbackChain.ts
new file mode 100644
index 00000000..408398ac
--- /dev/null
+++ b/ccw/frontend/src/hooks/useCompletionCallbackChain.ts
@@ -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
,
+ 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,
+ 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,
+ 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,
+ 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,
+ 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;
diff --git a/ccw/frontend/src/hooks/useHistory.ts b/ccw/frontend/src/hooks/useHistory.ts
index 4d3670b0..21fc27fc 100644
--- a/ccw/frontend/src/hooks/useHistory.ts
+++ b/ccw/frontend/src/hooks/useHistory.ts
@@ -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) });
},
});
diff --git a/ccw/frontend/src/hooks/useIndex.ts b/ccw/frontend/src/hooks/useIndex.ts
index 462798af..65fd415c 100644
--- a/ccw/frontend/src/hooks/useIndex.ts
+++ b/ccw/frontend/src/hooks/useIndex.ts
@@ -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);
},
});
diff --git a/ccw/frontend/src/hooks/useLoops.ts b/ccw/frontend/src/hooks/useLoops.ts
index a9874727..e9ae3b63 100644
--- a/ccw/frontend/src/hooks/useLoops.ts
+++ b/ccw/frontend/src/hooks/useLoops.ts
@@ -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(loopsKeys.list(), (old) => {
+ queryClient.setQueryData(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(loopsKeys.list(), (old) => {
+ queryClient.setQueryData(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(loopsKeys.list());
+ await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
+ const previousLoops = queryClient.getQueryData(workspaceQueryKeys.loopsList(projectPath));
- queryClient.setQueryData(loopsKeys.list(), (old) => {
+ queryClient.setQueryData(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) });
},
});
diff --git a/ccw/frontend/src/hooks/usePromptHistory.ts b/ccw/frontend/src/hooks/usePromptHistory.ts
index e2155435..3717b705 100644
--- a/ccw/frontend/src/hooks/usePromptHistory.ts
+++ b/ccw/frontend/src/hooks/usePromptHistory.ts
@@ -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(promptHistoryKeys.list());
+ await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
+ const previousPrompts = queryClient.getQueryData(workspaceQueryKeys.promptsList(projectPath));
- queryClient.setQueryData(promptHistoryKeys.list(), (old) => {
+ queryClient.setQueryData(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(promptHistoryKeys.list());
+ await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
+ const previousPrompts = queryClient.getQueryData(workspaceQueryKeys.promptsList(projectPath));
- queryClient.setQueryData(promptHistoryKeys.list(), (old) => {
+ queryClient.setQueryData(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(promptHistoryKeys.insightsHistory());
+ await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath) });
+ const previousInsights = queryClient.getQueryData(workspaceQueryKeys.promptsInsightsHistory(projectPath));
- queryClient.setQueryData(promptHistoryKeys.insightsHistory(), (old) => {
+ queryClient.setQueryData(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) });
},
});
diff --git a/ccw/frontend/src/hooks/useTeamData.ts b/ccw/frontend/src/hooks/useTeamData.ts
index c1c17b7c..3bd55dae 100644
--- a/ccw/frontend/src/hooks/useTeamData.ts
+++ b/ccw/frontend/src/hooks/useTeamData.ts
@@ -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 => {
- 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,
+ };
+}
diff --git a/ccw/frontend/src/hooks/useWebSocket.ts b/ccw/frontend/src/hooks/useWebSocket.ts
index e129b51e..18dd5a59 100644
--- a/ccw/frontend/src/hooks/useWebSocket.ts
+++ b/ccw/frontend/src/hooks/useWebSocket.ts
@@ -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) {
diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts
index 533d7c16..4bafd1a6 100644
--- a/ccw/frontend/src/lib/api.ts
+++ b/ccw/frontend/src/lib/api.ts
@@ -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): 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): 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 {
// ========== 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 {
+ return fetchApi(`/api/teams/${encodeURIComponent(teamName)}`, { method: 'DELETE' });
}
export async function fetchTeamMessages(
diff --git a/ccw/frontend/src/lib/queryKeys.ts b/ccw/frontend/src/lib/queryKeys.ts
index 23cefd83..228c09f9 100644
--- a/ccw/frontend/src/lib/queryKeys.ts
+++ b/ccw/frontend/src/lib/queryKeys.ts
@@ -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,
diff --git a/ccw/frontend/src/lib/unifiedExecutionDispatcher.ts b/ccw/frontend/src/lib/unifiedExecutionDispatcher.ts
new file mode 100644
index 00000000..ccf57730
--- /dev/null
+++ b/ccw/frontend/src/lib/unifiedExecutionDispatcher.ts
@@ -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 {
+ 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 {
+ 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';
+ }
+}
diff --git a/ccw/frontend/src/locales/en/home.json b/ccw/frontend/src/locales/en/home.json
index 4d8fe3ba..9b0d77d2 100644
--- a/ccw/frontend/src/locales/en/home.json
+++ b/ccw/frontend/src/locales/en/home.json
@@ -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"
}
}
}
diff --git a/ccw/frontend/src/locales/en/issues.json b/ccw/frontend/src/locales/en/issues.json
index ff00986d..4946f5af 100644
--- a/ccw/frontend/src/locales/en/issues.json
+++ b/ccw/frontend/src/locales/en/issues.json
@@ -114,6 +114,7 @@
}
},
"terminal": {
+ "launch": "Launch Session",
"session": {
"select": "Select session",
"none": "No sessions",
diff --git a/ccw/frontend/src/locales/en/orchestrator.json b/ccw/frontend/src/locales/en/orchestrator.json
index 05ac336a..c7337829 100644
--- a/ccw/frontend/src/locales/en/orchestrator.json
+++ b/ccw/frontend/src/locales/en/orchestrator.json
@@ -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",
diff --git a/ccw/frontend/src/locales/en/team.json b/ccw/frontend/src/locales/en/team.json
index faa5f7d9..c53b84ab 100644
--- a/ccw/frontend/src/locales/en/team.json
+++ b/ccw/frontend/src/locales/en/team.json
@@ -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",
diff --git a/ccw/frontend/src/locales/zh/home.json b/ccw/frontend/src/locales/zh/home.json
index 50680585..7caee96d 100644
--- a/ccw/frontend/src/locales/zh/home.json
+++ b/ccw/frontend/src/locales/zh/home.json
@@ -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": "从问题队列发起执行后将在此显示"
}
}
}
diff --git a/ccw/frontend/src/locales/zh/issues.json b/ccw/frontend/src/locales/zh/issues.json
index 43f18af8..a7285c38 100644
--- a/ccw/frontend/src/locales/zh/issues.json
+++ b/ccw/frontend/src/locales/zh/issues.json
@@ -114,6 +114,7 @@
}
},
"terminal": {
+ "launch": "启动会话",
"session": {
"select": "选择会话",
"none": "暂无会话",
diff --git a/ccw/frontend/src/locales/zh/orchestrator.json b/ccw/frontend/src/locales/zh/orchestrator.json
index ea0416d1..36218f95 100644
--- a/ccw/frontend/src/locales/zh/orchestrator.json
+++ b/ccw/frontend/src/locales/zh/orchestrator.json
@@ -29,6 +29,15 @@
"completed": "已完成",
"failed": "失败"
},
+ "controlPanel": {
+ "progress": "{completed}/{total} 步",
+ "noPlan": "未找到编排计划",
+ "completedMessage": "编排已成功完成",
+ "failedMessage": "编排已停止",
+ "cancelled": "已取消",
+ "retry": "重试",
+ "skip": "跳过"
+ },
"node": {
"title": "节点",
"nodes": "节点列表",
diff --git a/ccw/frontend/src/locales/zh/team.json b/ccw/frontend/src/locales/zh/team.json
index e4dd712f..04237734 100644
--- a/ccw/frontend/src/locales/zh/team.json
+++ b/ccw/frontend/src/locales/zh/team.json
@@ -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 创建团队以开始协作",
diff --git a/ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts b/ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts
new file mode 100644
index 00000000..8a755b3c
--- /dev/null
+++ b/ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts
@@ -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(flow.nodes.map((node) => [node.id, node]));
+ const adjacencyList = new Map(); // node.id -> list of dependent node.ids
+ const inDegree = new Map(); // 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): OrchestrationPlan {
+ const steps: OrchestrationStep[] = [];
+ const groupIdToSteps = new Map(); // Maps group ID to list of step IDs in that group
+ const allStepIds = new Set();
+
+ 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>();
+ 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';
+ }
+}
diff --git a/ccw/frontend/src/orchestrator/SequentialRunner.ts b/ccw/frontend/src/orchestrator/SequentialRunner.ts
new file mode 100644
index 00000000..659ea523
--- /dev/null
+++ b/ccw/frontend/src/orchestrator/SequentialRunner.ts
@@ -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;
+ /** Options passed at start() for reuse during step dispatches */
+ options: StartOptions;
+}
+
+// ========== Module State ==========
+
+/** Active subscriptions keyed by plan ID */
+const activeSubscriptions = new Map();
+
+// ========== 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 {
+ 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 {
+ 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 {
+ 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,
+ stepOutputs: Record
+): 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();
+
+ // Track previous step statuses to detect transitions
+ let previousStatuses: Record | 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 {
+ const outputs: Record = {};
+
+ 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 {
+ 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
+): 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)[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;
+ }
+}
diff --git a/ccw/frontend/src/orchestrator/__tests__/OrchestrationPlanBuilder.test.ts b/ccw/frontend/src/orchestrator/__tests__/OrchestrationPlanBuilder.test.ts
new file mode 100644
index 00000000..2b11b377
--- /dev/null
+++ b/ccw/frontend/src/orchestrator/__tests__/OrchestrationPlanBuilder.test.ts
@@ -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();
+ 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();
+ 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');
+ });
+ });
+});
diff --git a/ccw/frontend/src/orchestrator/index.ts b/ccw/frontend/src/orchestrator/index.ts
new file mode 100644
index 00000000..122c3863
--- /dev/null
+++ b/ccw/frontend/src/orchestrator/index.ts
@@ -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';
diff --git a/ccw/frontend/src/pages/ReviewSessionPage.tsx b/ccw/frontend/src/pages/ReviewSessionPage.tsx
index f211b767..93dcf6f7 100644
--- a/ccw/frontend/src/pages/ReviewSessionPage.tsx
+++ b/ccw/frontend/src/pages/ReviewSessionPage.tsx
@@ -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 | 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) => {
diff --git a/ccw/frontend/src/pages/TeamPage.tsx b/ccw/frontend/src/pages/TeamPage.tsx
index 8378d089..e23f2012 100644
--- a/ccw/frontend/src/pages/TeamPage.tsx
+++ b/ccw/frontend/src/pages/TeamPage.tsx
@@ -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 (
-
-
-
{formatMessage({ id: 'team.title' })}
-
-
+
);
}
+ const tabs: TabItem[] = [
+ {
+ value: 'artifacts',
+ label: formatMessage({ id: 'team.tabs.artifacts' }),
+ icon: ,
+ },
+ {
+ value: 'messages',
+ label: formatMessage({ id: 'team.tabs.messages' }),
+ icon: ,
+ },
+ ];
+
+ // Detail view
return (
- {/* Page title */}
-
-
-
{formatMessage({ id: 'team.title' })}
-
-
- {/* Team Header: selector + stats + controls */}
+ {/* Detail Header: back button + team name + stats + controls */}
- {selectedTeam ? (
- <>
- {/* Main content grid: Pipeline (left) + Members (right) */}
-
- {/* Pipeline visualization */}
-
-
-
-
-
+ {/* Overview: Pipeline + Members (always visible) */}
+
+
+
+
+
+
+
+
+
+
+
+
- {/* Members panel */}
-
-
-
-
-
-
+ {/* Tab Navigation: Artifacts / Messages */}
+
setDetailTab(v as TeamDetailTab)}
+ tabs={tabs}
+ />
- {/* Message timeline */}
-
- >
- ) : (
-
- {formatMessage({ id: 'team.noTeamSelected' })}
-
+ {/* Artifacts Tab */}
+ {detailTab === 'artifacts' && (
+
+ )}
+
+ {/* Messages Tab */}
+ {detailTab === 'messages' && (
+
)}
);
diff --git a/ccw/frontend/src/stores/cliStreamStore.ts b/ccw/frontend/src/stores/cliStreamStore.ts
index 512c2abe..fafca490 100644
--- a/ccw/frontend/src/stores/cliStreamStore.ts
+++ b/ccw/frontend/src/stores/cliStreamStore.ts
@@ -356,15 +356,21 @@ export const useCliStreamStore = create()(
// 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()(
// ========== 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
diff --git a/ccw/frontend/src/stores/executionStore.ts b/ccw/frontend/src/stores/executionStore.ts
index 9897acf5..011db286 100644
--- a/ccw/frontend/src/stores/executionStore.ts
+++ b/ccw/frontend/src/stores/executionStore.ts
@@ -464,6 +464,7 @@ export const useExecutionStore = create()(
);
// 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;
};
diff --git a/ccw/frontend/src/stores/index.ts b/ccw/frontend/src/stores/index.ts
index 4d132fff..90362f7f 100644
--- a/ccw/frontend/src/stores/index.ts
+++ b/ccw/frontend/src/stores/index.ts
@@ -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
diff --git a/ccw/frontend/src/stores/orchestratorStore.ts b/ccw/frontend/src/stores/orchestratorStore.ts
new file mode 100644
index 00000000..b71ec716
--- /dev/null
+++ b/ccw/frontend/src/stores/orchestratorStore.ts
@@ -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;
+ /** Maps executionId -> stepId for callback chain matching */
+ executionIdMap: Record;
+ /** 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;
+}
+
+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
+): 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 | 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): 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()(
+ devtools(
+ (set, get) => ({
+ ...initialState,
+
+ // ========== Plan Lifecycle ==========
+
+ startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => {
+ // Initialize all step statuses as pending
+ const stepStatuses: Record = {};
+ 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 | 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;
+ };
diff --git a/ccw/frontend/src/stores/queueExecutionStore.ts b/ccw/frontend/src/stores/queueExecutionStore.ts
index aea86a16..4166dd3f 100644
--- a/ccw/frontend/src/stores/queueExecutionStore.ts
+++ b/ccw/frontend/src/stores/queueExecutionStore.ts
@@ -157,21 +157,33 @@ export const useQueueExecutionStore = create()(
// ========== 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 */
diff --git a/ccw/frontend/src/stores/teamStore.ts b/ccw/frontend/src/stores/teamStore.ts
index 11c82dcb..a871e09e 100644
--- a/ccw/frontend/src/stores/teamStore.ts
+++ b/ccw/frontend/src/stores/teamStore.ts
@@ -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) => 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()(
@@ -27,12 +39,22 @@ export const useTeamStore = create()(
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' }
),
diff --git a/ccw/frontend/src/stores/terminalPanelStore.ts b/ccw/frontend/src/stores/terminalPanelStore.ts
index 2a861cea..c128096b 100644
--- a/ccw/frontend/src/stores/terminalPanelStore.ts
+++ b/ccw/frontend/src/stores/terminalPanelStore.ts
@@ -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()(
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) => {
diff --git a/ccw/frontend/src/stores/viewerStore.ts b/ccw/frontend/src/stores/viewerStore.ts
index 6504b12d..87ce86cd 100644
--- a/ccw/frontend/src/stores/viewerStore.ts
+++ b/ccw/frontend/src/stores/viewerStore.ts
@@ -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()(
// ========== 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,
- }));
+ })));
};
diff --git a/ccw/frontend/src/types/index.ts b/ccw/frontend/src/types/index.ts
index 75afc4ec..fcbca192 100644
--- a/ccw/frontend/src/types/index.ts
+++ b/ccw/frontend/src/types/index.ts
@@ -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,
diff --git a/ccw/frontend/src/types/orchestrator.ts b/ccw/frontend/src/types/orchestrator.ts
new file mode 100644
index 00000000..d603e11a
--- /dev/null
+++ b/ccw/frontend/src/types/orchestrator.ts
@@ -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;
+
+ /**
+ * 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;
+}
diff --git a/ccw/frontend/src/types/team.ts b/ccw/frontend/src/types/team.ts
index 6f3a17af..215508d1 100644
--- a/ccw/frontend/src/types/team.ts
+++ b/ccw/frontend/src/types/team.ts
@@ -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 {
diff --git a/ccw/src/core/lite-scanner.ts b/ccw/src/core/lite-scanner.ts
index dbd3c93e..79788934 100644
--- a/ccw/src/core/lite-scanner.ts
+++ b/ccw/src/core/lite-scanner.ts
@@ -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
diff --git a/ccw/src/core/routes/team-routes.ts b/ccw/src/core/routes/team-routes.ts
index bdc99260..26ac4847 100644
--- a/ccw/src/core/routes/team-routes.ts
+++ b/ccw/src/core/routes/team-routes.ts
@@ -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 {
- 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();
+ 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 {
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 {
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;
}
}
diff --git a/ccw/src/tools/team-msg.ts b/ccw/src/tools/team-msg.ts
index 61a7e468..0fec853b 100644
--- a/ccw/src/tools/team-msg.ts
+++ b/ccw/src/tools/team-msg.ts
@@ -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 {