Files
zclaw_openfang/desktop/src/store/handStore.ts
iven 6f72442531 docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
Major changes:
- Shift from "OpenFang desktop client" to "independent AI Agent desktop app"
- Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?"
- Simplify project structure and tech stack sections
- Replace OpenClaw vs OpenFang comparison with unified backend approach
- Consolidate troubleshooting from scattered sections into organized FAQ
- Update Hands system documentation with 8 capabilities and status
- Stream
2026-03-20 19:30:09 +08:00

535 lines
17 KiB
TypeScript

/**
* 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<string, unknown>;
respondedAt?: string;
respondedBy?: string;
responseReason?: string;
}
// === Trigger Create Options ===
export interface TriggerCreateOptions {
type: string;
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
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<string, unknown>;
responded_at?: string;
respondedAt?: string;
responded_by?: string;
respondedBy?: string;
response_reason?: string;
responseReason?: string;
}
// === Store Interface ===
interface HandClient {
listHands: () => Promise<{ hands?: Array<Record<string, unknown>> } | null>;
getHand: (name: string) => Promise<Record<string, unknown> | null>;
listHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<{ runs?: RawHandRun[] } | null>;
triggerHand: (name: string, params?: Record<string, unknown>) => 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<Trigger | null>;
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<{ id?: string } | null>;
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; 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<string, HandRun[]>;
triggers: Trigger[];
approvals: Approval[];
isLoading: boolean;
error: string | null;
client: HandClient | null;
}
// === Store Actions Slice ===
export interface HandActionsSlice {
setHandStoreClient: (client: HandClient) => void;
loadHands: () => Promise<void>;
getHandDetails: (name: string) => Promise<Hand | undefined>;
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
loadHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<HandRun[]>;
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: TriggerCreateOptions) => 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>;
clearError: () => void;
}
// === Combined Store Type ===
export type HandStore = HandStateSlice & HandActionsSlice;
export const useHandStore = create<HandStore>((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;
console.log('[HandStore] loadHands called, client:', !!client);
if (!client) {
console.warn('[HandStore] No client available, skipping loadHands');
return;
}
set({ isLoading: true });
try {
console.log('[HandStore] Calling client.listHands()...');
const result = await client.listHands();
console.log('[HandStore] listHands result:', result);
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
const hands: Hand[] = (result?.hands || []).map((h: Record<string, unknown>) => {
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),
};
});
console.log('[HandStore] Mapped hands:', hands.length, 'items');
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<string, unknown>)?.[key];
return typeof val === 'string' ? val : undefined;
};
const getArrayFromConfig = (key: string): string[] | undefined => {
const val = (result.config as Record<string, unknown>)?.[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<string, unknown>) => {
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: 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 }),
}));
/**
* 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),
};
}
// === Client Injection ===
/**
* Sets the client for the hand store.
* Called by the coordinator during initialization.
*/
export function setHandStoreClient(client: unknown): void {
const handClient = createHandClientFromGateway(client as GatewayClient);
useHandStore.getState().setHandStoreClient(handClient);
}