Files
zclaw_openfang/desktop/src/store/gatewayStore.ts
iven 185763868a feat: production readiness improvements
## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 00:03:22 +08:00

358 lines
16 KiB
TypeScript

/**
* gatewayStore.ts - Backward-Compatible Facade
*
* This file was the original monolithic store (1800+ lines).
* It is now a thin facade that re-exports types and provides
* a composite useGatewayStore hook from the domain-specific stores:
*
* connectionStore.ts - Connection, local gateway management
* agentStore.ts - Clones, usage stats, plugins
* handStore.ts - Hands, triggers, approvals
* workflowStore.ts - Workflows, workflow runs
* configStore.ts - Config, channels, skills, models, workspace
* securityStore.ts - Security status, audit logs
* sessionStore.ts - Sessions, session messages
*
* Components should gradually migrate to import from the specific stores.
* This facade exists only for backward compatibility.
*/
import { useConnectionStore } from './connectionStore';
import { useAgentStore } from './agentStore';
import { useHandStore } from './handStore';
import { useWorkflowStore } from './workflowStore';
import { useConfigStore } from './configStore';
import { useSecurityStore } from './securityStore';
import { useSessionStore } from './sessionStore';
import { useChatStore } from './chatStore';
import type { GatewayClient, ConnectionState } from '../lib/gateway-client';
import type { GatewayModelChoice } from '../lib/gateway-config';
import type { LocalGatewayStatus } from '../lib/tauri-gateway';
import type { Hand, HandRun, Trigger, Approval, ApprovalStatus } from './handStore';
import type { Workflow, WorkflowRun } from './workflowStore';
import type { Clone, PluginStatus, UsageStats } from './agentStore';
import type { QuickConfig, ChannelInfo, ScheduledTask, SkillInfo, WorkspaceInfo } from './configStore';
import type { SecurityStatus, AuditLogEntry } from './securityStore';
import type { Session, SessionMessage } from './sessionStore';
import type { GatewayLog } from './connectionStore';
// === Re-export Types from Domain Stores ===
// These re-exports maintain backward compatibility for all 34+ consumer files.
export type { Hand, HandRun, HandRequirement, Trigger, Approval, ApprovalStatus } from './handStore';
export type { Workflow, WorkflowRun } from './workflowStore';
export type { Clone, UsageStats, PluginStatus } from './agentStore';
export type { QuickConfig, WorkspaceInfo, ChannelInfo, ScheduledTask, SkillInfo } from './configStore';
export type { SecurityLayer, SecurityStatus, AuditLogEntry } from './securityStore';
export type { Session, SessionMessage } from './sessionStore';
export type { GatewayLog } from './connectionStore';
// === Composite useGatewayStore Hook ===
// Provides a single store interface that delegates to all domain stores.
// Components should gradually migrate to import from the specific stores.
/**
* Composite gateway store hook.
*
* Reads state from all domain stores and delegates actions.
* This is a React hook (not a Zustand store) — it subscribes to
* all underlying stores and returns a unified interface.
*
* @deprecated Components should migrate to use domain-specific stores directly:
* useConnectionStore, useAgentStore, useHandStore, useWorkflowStore,
* useConfigStore, useSecurityStore, useSessionStore
*/
export function useGatewayStore(): GatewayFacade;
export function useGatewayStore<T>(selector: (state: GatewayFacade) => T): T;
export function useGatewayStore<T>(selector?: (state: GatewayFacade) => T): T | GatewayFacade {
// Subscribe to all stores (React will re-render when any changes)
const conn = useConnectionStore();
const agent = useAgentStore();
const hand = useHandStore();
const workflow = useWorkflowStore();
const config = useConfigStore();
const security = useSecurityStore();
const session = useSessionStore();
const facade: GatewayFacade = {
// === Connection State ===
connectionState: conn.connectionState,
gatewayVersion: conn.gatewayVersion,
error: conn.error || agent.error || hand.error || workflow.error || config.error || session.error || security.securityStatusError,
logs: conn.logs,
localGateway: conn.localGateway,
localGatewayBusy: conn.localGatewayBusy,
isLoading: conn.isLoading || agent.isLoading || hand.isLoading || workflow.isLoading,
client: conn.client,
// === Agent State ===
clones: agent.clones,
usageStats: agent.usageStats,
pluginStatus: agent.pluginStatus,
// === Hand State ===
hands: hand.hands,
handRuns: hand.handRuns,
triggers: hand.triggers,
approvals: hand.approvals,
// === Workflow State ===
workflows: workflow.workflows,
workflowRuns: workflow.workflowRuns as Record<string, WorkflowRun[]>,
// === Config State ===
quickConfig: config.quickConfig,
workspaceInfo: config.workspaceInfo,
channels: config.channels,
scheduledTasks: config.scheduledTasks,
skillsCatalog: config.skillsCatalog,
models: config.models,
modelsLoading: config.modelsLoading,
modelsError: config.modelsError,
// === Security State ===
securityStatus: security.securityStatus,
securityStatusLoading: security.securityStatusLoading,
securityStatusError: security.securityStatusError,
auditLogs: security.auditLogs,
// === Session State ===
sessions: session.sessions,
sessionMessages: session.sessionMessages,
// === Connection Actions ===
connect: async (url?: string, token?: string) => {
await conn.connect(url, token);
// Post-connect: load all data from domain stores
await Promise.allSettled([
config.loadQuickConfig(),
config.loadWorkspaceInfo(),
agent.loadClones().then(() => {
// Sync agents to chat store after loading (use getState for latest)
useChatStore.getState().syncAgents(useAgentStore.getState().clones);
}),
agent.loadUsageStats(),
agent.loadPluginStatus(),
config.loadScheduledTasks(),
config.loadSkillsCatalog(),
hand.loadHands(),
workflow.loadWorkflows(),
hand.loadTriggers(),
security.loadSecurityStatus(),
config.loadModels(),
]);
await config.loadChannels();
},
disconnect: conn.disconnect,
clearLogs: conn.clearLogs,
refreshLocalGateway: conn.refreshLocalGateway,
startLocalGateway: conn.startLocalGateway,
stopLocalGateway: conn.stopLocalGateway,
restartLocalGateway: conn.restartLocalGateway,
// === Agent Actions ===
loadClones: agent.loadClones,
createClone: agent.createClone as GatewayFacade['createClone'],
updateClone: agent.updateClone as GatewayFacade['updateClone'],
deleteClone: agent.deleteClone,
loadUsageStats: agent.loadUsageStats,
loadPluginStatus: agent.loadPluginStatus,
// === Hand Actions ===
loadHands: hand.loadHands,
getHandDetails: hand.getHandDetails,
triggerHand: hand.triggerHand,
loadHandRuns: hand.loadHandRuns,
approveHand: hand.approveHand,
cancelHand: hand.cancelHand,
loadTriggers: hand.loadTriggers,
getTrigger: hand.getTrigger,
createTrigger: hand.createTrigger as GatewayFacade['createTrigger'],
updateTrigger: hand.updateTrigger,
deleteTrigger: hand.deleteTrigger,
loadApprovals: hand.loadApprovals,
respondToApproval: hand.respondToApproval,
// === Workflow Actions ===
loadWorkflows: workflow.loadWorkflows,
createWorkflow: workflow.createWorkflow as GatewayFacade['createWorkflow'],
updateWorkflow: workflow.updateWorkflow as GatewayFacade['updateWorkflow'],
deleteWorkflow: workflow.deleteWorkflow,
executeWorkflow: workflow.triggerWorkflow as GatewayFacade['executeWorkflow'],
cancelWorkflow: workflow.cancelWorkflow,
loadWorkflowRuns: workflow.loadWorkflowRuns as GatewayFacade['loadWorkflowRuns'],
// === Config Actions ===
loadQuickConfig: config.loadQuickConfig,
saveQuickConfig: config.saveQuickConfig,
loadWorkspaceInfo: config.loadWorkspaceInfo,
loadChannels: config.loadChannels,
getChannel: config.getChannel,
createChannel: config.createChannel,
updateChannel: config.updateChannel,
deleteChannel: config.deleteChannel,
loadScheduledTasks: config.loadScheduledTasks,
createScheduledTask: config.createScheduledTask,
loadSkillsCatalog: config.loadSkillsCatalog,
getSkill: config.getSkill,
createSkill: config.createSkill,
updateSkill: config.updateSkill,
deleteSkill: config.deleteSkill,
loadModels: config.loadModels,
// === Security Actions ===
loadSecurityStatus: security.loadSecurityStatus,
loadAuditLogs: security.loadAuditLogs,
// === Session Actions ===
loadSessions: session.loadSessions,
getSession: session.getSession,
createSession: session.createSession,
deleteSession: session.deleteSession,
loadSessionMessages: session.loadSessionMessages,
// === Legacy ===
sendMessage: async (message: string, sessionKey?: string) => {
return conn.client.chat(message, { sessionKey });
},
};
if (selector) {
return selector(facade);
}
return facade;
}
// === Facade Interface (matches the old GatewayStore shape) ===
interface GatewayFacade {
// Connection state
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
localGateway: LocalGatewayStatus;
localGatewayBusy: boolean;
isLoading: boolean;
client: GatewayClient;
// Data
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: PluginStatus[];
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
skillsCatalog: SkillInfo[];
quickConfig: QuickConfig;
workspaceInfo: WorkspaceInfo | null;
models: GatewayModelChoice[];
modelsLoading: boolean;
modelsError: string | null;
// OpenFang Data
hands: Hand[];
handRuns: Record<string, HandRun[]>;
workflows: Workflow[];
triggers: Trigger[];
auditLogs: AuditLogEntry[];
securityStatus: SecurityStatus | null;
securityStatusLoading: boolean;
securityStatusError: string | null;
approvals: Approval[];
sessions: Session[];
sessionMessages: Record<string, SessionMessage[]>;
workflowRuns: Record<string, WorkflowRun[]>;
// Connection Actions
connect: (url?: string, token?: string) => Promise<void>;
disconnect: () => void;
clearLogs: () => void;
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
// Agent Actions
loadClones: () => Promise<void>;
createClone: (opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string; workspaceDir?: string; restrictFiles?: boolean; privacyOptIn?: boolean; userName?: string; userRole?: string }) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
// Hand Actions
loadHands: () => Promise<void>;
getHandDetails: (name: string) => Promise<Hand | undefined>;
loadHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<HandRun[]>;
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
cancelHand: (name: string, runId: string) => Promise<void>;
loadTriggers: () => Promise<void>;
getTrigger: (id: string) => Promise<Trigger | undefined>;
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
deleteTrigger: (id: string) => Promise<void>;
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
// Workflow Actions
loadWorkflows: () => Promise<void>;
createWorkflow: (workflow: { name: string; description?: string; steps: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> }) => Promise<Workflow | undefined>;
updateWorkflow: (id: string, updates: { name?: string; description?: string; steps?: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> }) => Promise<Workflow | undefined>;
deleteWorkflow: (id: string) => Promise<void>;
executeWorkflow: (id: string, input?: Record<string, unknown>) => Promise<WorkflowRun | undefined>;
cancelWorkflow: (id: string, runId: string) => Promise<void>;
loadWorkflowRuns: (workflowId: string, opts?: { limit?: number; offset?: number }) => Promise<WorkflowRun[]>;
// Config Actions
loadQuickConfig: () => Promise<void>;
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
loadWorkspaceInfo: () => Promise<void>;
loadChannels: () => Promise<void>;
getChannel: (id: string) => Promise<ChannelInfo | undefined>;
createChannel: (channel: { type: string; name: string; config: Record<string, unknown>; enabled?: boolean }) => Promise<ChannelInfo | undefined>;
updateChannel: (id: string, updates: { name?: string; config?: Record<string, unknown>; enabled?: boolean }) => Promise<ChannelInfo | undefined>;
deleteChannel: (id: string) => Promise<void>;
loadScheduledTasks: () => Promise<void>;
createScheduledTask: (task: { name: string; schedule: string; scheduleType: 'cron' | 'interval' | 'once'; target?: { type: 'agent' | 'hand' | 'workflow'; id: string }; description?: string; enabled?: boolean }) => Promise<ScheduledTask | undefined>;
loadSkillsCatalog: () => Promise<void>;
getSkill: (id: string) => Promise<SkillInfo | undefined>;
createSkill: (skill: { name: string; description?: string; triggers: Array<{ type: string; pattern?: string }>; actions: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }) => Promise<SkillInfo | undefined>;
updateSkill: (id: string, updates: { name?: string; description?: string; triggers?: Array<{ type: string; pattern?: string }>; actions?: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }) => Promise<SkillInfo | undefined>;
deleteSkill: (id: string) => Promise<void>;
loadModels: () => Promise<void>;
// Security Actions
loadSecurityStatus: () => Promise<void>;
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
// Session Actions
loadSessions: (opts?: { limit?: number; offset?: number }) => Promise<void>;
getSession: (sessionId: string) => Promise<Session | undefined>;
createSession: (agentId: string, metadata?: Record<string, unknown>) => Promise<Session | undefined>;
deleteSession: (sessionId: string) => Promise<void>;
loadSessionMessages: (sessionId: string, opts?: { limit?: number; offset?: number }) => Promise<SessionMessage[]>;
// Legacy
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
}
// Dev-only: Expose stores to window for E2E testing
if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
(window as any).__ZCLAW_STORES__.gateway = useGatewayStore;
(window as any).__ZCLAW_STORES__.connection = useConnectionStore;
(window as any).__ZCLAW_STORES__.agent = useAgentStore;
(window as any).__ZCLAW_STORES__.hand = useHandStore;
(window as any).__ZCLAW_STORES__.workflow = useWorkflowStore;
(window as any).__ZCLAW_STORES__.config = useConfigStore;
(window as any).__ZCLAW_STORES__.security = useSecurityStore;
(window as any).__ZCLAW_STORES__.session = useSessionStore;
// Dynamically import chatStore to avoid circular dependency
import('./chatStore').then(({ useChatStore }) => {
(window as any).__ZCLAW_STORES__.chat = useChatStore;
}).catch(() => {
// Ignore if chatStore is not available
});
}