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)
723 lines
23 KiB
TypeScript
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);
|
|
}
|