feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution

This commit is contained in:
catlog22
2026-01-30 16:59:18 +08:00
parent 0a7c1454d9
commit a5c3dff8d3
92 changed files with 23875 additions and 542 deletions

View File

@@ -0,0 +1,164 @@
// ========================================
// App Store
// ========================================
// Manages UI state: theme, sidebar, view, loading, error
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import type { AppStore, Theme, ViewMode, SessionFilter, LiteTaskType } from '../types/store';
// Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
// Helper to resolve theme based on preference
const resolveTheme = (theme: Theme): 'light' | 'dark' => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
};
// Initial state
const initialState = {
// Theme
theme: 'system' as Theme,
resolvedTheme: 'light' as 'light' | 'dark',
// Sidebar
sidebarOpen: true,
sidebarCollapsed: false,
// View state
currentView: 'sessions' as ViewMode,
currentFilter: 'all' as SessionFilter,
currentLiteType: null as LiteTaskType,
currentSessionDetailKey: null as string | null,
// Loading and error states
isLoading: false,
loadingMessage: null as string | null,
error: null as string | null,
};
export const useAppStore = create<AppStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
// ========== Theme Actions ==========
setTheme: (theme: Theme) => {
const resolved = resolveTheme(theme);
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme to document
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
}
},
toggleTheme: () => {
const { theme } = get();
const newTheme: Theme = theme === 'dark' ? 'light' : theme === 'light' ? 'dark' : 'dark';
get().setTheme(newTheme);
},
// ========== Sidebar Actions ==========
setSidebarOpen: (open: boolean) => {
set({ sidebarOpen: open }, false, 'setSidebarOpen');
},
toggleSidebar: () => {
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar');
},
setSidebarCollapsed: (collapsed: boolean) => {
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
},
// ========== View Actions ==========
setCurrentView: (view: ViewMode) => {
set({ currentView: view }, false, 'setCurrentView');
},
setCurrentFilter: (filter: SessionFilter) => {
set({ currentFilter: filter }, false, 'setCurrentFilter');
},
setCurrentLiteType: (type: LiteTaskType) => {
set({ currentLiteType: type }, false, 'setCurrentLiteType');
},
setCurrentSessionDetailKey: (key: string | null) => {
set({ currentSessionDetailKey: key }, false, 'setCurrentSessionDetailKey');
},
// ========== Loading/Error Actions ==========
setLoading: (loading: boolean, message: string | null = null) => {
set({ isLoading: loading, loadingMessage: message }, false, 'setLoading');
},
setError: (error: string | null) => {
set({ error }, false, 'setError');
},
clearError: () => {
set({ error: null }, false, 'clearError');
},
}),
{
name: 'ccw-app-store',
// Only persist theme preference
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => (state) => {
// Apply theme on rehydration
if (state) {
const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved;
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
}
}
},
}
),
{ name: 'AppStore' }
)
);
// Setup system theme listener
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
const state = useAppStore.getState();
if (state.theme === 'system') {
const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved });
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
}
});
}
// Selectors for common access patterns
export const selectTheme = (state: AppStore) => state.theme;
export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme;
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
export const selectCurrentView = (state: AppStore) => state.currentView;
export const selectIsLoading = (state: AppStore) => state.isLoading;
export const selectError = (state: AppStore) => state.error;

View File

