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
535 lines
17 KiB
TypeScript
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);
|
|
}
|