/** * handStore.ts - Hand, Trigger, and Approval management store * * Extracted from gatewayStore.ts for Phase 11 Store Refactoring. * Manages ZCLAW Hands, Triggers, and Approval workflows. */ import { create } from 'zustand'; import type { GatewayClient } from '../lib/gateway-client'; import { canAutoExecute, getAutonomyManager } from '../lib/autonomy-manager'; import type { AutonomyDecision } from '../lib/autonomy-manager'; import { saasClient } from '../lib/saas-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; } // === Trigger Create Options === export interface TriggerCreateOptions { type: string; name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: 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, autonomyLevel?: string) => Promise<{ runId?: string; status?: string } | null>; approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<{ status: string }>; cancelHand: (name: string, runId: string) => Promise<{ status: string }>; 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<{ id: string }>; deleteTrigger: (id: string) => Promise<{ status: string }>; listApprovals: (status?: ApprovalStatus) => Promise<{ approvals?: RawApproval[] } | null>; respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<{ status: string }>; } // === Store State Slice === export interface HandStateSlice { hands: Hand[]; handRuns: Record; triggers: Trigger[]; approvals: Approval[]; isLoading: boolean; error: string | null; client: HandClient | null; /** Latest autonomy decision (set when action requires approval) */ autonomyDecision: AutonomyDecision | null; } // === Store Actions Slice === export interface HandActionsSlice { setHandStoreClient: (client: HandClient) => void; loadHands: () => Promise; getHandDetails: (name: string) => Promise; triggerHand: (name: string, params?: Record, autonomyLevel?: string) => 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: TriggerCreateOptions) => 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; clearAutonomyDecision: () => void; } // === Combined Store Type === export type HandStore = HandStateSlice & HandActionsSlice; export const useHandStore = create((set, get) => ({ // Initial State hands: [], handRuns: {}, triggers: [], approvals: [], isLoading: false, error: null, autonomyDecision: 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 (err) { console.error('[HandStore] loadHands error:', err); 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; // Autonomy check before executing hand const { canProceed, decision } = canAutoExecute('hand_trigger', 5); if (!canProceed) { // Store decision for UI to display approval prompt set(() => ({ autonomyDecision: decision, })); return undefined; } // Pass current autonomy level to backend for defense-in-depth enforcement const autonomyLevel = getAutonomyManager().getConfig().level; try { const result = await client.triggerHand(name, params, autonomyLevel); 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(); // Report hand execution to billing (fire-and-forget, non-blocking) try { if (saasClient.isAuthenticated()) { saasClient.reportUsageFireAndForget('hand_executions'); } } catch { /* billing reporting must never block */ } 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: TriggerCreateOptions) => { 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 }), clearAutonomyDecision: () => set({ autonomyDecision: 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), }; } // === Kernel Client Adapter === import type { KernelClient } from '../lib/kernel-client'; /** * Helper to create a HandClient adapter from a KernelClient. * Maps KernelClient methods (Tauri invoke) to the HandClient interface. */ function createHandClientFromKernel(client: KernelClient): HandClient { return { listHands: async () => { try { const result = await client.listHands(); // KernelClient returns typed objects; cast to Record for HandClient compatibility const hands: Array> = result.hands.map((h) => ({ id: h.id || h.name, name: h.name, description: h.description, status: h.status, requirements_met: h.requirements_met, category: h.category, icon: h.icon, tool_count: h.tool_count, tools: h.tools, metric_count: h.metric_count, metrics: h.metrics, })); return { hands }; } catch { return null; } }, getHand: async (name: string) => { try { const result = await client.getHand(name); return result as Record || null; } catch { return null; } }, listHandRuns: async (name: string, opts) => { try { const result = await client.listHandRuns(name, opts); return result as unknown as { runs?: RawHandRun[] } | null; } catch { return null; } }, triggerHand: async (name: string, params) => { try { const result = await client.triggerHand(name, params); return { runId: result.runId, status: result.status }; } catch { return null; } }, approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => { return client.approveHand(name, runId, approved, reason); }, cancelHand: async (name: string, runId: string) => { return client.cancelHand(name, runId); }, listTriggers: async () => { try { const result = await client.listTriggers(); if (!result?.triggers) return { triggers: [] }; // Map KernelClient trigger shape to HandClient Trigger shape const triggers: Trigger[] = result.triggers.map((t) => ({ id: t.id, type: t.triggerType, enabled: t.enabled, })); return { triggers }; } catch { return { triggers: [] }; } }, getTrigger: async (id: string) => { try { const result = await client.getTrigger(id); if (!result) return null; return { id: result.id, type: result.triggerType, enabled: result.enabled, } as Trigger; } catch { return null; } }, createTrigger: async (trigger) => { try { const result = await client.createTrigger({ id: `${trigger.type}_${Date.now()}`, name: trigger.name || trigger.type, handId: trigger.handName || '', triggerType: { type: trigger.type }, enabled: trigger.enabled, description: trigger.config ? JSON.stringify(trigger.config) : undefined, }); return result ? { id: result.id } : null; } catch { return null; } }, updateTrigger: async (id: string, updates) => { const result = await client.updateTrigger(id, { name: updates.name, enabled: updates.enabled, handId: updates.handName, triggerType: updates.config ? { type: (updates.config as Record).type as string } : undefined, }); return { id: result.id }; }, deleteTrigger: async (id: string) => { await client.deleteTrigger(id); return { status: 'deleted' }; }, listApprovals: async () => { try { const result = await client.listApprovals(); // Map KernelClient approval shape to HandClient RawApproval shape const approvals: RawApproval[] = (result?.approvals || []).map((a) => ({ id: a.id, hand_id: a.handId, status: a.status, requestedAt: a.createdAt, })); return { approvals }; } catch { return { approvals: [] }; } }, respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => { await client.respondToApproval(approvalId, approved, reason); return { status: approved ? 'approved' : 'rejected' }; }, }; } // === Client Injection === /** * Sets the client for the hand store. * Called by the coordinator during initialization. * Detects whether the client is a KernelClient (Tauri) or GatewayClient (browser). */ export function setHandStoreClient(client: unknown): void { let handClient: HandClient; // Check if it's a KernelClient (has listHands method that returns typed objects) if (client && typeof client === 'object' && 'listHands' in client) { handClient = createHandClientFromKernel(client as KernelClient); } else if (client && typeof client === 'object') { // It's a GatewayClient handClient = createHandClientFromGateway(client as GatewayClient); } else { // Fallback: return a stub client that gracefully handles all calls handClient = { listHands: async () => null, getHand: async () => null, listHandRuns: async () => null, triggerHand: async () => null, approveHand: async () => ({ status: 'error' }), cancelHand: async () => ({ status: 'error' }), listTriggers: async () => ({ triggers: [] }), getTrigger: async () => null, createTrigger: async () => null, updateTrigger: async () => ({ id: '' }), deleteTrigger: async () => ({ status: 'error' }), listApprovals: async () => ({ approvals: [] }), respondToApproval: async () => ({ status: 'error' }), }; } useHandStore.getState().setHandStoreClient(handClient); }