@@ -0,0 +1,223 @@
// ========================================
// Config Store
// ========================================
// Manages CLI tools, API endpoints, and user preferences with persistence
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import type {
ConfigStore,
ConfigState,
CliToolConfig,
ApiEndpoints,
UserPreferences,
} from '../types/store';
// Default CLI tools configuration
const defaultCliTools: Record<string, CliToolConfig> = {
gemini: {
enabled: true,
primaryModel: 'gemini-2.5-pro',
secondaryModel: 'gemini-2.5-flash',
tags: ['analysis', 'debug'],
type: 'builtin',
},
qwen: {
enabled: true,
primaryModel: 'coder-model',
secondaryModel: 'coder-model',
tags: [],
type: 'builtin',
},
codex: {
enabled: true,
primaryModel: 'gpt-5.2',
secondaryModel: 'gpt-5.2',
tags: [],
type: 'builtin',
},
claude: {
enabled: true,
primaryModel: 'sonnet',
secondaryModel: 'haiku',
tags: [],
type: 'builtin',
},
};
// Default API endpoints
const defaultApiEndpoints: ApiEndpoints = {
base: '/api',
sessions: '/api/sessions',
tasks: '/api/tasks',
loops: '/api/loops',
issues: '/api/issues',
orchestrator: '/api/orchestrator',
};
// Default user preferences
const defaultUserPreferences: UserPreferences = {
autoRefresh: true,
refreshInterval: 30000, // 30 seconds
notificationsEnabled: true,
soundEnabled: false,
compactView: false,
showCompletedTasks: true,
defaultSessionFilter: 'all',
defaultSortField: 'created_at',
defaultSortDirection: 'desc',
};
// Initial state
const initialState: ConfigState = {
cliTools: defaultCliTools,
defaultCliTool: 'gemini',
apiEndpoints: defaultApiEndpoints,
userPreferences: defaultUserPreferences,
featureFlags: {
orchestratorEnabled: true,
darkModeEnabled: true,
notificationsEnabled: true,
experimentalFeatures: false,
},
};
export const useConfigStore = create<ConfigStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
// ========== CLI Tools Actions ==========
setCliTools: (tools: Record<string, CliToolConfig>) => {
set({ cliTools: tools }, false, 'setCliTools');
},
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => {
set(
(state) => ({
cliTools: {
...state.cliTools,
[toolId]: {
...state.cliTools[toolId],
...updates,
},
},
}),
false,
'updateCliTool'
);
},
setDefaultCliTool: (toolId: string) => {
const { cliTools } = get();
if (cliTools[toolId]?.enabled) {
set({ defaultCliTool: toolId }, false, 'setDefaultCliTool');
}
},
// ========== API Endpoints Actions ==========
setApiEndpoints: (endpoints: Partial<ApiEndpoints>) => {
set(
(state) => ({
apiEndpoints: {
...state.apiEndpoints,
...endpoints,
},
}),
false,
'setApiEndpoints'
);
},
// ========== User Preferences Actions ==========
setUserPreferences: (prefs: Partial<UserPreferences>) => {
set(
(state) => ({
userPreferences: {
...state.userPreferences,
...prefs,
},
}),
false,
'setUserPreferences'
);
},
resetUserPreferences: () => {
set({ userPreferences: defaultUserPreferences }, false, 'resetUserPreferences');
},
// ========== Feature Flags Actions ==========
setFeatureFlag: (flag: string, enabled: boolean) => {
set(
(state) => ({
featureFlags: {
...state.featureFlags,
[flag]: enabled,
},
}),
false,
'setFeatureFlag'
);
},
// ========== Bulk Config Actions ==========
loadConfig: (config: Partial<ConfigState>) => {
set(
(state) => ({
...state,
...config,
// Deep merge nested objects
cliTools: config.cliTools || state.cliTools,
apiEndpoints: {
...state.apiEndpoints,
...(config.apiEndpoints || {}),
},
userPreferences: {
...state.userPreferences,
...(config.userPreferences || {}),
},
featureFlags: {
...state.featureFlags,
...(config.featureFlags || {}),
},
}),
false,
'loadConfig'
);
},
}),
{
name: 'ccw-config-store',
// Persist all config state
partialize: (state) => ({
cliTools: state.cliTools,
defaultCliTool: state.defaultCliTool,
userPreferences: state.userPreferences,
featureFlags: state.featureFlags,
}),
}
),
{ name: 'ConfigStore' }
)
);
// Selectors for common access patterns
export const selectCliTools = (state: ConfigStore) => state.cliTools;
export const selectDefaultCliTool = (state: ConfigStore) => state.defaultCliTool;
export const selectApiEndpoints = (state: ConfigStore) => state.apiEndpoints;
export const selectUserPreferences = (state: ConfigStore) => state.userPreferences;
export const selectFeatureFlags = (state: ConfigStore) => state.featureFlags;
// Helper to get first enabled CLI tool
export const getFirstEnabledCliTool = (cliTools: Record<string, CliToolConfig>): string => {
const entries = Object.entries(cliTools);
const enabled = entries.find(([, config]) => config.enabled);
return enabled ? enabled[0] : 'gemini';
};

View File

