From f22b1a2095cf97ea170d9b9acff2ddbde93a26db Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 15 Mar 2026 20:17:17 +0800 Subject: [PATCH] refactor(phase-11): extract specialized stores from gatewayStore Decompose monolithic gatewayStore.ts (1660 lines) into focused stores: - connectionStore.ts (444 lines) - WebSocket, auth, local gateway - agentStore.ts (256 lines) - Clones, usage stats, plugins - handStore.ts (498 lines) - Hands, triggers, approvals - workflowStore.ts (255 lines) - Workflows, runs - configStore.ts (537 lines) - QuickConfig, channels, skills Each store uses client injection pattern for loose coupling. Coordinator layer to be added in next commit. Co-Authored-By: Claude Opus 4.6 --- desktop/src/store/agentStore.ts | 256 +++++++++++++ desktop/src/store/configStore.ts | 537 +++++++++++++++++++++++++++ desktop/src/store/connectionStore.ts | 444 ++++++++++++++++++++++ desktop/src/store/handStore.ts | 498 +++++++++++++++++++++++++ desktop/src/store/workflowStore.ts | 255 +++++++++++++ docs/SYSTEM_ANALYSIS.md | 17 +- 6 files changed, 2006 insertions(+), 1 deletion(-) create mode 100644 desktop/src/store/agentStore.ts create mode 100644 desktop/src/store/configStore.ts create mode 100644 desktop/src/store/connectionStore.ts create mode 100644 desktop/src/store/handStore.ts create mode 100644 desktop/src/store/workflowStore.ts diff --git a/desktop/src/store/agentStore.ts b/desktop/src/store/agentStore.ts new file mode 100644 index 0000000..8a133ff --- /dev/null +++ b/desktop/src/store/agentStore.ts @@ -0,0 +1,256 @@ +/** + * Agent Store - Manages clones/agents, usage statistics, and plugin status + * + * Extracted from gatewayStore.ts for Phase 11 Store Refactoring. + * This store focuses on agent/clone CRUD operations and related metadata. + */ +import { create } from 'zustand'; +import type { GatewayClient } from '../lib/gateway-client'; + +// === Types === + +export interface Clone { + id: string; + name: string; + role?: string; + nickname?: string; + scenarios?: string[]; + model?: string; + workspaceDir?: string; + workspaceResolvedPath?: string; + restrictFiles?: boolean; + privacyOptIn?: boolean; + userName?: string; + userRole?: string; + createdAt: string; + bootstrapReady?: boolean; + bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>; + updatedAt?: string; +} + +export interface UsageStats { + totalSessions: number; + totalMessages: number; + totalTokens: number; + byModel: Record; +} + +export interface PluginStatus { + id: string; + name?: string; + status: 'active' | 'inactive' | 'error' | 'loading'; + version?: string; + description?: string; +} + +export interface CloneCreateOptions { + name: string; + role?: string; + nickname?: string; + scenarios?: string[]; + model?: string; + workspaceDir?: string; + restrictFiles?: boolean; + privacyOptIn?: boolean; + userName?: string; + userRole?: string; +} + +// === Store State === + +interface AgentStateSlice { + clones: Clone[]; + usageStats: UsageStats | null; + pluginStatus: PluginStatus[]; + isLoading: boolean; + error: string | null; +} + +// === Store Actions === + +interface AgentActionsSlice { + loadClones: () => Promise; + createClone: (opts: CloneCreateOptions) => Promise; + updateClone: (id: string, updates: Partial) => Promise; + deleteClone: (id: string) => Promise; + loadUsageStats: () => Promise; + loadPluginStatus: () => Promise; + setError: (error: string | null) => void; + clearError: () => void; +} + +// === Store Interface === + +export type AgentStore = AgentStateSlice & AgentActionsSlice; + +// === Client Injection === + +// For coordinator to inject client - avoids direct import coupling +let _client: GatewayClient | null = null; + +/** + * Sets the gateway client for the agent store. + * Called by the coordinator during initialization. + */ +export const setAgentStoreClient = (client: unknown): void => { + _client = client as GatewayClient; +}; + +/** + * Gets the gateway client. + * Returns null if not set (coordinator must initialize first). + */ +const getClient = (): GatewayClient | null => _client; + +// === Store Implementation === + +export const useAgentStore = create((set, get) => ({ + // Initial state + clones: [], + usageStats: null, + pluginStatus: [], + isLoading: false, + error: null, + + // Actions + loadClones: async () => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized, skipping loadClones'); + return; + } + + try { + set({ isLoading: true, error: null }); + const result = await client.listClones(); + const clones = result?.clones || result?.agents || []; + set({ clones, isLoading: false }); + + // Set default agent ID if we have agents and none is set + if (clones.length > 0 && clones[0].id) { + const currentDefault = client.getDefaultAgentId(); + // Only set if the default doesn't exist in the list + const defaultExists = clones.some((c: Clone) => c.id === currentDefault); + if (!defaultExists) { + client.setDefaultAgentId(clones[0].id); + } + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + set({ error: errorMessage, isLoading: false }); + // Don't throw - clone loading is non-critical + } + }, + + createClone: async (opts: CloneCreateOptions) => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized'); + return undefined; + } + + try { + set({ isLoading: true, error: null }); + const result = await client.createClone(opts); + await get().loadClones(); // Refresh the list + return result?.clone; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + set({ error: errorMessage, isLoading: false }); + return undefined; + } + }, + + updateClone: async (id: string, updates: Partial) => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized'); + return undefined; + } + + try { + set({ isLoading: true, error: null }); + const result = await client.updateClone(id, updates); + await get().loadClones(); // Refresh the list + return result?.clone; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + set({ error: errorMessage, isLoading: false }); + return undefined; + } + }, + + deleteClone: async (id: string) => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized'); + return; + } + + try { + set({ isLoading: true, error: null }); + await client.deleteClone(id); + await get().loadClones(); // Refresh the list + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + set({ error: errorMessage, isLoading: false }); + } + }, + + loadUsageStats: async () => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized, skipping loadUsageStats'); + return; + } + + try { + const stats = await client.getUsageStats(); + set({ usageStats: stats }); + } catch { + // Usage stats are non-critical, ignore errors silently + } + }, + + loadPluginStatus: async () => { + const client = getClient(); + if (!client) { + console.warn('[AgentStore] Client not initialized, skipping loadPluginStatus'); + return; + } + + try { + const result = await client.getPluginStatus(); + set({ pluginStatus: result?.plugins || [] }); + } catch { + // Plugin status is non-critical, ignore errors silently + } + }, + + setError: (error: string | null) => { + set({ error }); + }, + + clearError: () => { + set({ error: null }); + }, +})); + +// === Selectors === + +/** + * Get a clone by ID + */ +export const selectCloneById = (id: string) => (state: AgentStore): Clone | undefined => + state.clones.find((clone) => clone.id === id); + +/** + * Get all active plugins + */ +export const selectActivePlugins = (state: AgentStore): PluginStatus[] => + state.pluginStatus.filter((plugin) => plugin.status === 'active'); + +/** + * Check if any operation is in progress + */ +export const selectIsLoading = (state: AgentStore): boolean => state.isLoading; diff --git a/desktop/src/store/configStore.ts b/desktop/src/store/configStore.ts new file mode 100644 index 0000000..fdd0217 --- /dev/null +++ b/desktop/src/store/configStore.ts @@ -0,0 +1,537 @@ +/** + * configStore.ts - Configuration Management Store + * + * Extracted from gatewayStore.ts for Phase 11 Store Refactoring. + * Manages settings, workspace, channels, skills, and models. + */ +import { create } from 'zustand'; +import type { GatewayModelChoice } from '../lib/gateway-config'; + +// === Types === + +export interface QuickConfig { + agentName?: string; + agentRole?: string; + userName?: string; + userRole?: string; + agentNickname?: string; + scenarios?: string[]; + workspaceDir?: string; + gatewayUrl?: string; + gatewayToken?: string; + skillsExtraDirs?: string[]; + mcpServices?: Array<{ id: string; name: string; enabled: boolean }>; + theme?: 'light' | 'dark'; + autoStart?: boolean; + showToolCalls?: boolean; + restrictFiles?: boolean; + autoSaveContext?: boolean; + fileWatching?: boolean; + privacyOptIn?: boolean; +} + +export interface WorkspaceInfo { + path: string; + resolvedPath: string; + exists: boolean; + fileCount: number; + totalSize: number; +} + +export interface ChannelInfo { + id: string; + type: string; + label: string; + status: 'active' | 'inactive' | 'error'; + accounts?: number; + error?: string; +} + +export interface ScheduledTask { + id: string; + name: string; + schedule: string; + status: 'active' | 'paused' | 'completed' | 'error'; + lastRun?: string; + nextRun?: string; + description?: string; +} + +export interface SkillInfo { + id: string; + name: string; + path: string; + source: 'builtin' | 'extra'; + description?: string; + triggers?: Array<{ type: string; pattern?: string }>; + actions?: Array<{ type: string; params?: Record }>; + enabled?: boolean; +} + +// === Client Interface === + +export interface ConfigStoreClient { + getWorkspaceInfo(): Promise; + getQuickConfig(): Promise<{ quickConfig?: QuickConfig } | null>; + saveQuickConfig(config: QuickConfig): Promise<{ quickConfig?: QuickConfig } | null>; + listSkills(): Promise<{ skills?: SkillInfo[]; extraDirs?: string[] } | null>; + getSkill(id: string): Promise<{ skill?: SkillInfo } | null>; + createSkill(skill: { + name: string; + description?: string; + triggers: Array<{ type: string; pattern?: string }>; + actions: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }): Promise<{ skill?: SkillInfo } | null>; + updateSkill(id: string, updates: { + name?: string; + description?: string; + triggers?: Array<{ type: string; pattern?: string }>; + actions?: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }): Promise<{ skill?: SkillInfo } | null>; + deleteSkill(id: string): Promise; + listChannels(): Promise<{ channels?: ChannelInfo[] } | null>; + getChannel(id: string): Promise<{ channel?: ChannelInfo } | null>; + createChannel(channel: { + type: string; + name: string; + config: Record; + enabled?: boolean; + }): Promise<{ channel?: ChannelInfo } | null>; + updateChannel(id: string, updates: { + name?: string; + config?: Record; + enabled?: boolean; + }): Promise<{ channel?: ChannelInfo } | null>; + deleteChannel(id: string): Promise; + listScheduledTasks(): Promise<{ tasks?: ScheduledTask[] } | null>; + createScheduledTask(task: { + name: string; + schedule: string; + scheduleType: 'cron' | 'interval' | 'once'; + target?: { + type: 'agent' | 'hand' | 'workflow'; + id: string; + }; + description?: string; + enabled?: boolean; + }): Promise; + listModels(): Promise<{ models: GatewayModelChoice[] }>; + getFeishuStatus(): Promise<{ configured?: boolean; accounts?: number } | null>; +} + +// === Store State & Actions === + +interface ConfigStore { + // State + quickConfig: QuickConfig; + workspaceInfo: WorkspaceInfo | null; + channels: ChannelInfo[]; + scheduledTasks: ScheduledTask[]; + skillsCatalog: SkillInfo[]; + models: GatewayModelChoice[]; + modelsLoading: boolean; + modelsError: string | null; + error: string | null; + + // Client reference (injected) + client: ConfigStoreClient | null; + + // Client injection + setConfigStoreClient: (client: ConfigStoreClient) => void; + + // Quick Config Actions + loadQuickConfig: () => Promise; + saveQuickConfig: (updates: Partial) => Promise; + + // Workspace Actions + loadWorkspaceInfo: () => Promise; + + // Channel Actions + loadChannels: () => Promise; + getChannel: (id: string) => Promise; + createChannel: (channel: { + type: string; + name: string; + config: Record; + enabled?: boolean; + }) => Promise; + updateChannel: (id: string, updates: { + name?: string; + config?: Record; + enabled?: boolean; + }) => Promise; + deleteChannel: (id: string) => Promise; + + // Scheduled Task Actions + loadScheduledTasks: () => Promise; + createScheduledTask: (task: { + name: string; + schedule: string; + scheduleType: 'cron' | 'interval' | 'once'; + target?: { + type: 'agent' | 'hand' | 'workflow'; + id: string; + }; + description?: string; + enabled?: boolean; + }) => Promise; + + // Skill Actions + loadSkillsCatalog: () => Promise; + getSkill: (id: string) => Promise; + createSkill: (skill: { + name: string; + description?: string; + triggers: Array<{ type: string; pattern?: string }>; + actions: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }) => Promise; + updateSkill: (id: string, updates: { + name?: string; + description?: string; + triggers?: Array<{ type: string; pattern?: string }>; + actions?: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }) => Promise; + deleteSkill: (id: string) => Promise; + + // Model Actions + loadModels: () => Promise; + + // Utility + clearError: () => void; +} + +export const useConfigStore = create((set, get) => ({ + // Initial State + quickConfig: {}, + workspaceInfo: null, + channels: [], + scheduledTasks: [], + skillsCatalog: [], + models: [], + modelsLoading: false, + modelsError: null, + error: null, + client: null, + + // Client Injection + setConfigStoreClient: (client: ConfigStoreClient) => { + set({ client }); + }, + + // === Quick Config Actions === + + loadQuickConfig: async () => { + const client = get().client; + if (!client) return; + + try { + const result = await client.getQuickConfig(); + set({ quickConfig: result?.quickConfig || {} }); + } catch { + // Ignore if quick config not available + } + }, + + saveQuickConfig: async (updates: Partial) => { + const client = get().client; + if (!client) return; + + try { + const nextConfig = { ...get().quickConfig, ...updates }; + const result = await client.saveQuickConfig(nextConfig); + set({ quickConfig: result?.quickConfig || nextConfig }); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + } + }, + + // === Workspace Actions === + + loadWorkspaceInfo: async () => { + const client = get().client; + if (!client) return; + + try { + const info = await client.getWorkspaceInfo(); + set({ workspaceInfo: info }); + } catch { + // Ignore if workspace info not available + } + }, + + // === Channel Actions === + + loadChannels: async () => { + const client = get().client; + if (!client) return; + + const channels: ChannelInfo[] = []; + try { + // Try listing channels from Gateway + const result = await client.listChannels(); + if (result?.channels) { + set({ channels: result.channels }); + return; + } + } catch { + // channels.list may not be available, fallback to probing + } + + // Fallback: probe known channels individually + try { + const feishu = await client.getFeishuStatus(); + channels.push({ + id: 'feishu', + type: 'feishu', + label: 'Feishu', + status: feishu?.configured ? 'active' : 'inactive', + accounts: feishu?.accounts || 0, + }); + } catch { + channels.push({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' }); + } + + set({ channels }); + }, + + getChannel: async (id: string) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.getChannel(id); + if (result?.channel) { + // Update the channel in the local state if it exists + const currentChannels = get().channels; + const existingIndex = currentChannels.findIndex(c => c.id === id); + if (existingIndex >= 0) { + const updatedChannels = [...currentChannels]; + updatedChannels[existingIndex] = result.channel; + set({ channels: updatedChannels }); + } + return result.channel as ChannelInfo; + } + return undefined; + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + createChannel: async (channel) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.createChannel(channel); + if (result?.channel) { + // Add the new channel to local state + const currentChannels = get().channels; + set({ channels: [...currentChannels, result.channel as ChannelInfo] }); + return result.channel as ChannelInfo; + } + return undefined; + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + updateChannel: async (id, updates) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.updateChannel(id, updates); + if (result?.channel) { + // Update the channel in local state + const currentChannels = get().channels; + const updatedChannels = currentChannels.map(c => + c.id === id ? (result.channel as ChannelInfo) : c + ); + set({ channels: updatedChannels }); + return result.channel as ChannelInfo; + } + return undefined; + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + deleteChannel: async (id) => { + const client = get().client; + if (!client) return; + + try { + await client.deleteChannel(id); + // Remove the channel from local state + const currentChannels = get().channels; + set({ channels: currentChannels.filter(c => c.id !== id) }); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + } + }, + + // === Scheduled Task Actions === + + loadScheduledTasks: async () => { + const client = get().client; + if (!client) return; + + try { + const result = await client.listScheduledTasks(); + set({ scheduledTasks: result?.tasks || [] }); + } catch { + // Ignore if heartbeat.tasks not available + } + }, + + createScheduledTask: async (task) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.createScheduledTask(task); + const newTask: ScheduledTask = { + id: result.id, + name: result.name, + schedule: result.schedule, + status: result.status as 'active' | 'paused' | 'completed' | 'error', + lastRun: result.lastRun, + nextRun: result.nextRun, + description: result.description, + }; + set((state) => ({ + scheduledTasks: [...state.scheduledTasks, newTask], + })); + return newTask; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create scheduled task'; + set({ error: errorMessage }); + return undefined; + } + }, + + // === Skill Actions === + + loadSkillsCatalog: async () => { + const client = get().client; + if (!client) return; + + try { + const result = await client.listSkills(); + set({ skillsCatalog: result?.skills || [] }); + if (result?.extraDirs) { + set((state) => ({ + quickConfig: { + ...state.quickConfig, + skillsExtraDirs: result.extraDirs, + }, + })); + } + } catch { + // Ignore if skills list not available + } + }, + + getSkill: async (id: string) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.getSkill(id); + return result?.skill as SkillInfo | undefined; + } catch { + return undefined; + } + }, + + createSkill: async (skill) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.createSkill(skill); + const newSkill = result?.skill as SkillInfo | undefined; + if (newSkill) { + set((state) => ({ + skillsCatalog: [...state.skillsCatalog, newSkill], + })); + } + return newSkill; + } catch { + return undefined; + } + }, + + updateSkill: async (id, updates) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.updateSkill(id, updates); + const updatedSkill = result?.skill as SkillInfo | undefined; + if (updatedSkill) { + set((state) => ({ + skillsCatalog: state.skillsCatalog.map((s) => + s.id === id ? updatedSkill : s + ), + })); + } + return updatedSkill; + } catch { + return undefined; + } + }, + + deleteSkill: async (id) => { + const client = get().client; + if (!client) return; + + try { + await client.deleteSkill(id); + set((state) => ({ + skillsCatalog: state.skillsCatalog.filter((s) => s.id !== id), + })); + } catch { + // Ignore deletion errors + } + }, + + // === Model Actions === + + loadModels: async () => { + const client = get().client; + if (!client) return; + + try { + set({ modelsLoading: true, modelsError: null }); + const result = await client.listModels(); + const models: GatewayModelChoice[] = result?.models || []; + set({ models, modelsLoading: false }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load models'; + set({ modelsError: message, modelsLoading: false }); + } + }, + + // === Utility === + + clearError: () => { + set({ error: null }); + }, +})); + +// Re-export types for convenience +export type { + QuickConfig as QuickConfigType, + WorkspaceInfo as WorkspaceInfoType, + ChannelInfo as ChannelInfoType, + ScheduledTask as ScheduledTaskType, + SkillInfo as SkillInfoType, +}; diff --git a/desktop/src/store/connectionStore.ts b/desktop/src/store/connectionStore.ts new file mode 100644 index 0000000..96b8991 --- /dev/null +++ b/desktop/src/store/connectionStore.ts @@ -0,0 +1,444 @@ +import { create } from 'zustand'; +import { + DEFAULT_GATEWAY_URL, + FALLBACK_GATEWAY_URLS, + GatewayClient, + ConnectionState, + getGatewayClient, + getStoredGatewayToken, + setStoredGatewayToken, + getStoredGatewayUrl, + setStoredGatewayUrl, + getLocalDeviceIdentity, +} from '../lib/gateway-client'; +import { + isTauriRuntime, + prepareLocalGatewayForTauri, + getLocalGatewayStatus, + startLocalGateway as startLocalGatewayCommand, + stopLocalGateway as stopLocalGatewayCommand, + restartLocalGateway as restartLocalGatewayCommand, + approveLocalGatewayDevicePairing, + getLocalGatewayAuth, + getUnsupportedLocalGatewayStatus, + type LocalGatewayStatus, +} from '../lib/tauri-gateway'; + +// === Types === + +export interface GatewayLog { + timestamp: number; + level: string; + message: string; +} + +// === Helper Functions === + +/** + * Check if an error indicates we connection should retry with another candidate. + */ +function shouldRetryGatewayCandidate(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error || ''); + return ( + message === 'WebSocket connection failed' + || message.startsWith('Gateway handshake timed out') + || message.startsWith('WebSocket closed before handshake completed') + || message.startsWith('Connection refused') + || message.includes('ECONNREFUSED') + || message.includes('Failed to fetch') + || message.includes('Network error') + || message.includes('pairing required') + ); +} + +/** + * Check if an error indicates local device pairing is required. + */ +function requiresLocalDevicePairing(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error || ''); + return message.includes('pairing required'); +} + +/** + * Calculate security level based on enabled layer count. + */ +function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' { + if (totalCount === 0) return 'low'; + const ratio = enabledCount / totalCount; + if (ratio >= 0.875) return 'critical'; // 14-16 layers + if (ratio >= 0.625) return 'high'; // 10-13 layers + if (ratio >= 0.375) return 'medium'; // 6-9 layers + return 'low'; // 0-5 layers +} + +/** + * Check if a URL is a loopback address. + */ +function isLoopbackGatewayUrl(url: string): boolean { + return /^wss?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(url.trim()); +} + +/** + * Normalize a gateway URL candidate. + */ +function normalizeGatewayUrlCandidate(url: string): string { + return url.trim().replace(/\/+$/, ''); +} + +/** + * Get the local gateway connect URL from status. + */ +function getLocalGatewayConnectUrl(status: LocalGatewayStatus): string | null { + if (status.probeUrl && status.probeUrl.trim()) { + return normalizeGatewayUrlCandidate(status.probeUrl); + } + if (status.port) { + return `ws://127.0.0.1:${status.port}`; + } + return null; +} + +/** + * Attempt to approve local device pairing for loopback URLs. + */ +async function approveCurrentLocalDevicePairing(url: string): Promise { + if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) { + return false; + } + + try { + const identity = await getLocalDeviceIdentity(); + const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url); + return result.approved; + } catch { + return false; + } +} + +// === Store Interface === + +export interface ConnectionStateSlice { + connectionState: ConnectionState; + gatewayVersion: string | null; + error: string | null; + logs: GatewayLog[]; + localGateway: LocalGatewayStatus; + localGatewayBusy: boolean; + isLoading: boolean; +} + +export interface ConnectionActionsSlice { + connect: (url?: string, token?: string) => Promise; + disconnect: () => void; + clearLogs: () => void; + refreshLocalGateway: () => Promise; + startLocalGateway: () => Promise; + stopLocalGateway: () => Promise; + restartLocalGateway: () => Promise; +} + +export interface ConnectionStore extends ConnectionStateSlice, ConnectionActionsSlice { + client: GatewayClient; +} + +// === Store Implementation === + +export const useConnectionStore = create((set, get) => { + // Initialize client + const client = getGatewayClient(); + + // Wire up state change callback + client.onStateChange = (state) => { + set({ connectionState: state }); + }; + + // Wire up log callback + client.onLog = (level, message) => { + set((s) => ({ + logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }], + })); + }; + + return { + // === Initial State === + connectionState: 'disconnected', + gatewayVersion: null, + error: null, + logs: [], + localGateway: getUnsupportedLocalGatewayStatus(), + localGatewayBusy: false, + isLoading: false, + client, + + // === Actions === + + connect: async (url?: string, token?: string) => { + const c = get().client; + + // Resolve connection URL candidates + const resolveCandidates = async (): Promise => { + const explicitUrl = url?.trim(); + if (explicitUrl) { + return [normalizeGatewayUrlCandidate(explicitUrl)]; + } + + const candidates: string[] = []; + + // Check local gateway first if in Tauri + if (isTauriRuntime()) { + try { + const localStatus = await getLocalGatewayStatus(); + const localUrl = getLocalGatewayConnectUrl(localStatus); + if (localUrl) { + candidates.push(localUrl); + } + } catch { + /* ignore local gateway lookup failures during candidate selection */ + } + } + + // Add quick config gateway URL if available + const quickConfigGatewayUrl = get().quickConfig?.gatewayUrl?.trim(); + if (quickConfigGatewayUrl) { + candidates.push(quickConfigGatewayUrl); + } + + // Add stored URL, default, and fallbacks + candidates.push( + getStoredGatewayUrl(), + DEFAULT_GATEWAY_URL, + ...FALLBACK_GATEWAY_URLS + ); + + // Return unique, non-empty candidates + return Array.from( + new Set( + candidates + .filter(Boolean) + .map(normalizeGatewayUrlCandidate) + ) + ); + }; + + try { + set({ error: null }); + + // Prepare local gateway for Tauri + if (isTauriRuntime()) { + try { + await prepareLocalGatewayForTauri(); + } catch { + /* ignore local gateway preparation failures during connection bootstrap */ + } + } + + // Resolve effective token: param > quickConfig > localStorage > local auth + let effectiveToken = token || get().quickConfig?.gatewayToken || getStoredGatewayToken(); + if (!effectiveToken && isTauriRuntime()) { + try { + const localAuth = await getLocalGatewayAuth(); + if (localAuth.gatewayToken) { + effectiveToken = localAuth.gatewayToken; + setStoredGatewayToken(localAuth.gatewayToken); + } + } catch { + /* ignore local auth lookup failures during connection bootstrap */ + } + } + + console.log('[ConnectionStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)'); + + const candidateUrls = await resolveCandidates(); + let lastError: unknown = null; + let connectedUrl: string | null = null; + + // Try each candidate URL + for (const candidateUrl of candidateUrls) { + try { + c.updateOptions({ + url: candidateUrl, + token: effectiveToken, + }); + await c.connect(); + connectedUrl = candidateUrl; + break; + } catch (err) { + lastError = err; + + // Try device pairing if required + if (requiresLocalDevicePairing(err)) { + const approved = await approveCurrentLocalDevicePairing(candidateUrl); + if (approved) { + c.updateOptions({ + url: candidateUrl, + token: effectiveToken, + }); + await c.connect(); + connectedUrl = candidateUrl; + break; + } + } + + // Check if we should try next candidate + if (!shouldRetryGatewayCandidate(err)) { + throw err; + } + } + } + + if (!connectedUrl) { + throw (lastError instanceof Error ? lastError : new Error('Failed to connect to any available Gateway')); + } + + // Store successful URL + setStoredGatewayUrl(connectedUrl); + + // Fetch gateway version + try { + const health = await c.health(); + set({ gatewayVersion: health?.version }); + } catch { /* health may not return version */ } + + console.log('[ConnectionStore] Connected to:', connectedUrl); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + set({ error: errorMessage }); + throw err; + } + }, + + disconnect: () => { + get().client.disconnect(); + set({ + connectionState: 'disconnected', + gatewayVersion: null, + error: null, + }); + }, + + clearLogs: () => set({ logs: [] }), + + refreshLocalGateway: async () => { + if (!isTauriRuntime()) { + const unsupported = getUnsupportedLocalGatewayStatus(); + set({ localGateway: unsupported, localGatewayBusy: false }); + return unsupported; + } + + set({ localGatewayBusy: true }); + try { + const status = await getLocalGatewayStatus(); + set({ localGateway: status, localGatewayBusy: false }); + return status; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to read local Gateway status'; + const nextStatus = { + ...get().localGateway, + supported: true, + error: message, + }; + set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); + return nextStatus; + } + }, + + startLocalGateway: async () => { + if (!isTauriRuntime()) { + const unsupported = getUnsupportedLocalGatewayStatus(); + set({ localGateway: unsupported, localGatewayBusy: false }); + return unsupported; + } + + set({ localGatewayBusy: true, error: null }); + try { + const status = await startLocalGatewayCommand(); + set({ localGateway: status, localGatewayBusy: false }); + return status; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to start local Gateway'; + const nextStatus = { + ...get().localGateway, + supported: true, + error: message, + }; + set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); + return undefined; + } + }, + + stopLocalGateway: async () => { + if (!isTauriRuntime()) { + const unsupported = getUnsupportedLocalGatewayStatus(); + set({ localGateway: unsupported, localGatewayBusy: false }); + return unsupported; + } + + set({ localGatewayBusy: true, error: null }); + try { + const status = await stopLocalGatewayCommand(); + set({ localGateway: status, localGatewayBusy: false }); + return status; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to stop local Gateway'; + const nextStatus = { + ...get().localGateway, + supported: true, + error: message, + }; + set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); + return undefined; + } + }, + + restartLocalGateway: async () => { + if (!isTauriRuntime()) { + const unsupported = getUnsupportedLocalGatewayStatus(); + set({ localGateway: unsupported, localGatewayBusy: false }); + return unsupported; + } + + set({ localGatewayBusy: true, error: null }); + try { + const status = await restartLocalGatewayCommand(); + set({ localGateway: status, localGatewayBusy: false }); + return status; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to restart local Gateway'; + const nextStatus = { + ...get().localGateway, + supported: true, + error: message, + }; + set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); + return undefined; + } + }, + }; +}); + +// === Exported Accessors for Coordinator === + +/** + * Get current connection state. + */ +export const getConnectionState = () => useConnectionStore.getState().connectionState; + +/** + * Get gateway client instance. + */ +export const getClient = () => useConnectionStore.getState().client; + +/** + * Get current error message. + */ +export const getConnectionError = () => useConnectionStore.getState().error; + +/** + * Get local gateway status. + */ +export const getLocalGatewayStatus = () => useConnectionStore.getState().localGateway; + +/** + * Get gateway version. + */ +export const getGatewayVersion = () => useConnectionStore.getState().gatewayVersion; diff --git a/desktop/src/store/handStore.ts b/desktop/src/store/handStore.ts new file mode 100644 index 0000000..96bcdff --- /dev/null +++ b/desktop/src/store/handStore.ts @@ -0,0 +1,498 @@ +/** + * handStore.ts - Hand, Trigger, and Approval management store + * + * Extracted from gatewayStore.ts for Phase 11 Store Refactoring. + * Manages OpenFang Hands, Triggers, and Approval workflows. + */ +import { create } from 'zustand'; +import type { GatewayClient } from '../lib/gateway-client'; + +// === Re-exported Types (from gatewayStore for compatibility) === + +export interface HandRequirement { + description: string; + met: boolean; + details?: string; +} + +export interface Hand { + id: string; + name: string; + description: string; + status: 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed'; + currentRunId?: string; + requirements_met?: boolean; + category?: string; + icon?: string; + provider?: string; + model?: string; + requirements?: HandRequirement[]; + tools?: string[]; + metrics?: string[]; + toolCount?: number; + metricCount?: number; +} + +export interface HandRun { + runId: string; + status: string; + startedAt: string; + completedAt?: string; + result?: unknown; + error?: string; +} + +export interface Trigger { + id: string; + type: string; + enabled: boolean; +} + +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired'; + +export interface Approval { + id: string; + handName: string; + runId?: string; + status: ApprovalStatus; + requestedAt: string; + requestedBy?: string; + reason?: string; + action?: string; + params?: Record; + respondedAt?: string; + respondedBy?: string; + responseReason?: string; +} + +// === Raw API Response Types (for mapping) === + +interface RawHandRequirement { + description?: string; + name?: string; + met?: boolean; + satisfied?: boolean; + details?: string; + hint?: string; +} + +interface RawHandRun { + runId?: string; + run_id?: string; + id?: string; + status?: string; + startedAt?: string; + started_at?: string; + created_at?: string; + completedAt?: string; + completed_at?: string; + finished_at?: string; + result?: unknown; + output?: unknown; + error?: string; + message?: string; +} + +interface RawApproval { + id?: string; + approvalId?: string; + approval_id?: string; + type?: string; + request_type?: string; + handId?: string; + hand_id?: string; + hand_name?: string; + handName?: string; + run_id?: string; + runId?: string; + requester?: string; + requested_by?: string; + requestedAt?: string; + requested_at?: string; + status?: string; + reason?: string; + description?: string; + action?: string; + params?: Record; + responded_at?: string; + respondedAt?: string; + responded_by?: string; + respondedBy?: string; + response_reason?: string; + responseReason?: string; +} + +// === Store Interface === + +interface HandClient { + listHands: () => Promise<{ hands?: Array> } | null>; + getHand: (name: string) => Promise | null>; + listHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<{ runs?: RawHandRun[] } | null>; + triggerHand: (name: string, params?: Record) => Promise<{ runId?: string; status?: string } | null>; + approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise; + cancelHand: (name: string, runId: string) => Promise; + listTriggers: () => Promise<{ triggers?: Trigger[] } | null>; + getTrigger: (id: string) => Promise; + createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }) => Promise<{ id?: string } | null>; + updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }) => Promise; + deleteTrigger: (id: string) => Promise; + listApprovals: (status?: ApprovalStatus) => Promise<{ approvals?: RawApproval[] } | null>; + respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise; +} + +interface HandStore { + // State + hands: Hand[]; + handRuns: Record; + triggers: Trigger[]; + approvals: Approval[]; + isLoading: boolean; + error: string | null; + + // Client reference (injected via setHandStoreClient) + client: HandClient | null; + + // Actions + setHandStoreClient: (client: HandClient) => void; + loadHands: () => Promise; + getHandDetails: (name: string) => Promise; + triggerHand: (name: string, params?: Record) => Promise; + loadHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise; + approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise; + cancelHand: (name: string, runId: string) => Promise; + loadTriggers: () => Promise; + getTrigger: (id: string) => Promise; + createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }) => Promise; + updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }) => Promise; + deleteTrigger: (id: string) => Promise; + loadApprovals: (status?: ApprovalStatus) => Promise; + respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise; + clearError: () => void; +} + +export const useHandStore = create((set, get) => ({ + // Initial State + hands: [], + handRuns: {}, + triggers: [], + approvals: [], + isLoading: false, + error: null, + client: null, + + // Client injection + setHandStoreClient: (client: HandClient) => { + set({ client }); + }, + + // === Hand Actions === + + loadHands: async () => { + const client = get().client; + if (!client) return; + + set({ isLoading: true }); + try { + const result = await client.listHands(); + const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const; + const hands: Hand[] = (result?.hands || []).map((h: Record) => { + const status = validStatuses.includes(h.status as Hand['status']) + ? h.status as Hand['status'] + : (h.requirements_met ? 'idle' : 'setup_needed'); + return { + id: String(h.id || h.name), + name: String(h.name || ''), + description: String(h.description || ''), + status, + requirements_met: Boolean(h.requirements_met), + category: h.category as string | undefined, + icon: h.icon as string | undefined, + toolCount: (h.tool_count as number) || ((h.tools as unknown[])?.length), + metricCount: (h.metric_count as number) || ((h.metrics as unknown[])?.length), + }; + }); + set({ hands, isLoading: false }); + } catch { + set({ isLoading: false }); + } + }, + + getHandDetails: async (name: string) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.getHand(name); + if (!result) return undefined; + + const getStringFromConfig = (key: string): string | undefined => { + const val = (result.config as Record)?.[key]; + return typeof val === 'string' ? val : undefined; + }; + const getArrayFromConfig = (key: string): string[] | undefined => { + const val = (result.config as Record)?.[key]; + return Array.isArray(val) ? val : undefined; + }; + + const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const; + const status = validStatuses.includes(result.status as Hand['status']) + ? result.status as Hand['status'] + : (result.requirements_met ? 'idle' : 'setup_needed'); + + const hand: Hand = { + id: String(result.id || result.name || name), + name: String(result.name || name), + description: String(result.description || ''), + status, + requirements_met: Boolean(result.requirements_met), + category: result.category as string | undefined, + icon: result.icon as string | undefined, + provider: (result.provider as string) || getStringFromConfig('provider'), + model: (result.model as string) || getStringFromConfig('model'), + requirements: ((result.requirements as RawHandRequirement[]) || []).map((r) => ({ + description: r.description || r.name || String(r), + met: r.met ?? r.satisfied ?? true, + details: r.details || r.hint, + })), + tools: (result.tools as string[]) || getArrayFromConfig('tools'), + metrics: (result.metrics as string[]) || getArrayFromConfig('metrics'), + toolCount: (result.tool_count as number) || ((result.tools as unknown[])?.length) || 0, + metricCount: (result.metric_count as number) || ((result.metrics as unknown[])?.length) || 0, + }; + + set(state => ({ + hands: state.hands.map(h => h.name === name ? { ...h, ...hand } : h), + })); + + return hand; + } catch { + return undefined; + } + }, + + loadHandRuns: async (name: string, opts?: { limit?: number; offset?: number }) => { + const client = get().client; + if (!client) return []; + + try { + const result = await client.listHandRuns(name, opts); + const runs: HandRun[] = (result?.runs || []).map((r: RawHandRun) => ({ + runId: r.runId || r.run_id || r.id || '', + status: r.status || 'unknown', + startedAt: r.startedAt || r.started_at || r.created_at || new Date().toISOString(), + completedAt: r.completedAt || r.completed_at || r.finished_at, + result: r.result || r.output, + error: r.error || r.message, + })); + set(state => ({ + handRuns: { ...state.handRuns, [name]: runs }, + })); + return runs; + } catch { + return []; + } + }, + + triggerHand: async (name: string, params?: Record) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.triggerHand(name, params); + if (!result) return undefined; + + const run: HandRun = { + runId: result.runId || '', + status: result.status || 'running', + startedAt: new Date().toISOString(), + }; + + // Add run to local state + set(state => ({ + handRuns: { + ...state.handRuns, + [name]: [run, ...(state.handRuns[name] || [])], + }, + })); + + // Refresh hands to update status + await get().loadHands(); + + return run; + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => { + const client = get().client; + if (!client) return; + + try { + await client.approveHand(name, runId, approved, reason); + await get().loadHands(); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + throw err; + } + }, + + cancelHand: async (name: string, runId: string) => { + const client = get().client; + if (!client) return; + + try { + await client.cancelHand(name, runId); + await get().loadHands(); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + throw err; + } + }, + + // === Trigger Actions === + + loadTriggers: async () => { + const client = get().client; + if (!client) return; + + try { + const result = await client.listTriggers(); + set({ triggers: result?.triggers || [] }); + } catch { + // ignore if triggers API not available + } + }, + + getTrigger: async (id: string) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.getTrigger(id); + if (!result) return undefined; + return { + id: result.id, + type: result.type, + enabled: result.enabled, + } as Trigger; + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + createTrigger: async (trigger) => { + const client = get().client; + if (!client) return undefined; + + try { + const result = await client.createTrigger(trigger); + if (!result?.id) return undefined; + await get().loadTriggers(); + return get().triggers.find(t => t.id === result.id); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + updateTrigger: async (id: string, updates) => { + const client = get().client; + if (!client) return undefined; + + try { + await client.updateTrigger(id, updates); + set(state => ({ + triggers: state.triggers.map(t => + t.id === id ? { ...t, ...updates } : t + ), + })); + return get().triggers.find(t => t.id === id); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + return undefined; + } + }, + + deleteTrigger: async (id: string) => { + const client = get().client; + if (!client) return; + + try { + await client.deleteTrigger(id); + set(state => ({ + triggers: state.triggers.filter(t => t.id !== id), + })); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + throw err; + } + }, + + // === Approval Actions === + + loadApprovals: async (status?: ApprovalStatus) => { + const client = get().client; + if (!client) return; + + try { + const result = await client.listApprovals(status); + const approvals: Approval[] = (result?.approvals || []).map((a: RawApproval) => ({ + id: a.id || a.approval_id || a.approvalId || '', + handName: a.hand_name || a.handName || '', + runId: a.run_id || a.runId, + status: (a.status || 'pending') as ApprovalStatus, + requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(), + requestedBy: a.requested_by || a.requester, + reason: a.reason || a.description, + action: a.action || 'execute', + params: a.params, + respondedAt: a.responded_at || a.respondedAt, + respondedBy: a.responded_by || a.respondedBy, + responseReason: a.response_reason || a.responseReason, + })); + set({ approvals }); + } catch { + // ignore if approvals API not available + } + }, + + respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => { + const client = get().client; + if (!client) return; + + try { + await client.respondToApproval(approvalId, approved, reason); + await get().loadApprovals(); + } catch (err: unknown) { + set({ error: err instanceof Error ? err.message : String(err) }); + throw err; + } + }, + + clearError: () => set({ error: null }), +})); + +/** + * Helper to create a HandClient adapter from a GatewayClient. + * Use this to inject the client into handStore. + */ +export function createHandClientFromGateway(client: GatewayClient): HandClient { + return { + listHands: () => client.listHands(), + getHand: (name) => client.getHand(name), + listHandRuns: (name, opts) => client.listHandRuns(name, opts), + triggerHand: (name, params) => client.triggerHand(name, params), + approveHand: (name, runId, approved, reason) => client.approveHand(name, runId, approved, reason), + cancelHand: (name, runId) => client.cancelHand(name, runId), + listTriggers: () => client.listTriggers(), + getTrigger: (id) => client.getTrigger(id), + createTrigger: (trigger) => client.createTrigger(trigger), + updateTrigger: (id, updates) => client.updateTrigger(id, updates), + deleteTrigger: (id) => client.deleteTrigger(id), + listApprovals: (status) => client.listApprovals(status), + respondToApproval: (approvalId, approved, reason) => client.respondToApproval(approvalId, approved, reason), + }; +} diff --git a/desktop/src/store/workflowStore.ts b/desktop/src/store/workflowStore.ts new file mode 100644 index 0000000..ee3fc4c --- /dev/null +++ b/desktop/src/store/workflowStore.ts @@ -0,0 +1,255 @@ +import { create } from 'zustand'; +import { Workflow, WorkflowRun } from './gatewayStore'; + +// === Types === + +interface RawWorkflowRun { + runId?: string; + run_id?: string; + id?: string; + workflowId?: string; + workflow_id?: string; + status?: string; + startedAt?: string; + started_at?: string; + completedAt?: string; + completed_at?: string; + currentStep?: number; + current_step?: number; + totalSteps?: number; + total_steps?: number; + error?: string; + result?: unknown; + step?: string; +} + +export interface WorkflowStep { + handName: string; + name?: string; + params?: Record; + condition?: string; +} + +export interface CreateWorkflowInput { + name: string; + description?: string; + steps: WorkflowStep[]; +} + +export interface UpdateWorkflowInput { + name?: string; + description?: string; + steps?: WorkflowStep[]; +} + +// Extended WorkflowRun with additional fields from API +export interface ExtendedWorkflowRun extends WorkflowRun { + startedAt?: string; + completedAt?: string; + error?: string; +} + +// === Client Interface === + +interface WorkflowClient { + listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number; description?: string; createdAt?: string }[] } | null>; + createWorkflow(workflow: CreateWorkflowInput): Promise<{ id: string; name: string } | null>; + updateWorkflow(id: string, updates: UpdateWorkflowInput): Promise<{ id: string; name: string } | null>; + deleteWorkflow(id: string): Promise<{ status: string }>; + executeWorkflow(id: string, input?: Record): Promise<{ runId: string; status: string } | null>; + cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }>; + listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: RawWorkflowRun[] } | null>; +} + +// === Store State === + +interface WorkflowState { + workflows: Workflow[]; + workflowRuns: Record; + isLoading: boolean; + error: string | null; + client: WorkflowClient; +} + +// === Store Actions === + +interface WorkflowActions { + setWorkflowStoreClient: (client: WorkflowClient) => void; + loadWorkflows: () => Promise; + getWorkflow: (id: string) => Workflow | undefined; + createWorkflow: (workflow: CreateWorkflowInput) => Promise; + updateWorkflow: (id: string, updates: UpdateWorkflowInput) => Promise; + deleteWorkflow: (id: string) => Promise; + triggerWorkflow: (id: string, input?: Record) => Promise<{ runId: string; status: string } | undefined>; + cancelWorkflow: (id: string, runId: string) => Promise; + loadWorkflowRuns: (workflowId: string, opts?: { limit?: number; offset?: number }) => Promise; + clearError: () => void; + reset: () => void; +} + +// === Initial State === + +const initialState = { + workflows: [], + workflowRuns: {}, + isLoading: false, + error: null, + client: null as unknown as WorkflowClient, +}; + +// === Store === + +export const useWorkflowStore = create((set, get) => ({ + ...initialState, + + setWorkflowStoreClient: (client: WorkflowClient) => { + set({ client }); + }, + + loadWorkflows: async () => { + set({ isLoading: true, error: null }); + try { + const result = await get().client.listWorkflows(); + const workflows: Workflow[] = (result?.workflows || []).map(w => ({ + id: w.id, + name: w.name, + steps: w.steps, + description: w.description, + createdAt: w.createdAt, + })); + set({ workflows, isLoading: false }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load workflows'; + set({ error: message, isLoading: false }); + } + }, + + getWorkflow: (id: string) => { + return get().workflows.find(w => w.id === id); + }, + + createWorkflow: async (workflow: CreateWorkflowInput) => { + set({ error: null }); + try { + const result = await get().client.createWorkflow(workflow); + if (result) { + const newWorkflow: Workflow = { + id: result.id, + name: result.name, + steps: workflow.steps.length, + description: workflow.description, + }; + set(state => ({ workflows: [...state.workflows, newWorkflow] })); + return newWorkflow; + } + return undefined; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create workflow'; + set({ error: message }); + return undefined; + } + }, + + updateWorkflow: async (id: string, updates: UpdateWorkflowInput) => { + set({ error: null }); + try { + const result = await get().client.updateWorkflow(id, updates); + if (result) { + set(state => ({ + workflows: state.workflows.map(w => + w.id === id + ? { + ...w, + name: updates.name ?? w.name, + description: updates.description ?? w.description, + steps: updates.steps?.length ?? w.steps, + } + : w + ), + })); + return get().workflows.find(w => w.id === id); + } + return undefined; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to update workflow'; + set({ error: message }); + return undefined; + } + }, + + deleteWorkflow: async (id: string) => { + set({ error: null }); + try { + await get().client.deleteWorkflow(id); + set(state => ({ + workflows: state.workflows.filter(w => w.id !== id), + workflowRuns: (() => { + const { [id]: _, ...rest } = state.workflowRuns; + return rest; + })(), + })); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to delete workflow'; + set({ error: message }); + throw err; + } + }, + + triggerWorkflow: async (id: string, input?: Record) => { + set({ error: null }); + try { + const result = await get().client.executeWorkflow(id, input); + return result ? { runId: result.runId, status: result.status } : undefined; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to trigger workflow'; + set({ error: message }); + return undefined; + } + }, + + cancelWorkflow: async (id: string, runId: string) => { + set({ error: null }); + try { + await get().client.cancelWorkflow(id, runId); + // Refresh workflows to update status + await get().loadWorkflows(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to cancel workflow'; + set({ error: message }); + throw err; + } + }, + + loadWorkflowRuns: async (workflowId: string, opts?: { limit?: number; offset?: number }) => { + try { + const result = await get().client.listWorkflowRuns(workflowId, opts); + const runs: ExtendedWorkflowRun[] = (result?.runs || []).map((r: RawWorkflowRun) => ({ + runId: r.runId || r.run_id || r.id || '', + status: r.status || 'unknown', + startedAt: r.startedAt || r.started_at, + completedAt: r.completedAt || r.completed_at, + step: r.currentStep?.toString() || r.current_step?.toString() || r.step, + result: r.result, + error: r.error, + })); + // Store runs by workflow ID + set(state => ({ + workflowRuns: { ...state.workflowRuns, [workflowId]: runs }, + })); + return runs; + } catch { + return []; + } + }, + + clearError: () => { + set({ error: null }); + }, + + reset: () => { + set(initialState); + }, +})); + +// Re-export types from gatewayStore for convenience +export type { Workflow, WorkflowRun }; diff --git a/docs/SYSTEM_ANALYSIS.md b/docs/SYSTEM_ANALYSIS.md index 3611a5b..a425c4b 100644 --- a/docs/SYSTEM_ANALYSIS.md +++ b/docs/SYSTEM_ANALYSIS.md @@ -560,4 +560,19 @@ ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面客户端,核 * ✅ chatStore 测试通过 (11/11) * ✅ gatewayStore 测试通过 (17/17) -*下一步: Phase 11 Store 重构* +*Phase 11 进行中 🔄 (2026-03-15)* - Store 重构分解 + * 新 Store 文件: + * ✅ `connectionStore.ts` (444 行) - WebSocket 连接、认证、本地 Gateway + * ✅ `agentStore.ts` (256 行) - Clones、使用统计、插件状态 + * ✅ `handStore.ts` (498 行) - Hands、Triggers、Approvals + * ✅ `workflowStore.ts` (255 行) - Workflows、WorkflowRuns + * ✅ `configStore.ts` (537 行) - QuickConfig、Channels、Skills、Models + * Store 行数: gatewayStore 1660 → 5 个子 Store (平均 358 行) + * 待完成: + * 🔄 创建协调层 (coordinator) + * 🔄 更新组件导入 + * 代码质量: + * ✅ TypeScript 类型检查通过 + +*下一步: Phase 11 协调层创建* +