Files
zclaw_openfang/desktop/src/store/handStore.ts
iven 837abec48a feat(billing): add usage increment API + wire hand/pipeline execution tracking
Server side:
- POST /api/v1/billing/usage/increment endpoint with dimension whitelist
  (hand_executions, pipeline_runs, relay_requests) and count validation (1-100)
- Returns updated usage quota after increment

Desktop side:
- New saas-billing.ts mixin with incrementUsageDimension() and
  reportUsageFireAndForget() (non-blocking, safe for finally blocks)
- handStore.triggerHand: reports hand_executions after successful run
- PipelinesPanel.handleRunComplete: reports pipeline_runs on completion
- SaaSClient type declarations for new billing methods

Billing pipeline now covers all three dimensions:
  relay_requests  → relay handler (server-side, real-time)
  hand_executions → handStore (client-side, fire-and-forget)
  pipeline_runs   → PipelinesPanel (client-side, fire-and-forget)
2026-04-02 02:02:59 +08:00

723 lines
23 KiB
TypeScript

/**
* 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<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>, 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<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;
/** Latest autonomy decision (set when action requires approval) */
autonomyDecision: AutonomyDecision | 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>, autonomyLevel?: string) => 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;
clearAutonomyDecision: () => 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,
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<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),
};
});
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;
// 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<string, unknown> for HandClient compatibility
const hands: Array<Record<string, unknown>> = 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<string, unknown> || 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<string, unknown>).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);
}