@@ -0,0 +1,227 @@
// ========================================
// Execution Store
// ========================================
// Zustand store for Orchestrator execution state management
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
ExecutionStore,
ExecutionState,
ExecutionStatus,
NodeExecutionState,
ExecutionLog,
} from '../types/execution';
// Constants
const MAX_LOGS = 500;
// Initial state
const initialState = {
// Current execution
currentExecution: null as ExecutionState | null,
// Node execution states
nodeStates: {} as Record<string, NodeExecutionState>,
// Execution logs
logs: [] as ExecutionLog[],
maxLogs: MAX_LOGS,
// UI state
isMonitorExpanded: true,
autoScrollLogs: true,
};
export const useExecutionStore = create<ExecutionStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Execution Lifecycle ==========
startExecution: (execId: string, flowId: string) => {
const now = new Date().toISOString();
set(
{
currentExecution: {
execId,
flowId,
status: 'running',
startedAt: now,
elapsedMs: 0,
},
nodeStates: {},
logs: [],
},
false,
'startExecution'
);
},
setExecutionStatus: (status: ExecutionStatus, currentNodeId?: string) => {
set(
(state) => {
if (!state.currentExecution) return state;
return {
currentExecution: {
...state.currentExecution,
status,
currentNodeId: currentNodeId ?? state.currentExecution.currentNodeId,
},
};
},
false,
'setExecutionStatus'
);
},
completeExecution: (status: 'completed' | 'failed') => {
const now = new Date().toISOString();
set(
(state) => {
if (!state.currentExecution) return state;
const startTime = new Date(state.currentExecution.startedAt).getTime();
const elapsedMs = Date.now() - startTime;
return {
currentExecution: {
...state.currentExecution,
status,
completedAt: now,
elapsedMs,
currentNodeId: undefined,
},
};
},
false,
'completeExecution'
);
},
clearExecution: () => {
set(
{
currentExecution: null,
nodeStates: {},
logs: [],
},
false,
'clearExecution'
);
},
// ========== Node State Updates ==========
setNodeStarted: (nodeId: string) => {
const now = new Date().toISOString();
set(
(state) => ({
nodeStates: {
...state.nodeStates,
[nodeId]: {
nodeId,
status: 'running',
startedAt: now,
},
},
}),
false,
'setNodeStarted'
);
},
setNodeCompleted: (nodeId: string, result?: unknown) => {
const now = new Date().toISOString();
set(
(state) => ({
nodeStates: {
...state.nodeStates,
[nodeId]: {
...state.nodeStates[nodeId],
nodeId,
status: 'completed',
completedAt: now,
result,
},
},
}),
false,
'setNodeCompleted'
);
},
setNodeFailed: (nodeId: string, error: string) => {
const now = new Date().toISOString();
set(
(state) => ({
nodeStates: {
...state.nodeStates,
[nodeId]: {
...state.nodeStates[nodeId],
nodeId,
status: 'failed',
completedAt: now,
error,
},
},
}),
false,
'setNodeFailed'
);
},
clearNodeStates: () => {
set({ nodeStates: {} }, false, 'clearNodeStates');
},
// ========== Logs ==========
addLog: (log: ExecutionLog) => {
set(
(state) => {
const newLogs = [...state.logs, log];
// Trim logs if exceeding max
if (newLogs.length > state.maxLogs) {
return { logs: newLogs.slice(-state.maxLogs) };
}
return { logs: newLogs };
},
false,
'addLog'
);
},
clearLogs: () => {
set({ logs: [] }, false, 'clearLogs');
},
// ========== UI State ==========
setMonitorExpanded: (expanded: boolean) => {
set({ isMonitorExpanded: expanded }, false, 'setMonitorExpanded');
},
setAutoScrollLogs: (autoScroll: boolean) => {
set({ autoScrollLogs: autoScroll }, false, 'setAutoScrollLogs');
},
}),
{ name: 'ExecutionStore' }
)
);
// Selectors for common access patterns
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
export const selectLogs = (state: ExecutionStore) => state.logs;
export const selectIsMonitorExpanded = (state: ExecutionStore) => state.isMonitorExpanded;
export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollLogs;
// Helper to check if execution is active
export const selectIsExecuting = (state: ExecutionStore) => {
return state.currentExecution?.status === 'running';
};
// Helper to get node status
export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
return state.nodeStates[nodeId]?.status ?? 'pending';
};

View File

