## 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>
358 lines
16 KiB
TypeScript
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
|
|
});
|
|
}
|
|
|