@@ -0,0 +1,435 @@
// ========================================
// Flow Store
// ========================================
// Zustand store for Orchestrator flow editor state management
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
FlowStore,
Flow,
FlowNode,
FlowEdge,
FlowNodeType,
NodeData,
FlowEdgeData,
} from '../types/flow';
import { NODE_TYPE_CONFIGS as nodeConfigs } from '../types/flow';
// Helper to generate unique IDs
const generateId = (prefix: string): string => {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
// API base URL
const API_BASE = '/api/orchestrator';
// Initial state
const initialState = {
// Current flow
currentFlow: null as Flow | null,
isModified: false,
// Nodes and edges
nodes: [] as FlowNode[],
edges: [] as FlowEdge[],
// Selection state
selectedNodeId: null as string | null,
selectedEdgeId: null as string | null,
// Flow list
flows: [] as Flow[],
isLoadingFlows: false,
// UI state
isPaletteOpen: true,
isPropertyPanelOpen: true,
};
export const useFlowStore = create<FlowStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Flow CRUD ==========
setCurrentFlow: (flow: Flow | null) => {
set(
{
currentFlow: flow,
nodes: flow?.nodes ?? [],
edges: flow?.edges ?? [],
isModified: false,
selectedNodeId: null,
selectedEdgeId: null,
},
false,
'setCurrentFlow'
);
},
createFlow: (name: string, description?: string): Flow => {
const flow: Flow = {
id: generateId('flow'),
name,
description,
version: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
nodes: [],
edges: [],
variables: {},
metadata: { source: 'custom' },
};
set(
{
currentFlow: flow,
nodes: [],
edges: [],
isModified: true,
selectedNodeId: null,
selectedEdgeId: null,
},
false,
'createFlow'
);
return flow;
},
saveFlow: async (): Promise<boolean> => {
const { currentFlow, nodes, edges } = get();
if (!currentFlow) return false;
try {
const flowToSave: Flow = {
...currentFlow,
nodes,
edges,
updated_at: new Date().toISOString(),
};
const isNew = !get().flows.some((f) => f.id === currentFlow.id);
const method = isNew ? 'POST' : 'PUT';
const url = isNew
? `${API_BASE}/flows`
: `${API_BASE}/flows/${currentFlow.id}`;
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flowToSave),
});
if (!response.ok) {
throw new Error(`Failed to save flow: ${response.statusText}`);
}
const savedFlow = await response.json();
set(
(state) => ({
currentFlow: savedFlow,
isModified: false,
flows: isNew
? [...state.flows, savedFlow]
: state.flows.map((f) => (f.id === savedFlow.id ? savedFlow : f)),
}),
false,
'saveFlow'
);
return true;
} catch (error) {
console.error('Error saving flow:', error);
return false;
}
},
loadFlow: async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/flows/${id}`);
if (!response.ok) {
throw new Error(`Failed to load flow: ${response.statusText}`);
}
const flow: Flow = await response.json();
set(
{
currentFlow: flow,
nodes: flow.nodes,
edges: flow.edges,
isModified: false,
selectedNodeId: null,
selectedEdgeId: null,
},
false,
'loadFlow'
);
return true;
} catch (error) {
console.error('Error loading flow:', error);
return false;
}
},
deleteFlow: async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/flows/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete flow: ${response.statusText}`);
}
set(
(state) => ({
flows: state.flows.filter((f) => f.id !== id),
currentFlow: state.currentFlow?.id === id ? null : state.currentFlow,
nodes: state.currentFlow?.id === id ? [] : state.nodes,
edges: state.currentFlow?.id === id ? [] : state.edges,
}),
false,
'deleteFlow'
);
return true;
} catch (error) {
console.error('Error deleting flow:', error);
return false;
}
},
duplicateFlow: async (id: string): Promise<Flow | null> => {
try {
const response = await fetch(`${API_BASE}/flows/${id}/duplicate`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to duplicate flow: ${response.statusText}`);
}
const duplicatedFlow: Flow = await response.json();
set(
(state) => ({
flows: [...state.flows, duplicatedFlow],
}),
false,
'duplicateFlow'
);
return duplicatedFlow;
} catch (error) {
console.error('Error duplicating flow:', error);
return null;
}
},
// ========== Node Operations ==========
addNode: (type: FlowNodeType, position: { x: number; y: number }): string => {
const config = nodeConfigs[type];
const id = generateId('node');
const newNode: FlowNode = {
id,
type,
position,
data: { ...config.defaultData },
};
set(
(state) => ({
nodes: [...state.nodes, newNode],
isModified: true,
selectedNodeId: id,
}),
false,
'addNode'
);
return id;
},
updateNode: (id: string, data: Partial<NodeData>) => {
set(
(state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, ...data } as NodeData }
: node
),
isModified: true,
}),
false,
'updateNode'
);
},
removeNode: (id: string) => {
set(
(state) => ({
nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter(
(edge) => edge.source !== id && edge.target !== id
),
isModified: true,
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
}),
false,
'removeNode'
);
},
setNodes: (nodes: FlowNode[]) => {
set({ nodes, isModified: true }, false, 'setNodes');
},
// ========== Edge Operations ==========
addEdge: (
source: string,
target: string,
sourceHandle?: string,
targetHandle?: string
): string => {
const id = generateId('edge');
const newEdge: FlowEdge = {
id,
source,
target,
sourceHandle,
targetHandle,
};
set(
(state) => ({
edges: [...state.edges, newEdge],
isModified: true,
}),
false,
'addEdge'
);
return id;
},
updateEdge: (id: string, data: Partial<FlowEdgeData>) => {
set(
(state) => ({
edges: state.edges.map((edge) =>
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
),
isModified: true,
}),
false,
'updateEdge'
);
},
removeEdge: (id: string) => {
set(
(state) => ({
edges: state.edges.filter((edge) => edge.id !== id),
isModified: true,
selectedEdgeId: state.selectedEdgeId === id ? null : state.selectedEdgeId,
}),
false,
'removeEdge'
);
},
setEdges: (edges: FlowEdge[]) => {
set({ edges, isModified: true }, false, 'setEdges');
},
// ========== Selection ==========
setSelectedNodeId: (id: string | null) => {
set({ selectedNodeId: id, selectedEdgeId: null }, false, 'setSelectedNodeId');
},
setSelectedEdgeId: (id: string | null) => {
set({ selectedEdgeId: id, selectedNodeId: null }, false, 'setSelectedEdgeId');
},
// ========== Flow List ==========
fetchFlows: async (): Promise<void> => {
set({ isLoadingFlows: true }, false, 'fetchFlows/start');
try {
const response = await fetch(`${API_BASE}/flows`);
if (!response.ok) {
throw new Error(`Failed to fetch flows: ${response.statusText}`);
}
const data = await response.json();
const flows: Flow[] = data.flows || [];
set({ flows, isLoadingFlows: false }, false, 'fetchFlows/success');
} catch (error) {
console.error('Error fetching flows:', error);
set({ isLoadingFlows: false }, false, 'fetchFlows/error');
}
},
// ========== UI State ==========
setIsPaletteOpen: (open: boolean) => {
set({ isPaletteOpen: open }, false, 'setIsPaletteOpen');
},
setIsPropertyPanelOpen: (open: boolean) => {
set({ isPropertyPanelOpen: open }, false, 'setIsPropertyPanelOpen');
},
// ========== Utility ==========
resetFlow: () => {
set(
{
currentFlow: null,
nodes: [],
edges: [],
isModified: false,
selectedNodeId: null,
selectedEdgeId: null,
},
false,
'resetFlow'
);
},
getSelectedNode: (): FlowNode | undefined => {
const { nodes, selectedNodeId } = get();
return nodes.find((node) => node.id === selectedNodeId);
},
markModified: () => {
set({ isModified: true }, false, 'markModified');
},
}),
{ name: 'FlowStore' }
)
);
// Selectors for common access patterns
export const selectCurrentFlow = (state: FlowStore) => state.currentFlow;
export const selectNodes = (state: FlowStore) => state.nodes;
export const selectEdges = (state: FlowStore) => state.edges;
export const selectSelectedNodeId = (state: FlowStore) => state.selectedNodeId;
export const selectSelectedEdgeId = (state: FlowStore) => state.selectedEdgeId;
export const selectFlows = (state: FlowStore) => state.flows;
export const selectIsModified = (state: FlowStore) => state.isModified;
export const selectIsLoadingFlows = (state: FlowStore) => state.isLoadingFlows;
export const selectIsPaletteOpen = (state: FlowStore) => state.isPaletteOpen;
export const selectIsPropertyPanelOpen = (state: FlowStore) => state.isPropertyPanelOpen;

View File

@@ -0,0 +1,154 @@
// ========================================
// Stores Barrel Export
// ========================================
// Re-export all stores for convenient imports
// App Store
export {
useAppStore,
selectTheme,
selectResolvedTheme,
selectSidebarOpen,
selectCurrentView,
selectIsLoading,
selectError,
} from './appStore';
// Workflow Store
export {
useWorkflowStore,
selectWorkflowData,
selectActiveSessions,
selectArchivedSessions,
selectActiveSessionId,
selectProjectPath,
selectFilters,
selectSorting,
} from './workflowStore';
// Config Store
export {
useConfigStore,
selectCliTools,
selectDefaultCliTool,
selectApiEndpoints,
selectUserPreferences,
selectFeatureFlags,
getFirstEnabledCliTool,
} from './configStore';
// Notification Store
export {
useNotificationStore,
selectToasts,
selectWsStatus,
selectWsLastMessage,
selectIsPanelVisible,
selectPersistentNotifications,
toast,
} from './notificationStore';
// Flow Store
export {
useFlowStore,
selectCurrentFlow,
selectNodes,
selectEdges,
selectSelectedNodeId,
selectSelectedEdgeId,
selectFlows,
selectIsModified,
selectIsLoadingFlows,
selectIsPaletteOpen,
selectIsPropertyPanelOpen,
} from './flowStore';
// Execution Store
export {
useExecutionStore,
selectCurrentExecution,
selectNodeStates,
selectLogs,
selectIsMonitorExpanded,
selectAutoScrollLogs,
selectIsExecuting,
selectNodeStatus,
} from './executionStore';
// Re-export types for convenience
export type {
// App Store Types
AppStore,
AppState,
AppActions,
Theme,
ViewMode,
SessionFilter,
LiteTaskType,
// Workflow Store Types
WorkflowStore,
WorkflowState,
WorkflowActions,
WorkflowData,
WorkflowFilters,
WorkflowSorting,
SessionMetadata,
TaskData,
LiteTaskSession,
// Config Store Types
ConfigStore,
ConfigState,
ConfigActions,
CliToolConfig,
ApiEndpoints,
UserPreferences,
// Notification Store Types
NotificationStore,
NotificationState,
NotificationActions,
Toast,
ToastType,
WebSocketStatus,
WebSocketMessage,
} from '../types/store';
// Execution Types
export type {
ExecutionStatus,
NodeExecutionStatus,
LogLevel,
ExecutionLog,
NodeExecutionState,
ExecutionState,
OrchestratorWebSocketMessage,
ExecutionStore,
ExecutionStoreState,
ExecutionStoreActions,
FlowTemplate,
TemplateInstallRequest,
TemplateExportRequest,
} from '../types/execution';
// Flow Types
export type {
FlowNodeType,
SlashCommandNodeData,
FileOperationNodeData,
ConditionalNodeData,
ParallelNodeData,
NodeData,
FlowNode,
FlowEdge,
FlowEdgeData,
Flow,
FlowMetadata,
FlowState,
FlowActions,
FlowStore,
NodeTypeConfig,
} from '../types/flow';
export { NODE_TYPE_CONFIGS } from '../types/flow';

View File

@@ -0,0 +1,257 @@
// ========================================
// Notification Store
// ========================================
// Manages toasts, WebSocket connection status, and persistent notifications
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
NotificationStore,
NotificationState,
Toast,
WebSocketStatus,
WebSocketMessage,
} from '../types/store';
// Constants
const NOTIFICATION_STORAGE_KEY = 'ccw_notifications';
const NOTIFICATION_MAX_STORED = 100;
const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Helper to generate unique ID
const generateId = (): string => {
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Helper to load notifications from localStorage
const loadFromStorage = (): Toast[] => {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
if (stored) {
const parsed: Toast[] = JSON.parse(stored);
// Filter out notifications older than max age
const cutoffTime = Date.now() - NOTIFICATION_MAX_AGE_MS;
return parsed.filter((n) => new Date(n.timestamp).getTime() > cutoffTime);
}
} catch (e) {
console.error('[NotificationStore] Failed to load from storage:', e);
}
return [];
};
// Helper to save notifications to localStorage
const saveToStorage = (notifications: Toast[]): void => {
if (typeof window === 'undefined') return;
try {
// Keep only the last N notifications
const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED);
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.error('[NotificationStore] Failed to save to storage:', e);
}
};
// Initial state
const initialState: NotificationState = {
// Toast queue (ephemeral, UI-only)
toasts: [],
maxToasts: 5,
// WebSocket status
wsStatus: 'disconnected',
wsLastMessage: null,
wsReconnectAttempts: 0,
// Notification panel
isPanelVisible: false,
// Persistent notifications (stored in localStorage)
persistentNotifications: [],
};
export const useNotificationStore = create<NotificationStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Toast Actions ==========
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>): string => {
const id = generateId();
const newToast: Toast = {
...toast,
id,
timestamp: new Date().toISOString(),
dismissible: toast.dismissible ?? true,
duration: toast.duration ?? 5000, // Default 5 seconds
};
set(
(state) => {
const { maxToasts } = state;
// Add new toast at the end, remove oldest if over limit
let newToasts = [...state.toasts, newToast];
if (newToasts.length > maxToasts) {
newToasts = newToasts.slice(-maxToasts);
}
return { toasts: newToasts };
},
false,
'addToast'
);
// Auto-remove after duration (if not persistent)
if (newToast.duration && newToast.duration > 0) {
setTimeout(() => {
get().removeToast(id);
}, newToast.duration);
}
return id;
},
removeToast: (id: string) => {
set(
(state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}),
false,
'removeToast'
);
},
clearAllToasts: () => {
set({ toasts: [] }, false, 'clearAllToasts');
},
// ========== WebSocket Status Actions ==========
setWsStatus: (status: WebSocketStatus) => {
set({ wsStatus: status }, false, 'setWsStatus');
},
setWsLastMessage: (message: WebSocketMessage | null) => {
set({ wsLastMessage: message }, false, 'setWsLastMessage');
},
incrementReconnectAttempts: () => {
set(
(state) => ({
wsReconnectAttempts: state.wsReconnectAttempts + 1,
}),
false,
'incrementReconnectAttempts'
);
},
resetReconnectAttempts: () => {
set({ wsReconnectAttempts: 0 }, false, 'resetReconnectAttempts');
},
// ========== Notification Panel Actions ==========
togglePanel: () => {
set(
(state) => ({
isPanelVisible: !state.isPanelVisible,
}),
false,
'togglePanel'
);
},
setPanelVisible: (visible: boolean) => {
set({ isPanelVisible: visible }, false, 'setPanelVisible');
},
// ========== Persistent Notification Actions ==========
addPersistentNotification: (notification: Omit<Toast, 'id' | 'timestamp'>) => {
const id = generateId();
const newNotification: Toast = {
...notification,
id,
timestamp: new Date().toISOString(),
dismissible: notification.dismissible ?? true,
};
set(
(state) => ({
persistentNotifications: [newNotification, ...state.persistentNotifications],
}),
false,
'addPersistentNotification'
);
// Also save to localStorage
const state = get();
saveToStorage(state.persistentNotifications);
},
removePersistentNotification: (id: string) => {
set(
(state) => ({
persistentNotifications: state.persistentNotifications.filter((n) => n.id !== id),
}),
false,
'removePersistentNotification'
);
// Also save to localStorage
const state = get();
saveToStorage(state.persistentNotifications);
},
clearPersistentNotifications: () => {
set({ persistentNotifications: [] }, false, 'clearPersistentNotifications');
// Also clear localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem(NOTIFICATION_STORAGE_KEY);
}
},
loadPersistentNotifications: () => {
const loaded = loadFromStorage();
set({ persistentNotifications: loaded }, false, 'loadPersistentNotifications');
},
savePersistentNotifications: () => {
const state = get();
saveToStorage(state.persistentNotifications);
},
}),
{ name: 'NotificationStore' }
)
);
// Initialize persistent notifications on store creation
if (typeof window !== 'undefined') {
const loaded = loadFromStorage();
if (loaded.length > 0) {
useNotificationStore.setState({ persistentNotifications: loaded });
}
}
// Selectors for common access patterns
export const selectToasts = (state: NotificationStore) => state.toasts;
export const selectWsStatus = (state: NotificationStore) => state.wsStatus;
export const selectWsLastMessage = (state: NotificationStore) => state.wsLastMessage;
export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelVisible;
export const selectPersistentNotifications = (state: NotificationStore) =>
state.persistentNotifications;
// Helper to create toast shortcuts
export const toast = {
info: (title: string, message?: string) =>
useNotificationStore.getState().addToast({ type: 'info', title, message }),
success: (title: string, message?: string) =>
useNotificationStore.getState().addToast({ type: 'success', title, message }),
warning: (title: string, message?: string) =>
useNotificationStore.getState().addToast({ type: 'warning', title, message }),
error: (title: string, message?: string) =>
useNotificationStore.getState().addToast({ type: 'error', title, message, duration: 0 }),
};

View File

@@ -0,0 +1,477 @@
// ========================================
// Workflow Store
// ========================================
// Manages workflow sessions, tasks, and related data
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
WorkflowStore,
WorkflowState,
SessionMetadata,
TaskData,
LiteTaskSession,
WorkflowFilters,
WorkflowSorting,
} from '../types/store';
// Helper to generate session key from ID
const sessionKey = (sessionId: string): string => {
return `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
};
// Default filters
const defaultFilters: WorkflowFilters = {
status: null,
search: '',
dateRange: { start: null, end: null },
};
// Default sorting
const defaultSorting: WorkflowSorting = {
field: 'created_at',
direction: 'desc',
};
// Initial state
const initialState: WorkflowState = {
// Core data
workflowData: {
activeSessions: [],
archivedSessions: [],
},
projectPath: '',
recentPaths: [],
serverPlatform: 'win32',
// Data stores
sessionDataStore: {},
liteTaskDataStore: {},
taskJsonStore: {},
// Active session
activeSessionId: null,
// Filters and sorting
filters: defaultFilters,
sorting: defaultSorting,
};
export const useWorkflowStore = create<WorkflowStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Session Actions ==========
setSessions: (active: SessionMetadata[], archived: SessionMetadata[]) => {
const sessionDataStore: Record<string, SessionMetadata> = {};
// Build sessionDataStore from both arrays
[...active, ...archived].forEach((session) => {
const key = sessionKey(session.session_id);
sessionDataStore[key] = session;
});
set(
{
workflowData: {
activeSessions: active,
archivedSessions: archived,
},
sessionDataStore,
},
false,
'setSessions'
);
},
addSession: (session: SessionMetadata) => {
const key = sessionKey(session.session_id);
set(
(state) => ({
workflowData: {
...state.workflowData,
activeSessions: [...state.workflowData.activeSessions, session],
},
sessionDataStore: {
...state.sessionDataStore,
[key]: session,
},
}),
false,
'addSession'
);
},
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => {
const key = sessionKey(sessionId);
set(
(state) => {
const session = state.sessionDataStore[key];
if (!session) return state;
const updatedSession = { ...session, ...updates, updated_at: new Date().toISOString() };
// Update in the appropriate array
const isActive = session.location === 'active';
const targetArray = isActive ? 'activeSessions' : 'archivedSessions';
return {
sessionDataStore: {
...state.sessionDataStore,
[key]: updatedSession,
},
workflowData: {
...state.workflowData,
[targetArray]: state.workflowData[targetArray].map((s) =>
s.session_id === sessionId ? updatedSession : s
),
},
};
},
false,
'updateSession'
);
},
removeSession: (sessionId: string) => {
const key = sessionKey(sessionId);
set(
(state) => {
const { [key]: removed, ...remainingStore } = state.sessionDataStore;
return {
sessionDataStore: remainingStore,
workflowData: {
activeSessions: state.workflowData.activeSessions.filter(
(s) => s.session_id !== sessionId
),
archivedSessions: state.workflowData.archivedSessions.filter(
(s) => s.session_id !== sessionId
),
},
};
},
false,
'removeSession'
);
},
archiveSession: (sessionId: string) => {
const key = sessionKey(sessionId);
set(
(state) => {
const session = state.sessionDataStore[key];
if (!session || session.location === 'archived') return state;
const archivedSession: SessionMetadata = {
...session,
location: 'archived',
status: 'archived',
updated_at: new Date().toISOString(),
};
return {
sessionDataStore: {
...state.sessionDataStore,
[key]: archivedSession,
},
workflowData: {
activeSessions: state.workflowData.activeSessions.filter(
(s) => s.session_id !== sessionId
),
archivedSessions: [...state.workflowData.archivedSessions, archivedSession],
},
};
},
false,
'archiveSession'
);
},
// ========== Task Actions ==========
addTask: (sessionId: string, task: TaskData) => {
const key = sessionKey(sessionId);
set(
(state) => {
const session = state.sessionDataStore[key];
if (!session) return state;
// Check for duplicate
const existingTask = session.tasks?.find((t) => t.task_id === task.task_id);
if (existingTask) return state;
const updatedSession: SessionMetadata = {
...session,
tasks: [...(session.tasks || []), task],
updated_at: new Date().toISOString(),
};
return {
sessionDataStore: {
...state.sessionDataStore,
[key]: updatedSession,
},
};
},
false,
'addTask'
);
},
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => {
const key = sessionKey(sessionId);
set(
(state) => {
const session = state.sessionDataStore[key];
if (!session?.tasks) return state;
const updatedTasks = session.tasks.map((task) =>
task.task_id === taskId
? { ...task, ...updates, updated_at: new Date().toISOString() }
: task
);
const updatedSession: SessionMetadata = {
...session,
tasks: updatedTasks,
updated_at: new Date().toISOString(),
};
return {
sessionDataStore: {
...state.sessionDataStore,
[key]: updatedSession,
},
};
},
false,
'updateTask'
);
},
removeTask: (sessionId: string, taskId: string) => {
const key = sessionKey(sessionId);
set(
(state) => {
const session = state.sessionDataStore[key];
if (!session?.tasks) return state;
const updatedSession: SessionMetadata = {
...session,
tasks: session.tasks.filter((t) => t.task_id !== taskId),
updated_at: new Date().toISOString(),
};
return {
sessionDataStore: {
...state.sessionDataStore,
[key]: updatedSession,
},
};
},
false,
'removeTask'
);
},
// ========== Lite Task Actions ==========
setLiteTaskSession: (key: string, session: LiteTaskSession) => {
set(
(state) => ({
liteTaskDataStore: {
...state.liteTaskDataStore,
[key]: session,
},
}),
false,
'setLiteTaskSession'
);
},
removeLiteTaskSession: (key: string) => {
set(
(state) => {
const { [key]: removed, ...remaining } = state.liteTaskDataStore;
return { liteTaskDataStore: remaining };
},
false,
'removeLiteTaskSession'
);
},
// ========== Task JSON Store ==========
setTaskJson: (key: string, data: unknown) => {
set(
(state) => ({
taskJsonStore: {
...state.taskJsonStore,
[key]: data,
},
}),
false,
'setTaskJson'
);
},
removeTaskJson: (key: string) => {
set(
(state) => {
const { [key]: removed, ...remaining } = state.taskJsonStore;
return { taskJsonStore: remaining };
},
false,
'removeTaskJson'
);
},
// ========== Active Session ==========
setActiveSessionId: (sessionId: string | null) => {
set({ activeSessionId: sessionId }, false, 'setActiveSessionId');
},
// ========== Project Path ==========
setProjectPath: (path: string) => {
set({ projectPath: path }, false, 'setProjectPath');
},
addRecentPath: (path: string) => {
set(
(state) => {
// Remove if exists, add to front
const filtered = state.recentPaths.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, 10); // Keep max 10
return { recentPaths: updated };
},
false,
'addRecentPath'
);
},
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => {
set({ serverPlatform: platform }, false, 'setServerPlatform');
},
// ========== Filters and Sorting ==========
setFilters: (filters: Partial<WorkflowFilters>) => {
set(
(state) => ({
filters: { ...state.filters, ...filters },
}),
false,
'setFilters'
);
},
setSorting: (sorting: Partial<WorkflowSorting>) => {
set(
(state) => ({
sorting: { ...state.sorting, ...sorting },
}),
false,
'setSorting'
);
},
resetFilters: () => {
set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters');
},
// ========== Computed Selectors ==========
getActiveSession: () => {
const { activeSessionId, sessionDataStore } = get();
if (!activeSessionId) return null;
const key = sessionKey(activeSessionId);
return sessionDataStore[key] || null;
},
getFilteredSessions: () => {
const { workflowData, filters, sorting } = get();
// Combine active and archived based on filter
let sessions = [...workflowData.activeSessions, ...workflowData.archivedSessions];
// Apply status filter
if (filters.status && filters.status.length > 0) {
sessions = sessions.filter((s) => filters.status!.includes(s.status));
}
// Apply search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
sessions = sessions.filter(
(s) =>
s.session_id.toLowerCase().includes(searchLower) ||
s.title?.toLowerCase().includes(searchLower) ||
s.description?.toLowerCase().includes(searchLower)
);
}
// Apply date range filter
if (filters.dateRange.start || filters.dateRange.end) {
sessions = sessions.filter((s) => {
const createdAt = new Date(s.created_at);
if (filters.dateRange.start && createdAt < filters.dateRange.start) return false;
if (filters.dateRange.end && createdAt > filters.dateRange.end) return false;
return true;
});
}
// Apply sorting
sessions.sort((a, b) => {
let comparison = 0;
switch (sorting.field) {
case 'created_at':
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
case 'updated_at':
comparison =
new Date(a.updated_at || a.created_at).getTime() -
new Date(b.updated_at || b.created_at).getTime();
break;
case 'title':
comparison = (a.title || a.session_id).localeCompare(b.title || b.session_id);
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
}
return sorting.direction === 'desc' ? -comparison : comparison;
});
return sessions;
},
getSessionByKey: (key: string) => {
return get().sessionDataStore[key];
},
}),
{ name: 'WorkflowStore' }
)
);
// Selectors for common access patterns
export const selectWorkflowData = (state: WorkflowStore) => state.workflowData;
export const selectActiveSessions = (state: WorkflowStore) => state.workflowData.activeSessions;
export const selectArchivedSessions = (state: WorkflowStore) => state.workflowData.archivedSessions;
export const selectActiveSessionId = (state: WorkflowStore) => state.activeSessionId;
export const selectProjectPath = (state: WorkflowStore) => state.projectPath;
export const selectFilters = (state: WorkflowStore) => state.filters;
export const selectSorting = (state: WorkflowStore) => state.sorting;