refactor(phase-11): extract specialized stores from gatewayStore

Decompose monolithic gatewayStore.ts (1660 lines) into focused stores:

- connectionStore.ts (444 lines) - WebSocket, auth, local gateway
- agentStore.ts (256 lines) - Clones, usage stats, plugins
- handStore.ts (498 lines) - Hands, triggers, approvals
- workflowStore.ts (255 lines) - Workflows, runs
- configStore.ts (537 lines) - QuickConfig, channels, skills

Each store uses client injection pattern for loose coupling.
Coordinator layer to be added in next commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 20:17:17 +08:00
parent 6a66ce159d
commit f22b1a2095
6 changed files with 2006 additions and 1 deletions

View File

@@ -0,0 +1,256 @@
/**
* Agent Store - Manages clones/agents, usage statistics, and plugin status
*
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
* This store focuses on agent/clone CRUD operations and related metadata.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
export interface Clone {
id: string;
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
workspaceResolvedPath?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
createdAt: string;
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
}
export interface UsageStats {
totalSessions: number;
totalMessages: number;
totalTokens: number;
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
}
export interface PluginStatus {
id: string;
name?: string;
status: 'active' | 'inactive' | 'error' | 'loading';
version?: string;
description?: string;
}
export interface CloneCreateOptions {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
}
// === Store State ===
interface AgentStateSlice {
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: PluginStatus[];
isLoading: boolean;
error: string | null;
}
// === Store Actions ===
interface AgentActionsSlice {
loadClones: () => Promise<void>;
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
}
// === Store Interface ===
export type AgentStore = AgentStateSlice & AgentActionsSlice;
// === Client Injection ===
// For coordinator to inject client - avoids direct import coupling
let _client: GatewayClient | null = null;
/**
* Sets the gateway client for the agent store.
* Called by the coordinator during initialization.
*/
export const setAgentStoreClient = (client: unknown): void => {
_client = client as GatewayClient;
};
/**
* Gets the gateway client.
* Returns null if not set (coordinator must initialize first).
*/
const getClient = (): GatewayClient | null => _client;
// === Store Implementation ===
export const useAgentStore = create<AgentStore>((set, get) => ({
// Initial state
clones: [],
usageStats: null,
pluginStatus: [],
isLoading: false,
error: null,
// Actions
loadClones: async () => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized, skipping loadClones');
return;
}
try {
set({ isLoading: true, error: null });
const result = await client.listClones();
const clones = result?.clones || result?.agents || [];
set({ clones, isLoading: false });
// Set default agent ID if we have agents and none is set
if (clones.length > 0 && clones[0].id) {
const currentDefault = client.getDefaultAgentId();
// Only set if the default doesn't exist in the list
const defaultExists = clones.some((c: Clone) => c.id === currentDefault);
if (!defaultExists) {
client.setDefaultAgentId(clones[0].id);
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
// Don't throw - clone loading is non-critical
}
},
createClone: async (opts: CloneCreateOptions) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
return undefined;
}
try {
set({ isLoading: true, error: null });
const result = await client.createClone(opts);
await get().loadClones(); // Refresh the list
return result?.clone;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
return undefined;
}
},
updateClone: async (id: string, updates: Partial<Clone>) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
return undefined;
}
try {
set({ isLoading: true, error: null });
const result = await client.updateClone(id, updates);
await get().loadClones(); // Refresh the list
return result?.clone;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
return undefined;
}
},
deleteClone: async (id: string) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
return;
}
try {
set({ isLoading: true, error: null });
await client.deleteClone(id);
await get().loadClones(); // Refresh the list
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
}
},
loadUsageStats: async () => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized, skipping loadUsageStats');
return;
}
try {
const stats = await client.getUsageStats();
set({ usageStats: stats });
} catch {
// Usage stats are non-critical, ignore errors silently
}
},
loadPluginStatus: async () => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized, skipping loadPluginStatus');
return;
}
try {
const result = await client.getPluginStatus();
set({ pluginStatus: result?.plugins || [] });
} catch {
// Plugin status is non-critical, ignore errors silently
}
},
setError: (error: string | null) => {
set({ error });
},
clearError: () => {
set({ error: null });
},
}));
// === Selectors ===
/**
* Get a clone by ID
*/
export const selectCloneById = (id: string) => (state: AgentStore): Clone | undefined =>
state.clones.find((clone) => clone.id === id);
/**
* Get all active plugins
*/
export const selectActivePlugins = (state: AgentStore): PluginStatus[] =>
state.pluginStatus.filter((plugin) => plugin.status === 'active');
/**
* Check if any operation is in progress
*/
export const selectIsLoading = (state: AgentStore): boolean => state.isLoading;

View File

@@ -0,0 +1,537 @@
/**
* configStore.ts - Configuration Management Store
*
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
* Manages settings, workspace, channels, skills, and models.
*/
import { create } from 'zustand';
import type { GatewayModelChoice } from '../lib/gateway-config';
// === Types ===
export interface QuickConfig {
agentName?: string;
agentRole?: string;
userName?: string;
userRole?: string;
agentNickname?: string;
scenarios?: string[];
workspaceDir?: string;
gatewayUrl?: string;
gatewayToken?: string;
skillsExtraDirs?: string[];
mcpServices?: Array<{ id: string; name: string; enabled: boolean }>;
theme?: 'light' | 'dark';
autoStart?: boolean;
showToolCalls?: boolean;
restrictFiles?: boolean;
autoSaveContext?: boolean;
fileWatching?: boolean;
privacyOptIn?: boolean;
}
export interface WorkspaceInfo {
path: string;
resolvedPath: string;
exists: boolean;
fileCount: number;
totalSize: number;
}
export interface ChannelInfo {
id: string;
type: string;
label: string;
status: 'active' | 'inactive' | 'error';
accounts?: number;
error?: string;
}
export interface ScheduledTask {
id: string;
name: string;
schedule: string;
status: 'active' | 'paused' | 'completed' | 'error';
lastRun?: string;
nextRun?: string;
description?: string;
}
export interface SkillInfo {
id: string;
name: string;
path: string;
source: 'builtin' | 'extra';
description?: string;
triggers?: Array<{ type: string; pattern?: string }>;
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}
// === Client Interface ===
export interface ConfigStoreClient {
getWorkspaceInfo(): Promise<WorkspaceInfo | null>;
getQuickConfig(): Promise<{ quickConfig?: QuickConfig } | null>;
saveQuickConfig(config: QuickConfig): Promise<{ quickConfig?: QuickConfig } | null>;
listSkills(): Promise<{ skills?: SkillInfo[]; extraDirs?: string[] } | null>;
getSkill(id: string): Promise<{ skill?: SkillInfo } | null>;
createSkill(skill: {
name: string;
description?: string;
triggers: Array<{ type: string; pattern?: string }>;
actions: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<{ skill?: SkillInfo } | null>;
updateSkill(id: string, updates: {
name?: string;
description?: string;
triggers?: Array<{ type: string; pattern?: string }>;
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<{ skill?: SkillInfo } | null>;
deleteSkill(id: string): Promise<void>;
listChannels(): Promise<{ channels?: ChannelInfo[] } | null>;
getChannel(id: string): Promise<{ channel?: ChannelInfo } | null>;
createChannel(channel: {
type: string;
name: string;
config: Record<string, unknown>;
enabled?: boolean;
}): Promise<{ channel?: ChannelInfo } | null>;
updateChannel(id: string, updates: {
name?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}): Promise<{ channel?: ChannelInfo } | null>;
deleteChannel(id: string): Promise<void>;
listScheduledTasks(): Promise<{ tasks?: ScheduledTask[] } | null>;
createScheduledTask(task: {
name: string;
schedule: string;
scheduleType: 'cron' | 'interval' | 'once';
target?: {
type: 'agent' | 'hand' | 'workflow';
id: string;
};
description?: string;
enabled?: boolean;
}): Promise<ScheduledTask>;
listModels(): Promise<{ models: GatewayModelChoice[] }>;
getFeishuStatus(): Promise<{ configured?: boolean; accounts?: number } | null>;
}
// === Store State & Actions ===
interface ConfigStore {
// State
quickConfig: QuickConfig;
workspaceInfo: WorkspaceInfo | null;
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
skillsCatalog: SkillInfo[];
models: GatewayModelChoice[];
modelsLoading: boolean;
modelsError: string | null;
error: string | null;
// Client reference (injected)
client: ConfigStoreClient | null;
// Client injection
setConfigStoreClient: (client: ConfigStoreClient) => void;
// Quick Config Actions
loadQuickConfig: () => Promise<void>;
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
// Workspace Actions
loadWorkspaceInfo: () => Promise<void>;
// Channel Actions
loadChannels: () => Promise<void>;
getChannel: (id: string) => Promise<ChannelInfo | undefined>;
createChannel: (channel: {
type: string;
name: string;
config: Record<string, unknown>;
enabled?: boolean;
}) => Promise<ChannelInfo | undefined>;
updateChannel: (id: string, updates: {
name?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}) => Promise<ChannelInfo | undefined>;
deleteChannel: (id: string) => Promise<void>;
// Scheduled Task Actions
loadScheduledTasks: () => Promise<void>;
createScheduledTask: (task: {
name: string;
schedule: string;
scheduleType: 'cron' | 'interval' | 'once';
target?: {
type: 'agent' | 'hand' | 'workflow';
id: string;
};
description?: string;
enabled?: boolean;
}) => Promise<ScheduledTask | undefined>;
// Skill Actions
loadSkillsCatalog: () => Promise<void>;
getSkill: (id: string) => Promise<SkillInfo | undefined>;
createSkill: (skill: {
name: string;
description?: string;
triggers: Array<{ type: string; pattern?: string }>;
actions: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}) => Promise<SkillInfo | undefined>;
updateSkill: (id: string, updates: {
name?: string;
description?: string;
triggers?: Array<{ type: string; pattern?: string }>;
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}) => Promise<SkillInfo | undefined>;
deleteSkill: (id: string) => Promise<void>;
// Model Actions
loadModels: () => Promise<void>;
// Utility
clearError: () => void;
}
export const useConfigStore = create<ConfigStore>((set, get) => ({
// Initial State
quickConfig: {},
workspaceInfo: null,
channels: [],
scheduledTasks: [],
skillsCatalog: [],
models: [],
modelsLoading: false,
modelsError: null,
error: null,
client: null,
// Client Injection
setConfigStoreClient: (client: ConfigStoreClient) => {
set({ client });
},
// === Quick Config Actions ===
loadQuickConfig: async () => {
const client = get().client;
if (!client) return;
try {
const result = await client.getQuickConfig();
set({ quickConfig: result?.quickConfig || {} });
} catch {
// Ignore if quick config not available
}
},
saveQuickConfig: async (updates: Partial<QuickConfig>) => {
const client = get().client;
if (!client) return;
try {
const nextConfig = { ...get().quickConfig, ...updates };
const result = await client.saveQuickConfig(nextConfig);
set({ quickConfig: result?.quickConfig || nextConfig });
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
// === Workspace Actions ===
loadWorkspaceInfo: async () => {
const client = get().client;
if (!client) return;
try {
const info = await client.getWorkspaceInfo();
set({ workspaceInfo: info });
} catch {
// Ignore if workspace info not available
}
},
// === Channel Actions ===
loadChannels: async () => {
const client = get().client;
if (!client) return;
const channels: ChannelInfo[] = [];
try {
// Try listing channels from Gateway
const result = await client.listChannels();
if (result?.channels) {
set({ channels: result.channels });
return;
}
} catch {
// channels.list may not be available, fallback to probing
}
// Fallback: probe known channels individually
try {
const feishu = await client.getFeishuStatus();
channels.push({
id: 'feishu',
type: 'feishu',
label: 'Feishu',
status: feishu?.configured ? 'active' : 'inactive',
accounts: feishu?.accounts || 0,
});
} catch {
channels.push({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' });
}
set({ channels });
},
getChannel: async (id: string) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.getChannel(id);
if (result?.channel) {
// Update the channel in the local state if it exists
const currentChannels = get().channels;
const existingIndex = currentChannels.findIndex(c => c.id === id);
if (existingIndex >= 0) {
const updatedChannels = [...currentChannels];
updatedChannels[existingIndex] = result.channel;
set({ channels: updatedChannels });
}
return result.channel as ChannelInfo;
}
return undefined;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
createChannel: async (channel) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.createChannel(channel);
if (result?.channel) {
// Add the new channel to local state
const currentChannels = get().channels;
set({ channels: [...currentChannels, result.channel as ChannelInfo] });
return result.channel as ChannelInfo;
}
return undefined;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
updateChannel: async (id, updates) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.updateChannel(id, updates);
if (result?.channel) {
// Update the channel in local state
const currentChannels = get().channels;
const updatedChannels = currentChannels.map(c =>
c.id === id ? (result.channel as ChannelInfo) : c
);
set({ channels: updatedChannels });
return result.channel as ChannelInfo;
}
return undefined;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
deleteChannel: async (id) => {
const client = get().client;
if (!client) return;
try {
await client.deleteChannel(id);
// Remove the channel from local state
const currentChannels = get().channels;
set({ channels: currentChannels.filter(c => c.id !== id) });
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
// === Scheduled Task Actions ===
loadScheduledTasks: async () => {
const client = get().client;
if (!client) return;
try {
const result = await client.listScheduledTasks();
set({ scheduledTasks: result?.tasks || [] });
} catch {
// Ignore if heartbeat.tasks not available
}
},
createScheduledTask: async (task) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.createScheduledTask(task);
const newTask: ScheduledTask = {
id: result.id,
name: result.name,
schedule: result.schedule,
status: result.status as 'active' | 'paused' | 'completed' | 'error',
lastRun: result.lastRun,
nextRun: result.nextRun,
description: result.description,
};
set((state) => ({
scheduledTasks: [...state.scheduledTasks, newTask],
}));
return newTask;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create scheduled task';
set({ error: errorMessage });
return undefined;
}
},
// === Skill Actions ===
loadSkillsCatalog: async () => {
const client = get().client;
if (!client) return;
try {
const result = await client.listSkills();
set({ skillsCatalog: result?.skills || [] });
if (result?.extraDirs) {
set((state) => ({
quickConfig: {
...state.quickConfig,
skillsExtraDirs: result.extraDirs,
},
}));
}
} catch {
// Ignore if skills list not available
}
},
getSkill: async (id: string) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.getSkill(id);
return result?.skill as SkillInfo | undefined;
} catch {
return undefined;
}
},
createSkill: async (skill) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.createSkill(skill);
const newSkill = result?.skill as SkillInfo | undefined;
if (newSkill) {
set((state) => ({
skillsCatalog: [...state.skillsCatalog, newSkill],
}));
}
return newSkill;
} catch {
return undefined;
}
},
updateSkill: async (id, updates) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.updateSkill(id, updates);
const updatedSkill = result?.skill as SkillInfo | undefined;
if (updatedSkill) {
set((state) => ({
skillsCatalog: state.skillsCatalog.map((s) =>
s.id === id ? updatedSkill : s
),
}));
}
return updatedSkill;
} catch {
return undefined;
}
},
deleteSkill: async (id) => {
const client = get().client;
if (!client) return;
try {
await client.deleteSkill(id);
set((state) => ({
skillsCatalog: state.skillsCatalog.filter((s) => s.id !== id),
}));
} catch {
// Ignore deletion errors
}
},
// === Model Actions ===
loadModels: async () => {
const client = get().client;
if (!client) return;
try {
set({ modelsLoading: true, modelsError: null });
const result = await client.listModels();
const models: GatewayModelChoice[] = result?.models || [];
set({ models, modelsLoading: false });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load models';
set({ modelsError: message, modelsLoading: false });
}
},
// === Utility ===
clearError: () => {
set({ error: null });
},
}));
// Re-export types for convenience
export type {
QuickConfig as QuickConfigType,
WorkspaceInfo as WorkspaceInfoType,
ChannelInfo as ChannelInfoType,
ScheduledTask as ScheduledTaskType,
SkillInfo as SkillInfoType,
};

View File

@@ -0,0 +1,444 @@
import { create } from 'zustand';
import {
DEFAULT_GATEWAY_URL,
FALLBACK_GATEWAY_URLS,
GatewayClient,
ConnectionState,
getGatewayClient,
getStoredGatewayToken,
setStoredGatewayToken,
getStoredGatewayUrl,
setStoredGatewayUrl,
getLocalDeviceIdentity,
} from '../lib/gateway-client';
import {
isTauriRuntime,
prepareLocalGatewayForTauri,
getLocalGatewayStatus,
startLocalGateway as startLocalGatewayCommand,
stopLocalGateway as stopLocalGatewayCommand,
restartLocalGateway as restartLocalGatewayCommand,
approveLocalGatewayDevicePairing,
getLocalGatewayAuth,
getUnsupportedLocalGatewayStatus,
type LocalGatewayStatus,
} from '../lib/tauri-gateway';
// === Types ===
export interface GatewayLog {
timestamp: number;
level: string;
message: string;
}
// === Helper Functions ===
/**
* Check if an error indicates we connection should retry with another candidate.
*/
function shouldRetryGatewayCandidate(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return (
message === 'WebSocket connection failed'
|| message.startsWith('Gateway handshake timed out')
|| message.startsWith('WebSocket closed before handshake completed')
|| message.startsWith('Connection refused')
|| message.includes('ECONNREFUSED')
|| message.includes('Failed to fetch')
|| message.includes('Network error')
|| message.includes('pairing required')
);
}
/**
* Check if an error indicates local device pairing is required.
*/
function requiresLocalDevicePairing(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return message.includes('pairing required');
}
/**
* Calculate security level based on enabled layer count.
*/
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
if (totalCount === 0) return 'low';
const ratio = enabledCount / totalCount;
if (ratio >= 0.875) return 'critical'; // 14-16 layers
if (ratio >= 0.625) return 'high'; // 10-13 layers
if (ratio >= 0.375) return 'medium'; // 6-9 layers
return 'low'; // 0-5 layers
}
/**
* Check if a URL is a loopback address.
*/
function isLoopbackGatewayUrl(url: string): boolean {
return /^wss?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(url.trim());
}
/**
* Normalize a gateway URL candidate.
*/
function normalizeGatewayUrlCandidate(url: string): string {
return url.trim().replace(/\/+$/, '');
}
/**
* Get the local gateway connect URL from status.
*/
function getLocalGatewayConnectUrl(status: LocalGatewayStatus): string | null {
if (status.probeUrl && status.probeUrl.trim()) {
return normalizeGatewayUrlCandidate(status.probeUrl);
}
if (status.port) {
return `ws://127.0.0.1:${status.port}`;
}
return null;
}
/**
* Attempt to approve local device pairing for loopback URLs.
*/
async function approveCurrentLocalDevicePairing(url: string): Promise<boolean> {
if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) {
return false;
}
try {
const identity = await getLocalDeviceIdentity();
const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url);
return result.approved;
} catch {
return false;
}
}
// === Store Interface ===
export interface ConnectionStateSlice {
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
localGateway: LocalGatewayStatus;
localGatewayBusy: boolean;
isLoading: boolean;
}
export interface ConnectionActionsSlice {
connect: (url?: string, token?: string) => Promise<void>;
disconnect: () => void;
clearLogs: () => void;
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
}
export interface ConnectionStore extends ConnectionStateSlice, ConnectionActionsSlice {
client: GatewayClient;
}
// === Store Implementation ===
export const useConnectionStore = create<ConnectionStore>((set, get) => {
// Initialize client
const client = getGatewayClient();
// Wire up state change callback
client.onStateChange = (state) => {
set({ connectionState: state });
};
// Wire up log callback
client.onLog = (level, message) => {
set((s) => ({
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
}));
};
return {
// === Initial State ===
connectionState: 'disconnected',
gatewayVersion: null,
error: null,
logs: [],
localGateway: getUnsupportedLocalGatewayStatus(),
localGatewayBusy: false,
isLoading: false,
client,
// === Actions ===
connect: async (url?: string, token?: string) => {
const c = get().client;
// Resolve connection URL candidates
const resolveCandidates = async (): Promise<string[]> => {
const explicitUrl = url?.trim();
if (explicitUrl) {
return [normalizeGatewayUrlCandidate(explicitUrl)];
}
const candidates: string[] = [];
// Check local gateway first if in Tauri
if (isTauriRuntime()) {
try {
const localStatus = await getLocalGatewayStatus();
const localUrl = getLocalGatewayConnectUrl(localStatus);
if (localUrl) {
candidates.push(localUrl);
}
} catch {
/* ignore local gateway lookup failures during candidate selection */
}
}
// Add quick config gateway URL if available
const quickConfigGatewayUrl = get().quickConfig?.gatewayUrl?.trim();
if (quickConfigGatewayUrl) {
candidates.push(quickConfigGatewayUrl);
}
// Add stored URL, default, and fallbacks
candidates.push(
getStoredGatewayUrl(),
DEFAULT_GATEWAY_URL,
...FALLBACK_GATEWAY_URLS
);
// Return unique, non-empty candidates
return Array.from(
new Set(
candidates
.filter(Boolean)
.map(normalizeGatewayUrlCandidate)
)
);
};
try {
set({ error: null });
// Prepare local gateway for Tauri
if (isTauriRuntime()) {
try {
await prepareLocalGatewayForTauri();
} catch {
/* ignore local gateway preparation failures during connection bootstrap */
}
}
// Resolve effective token: param > quickConfig > localStorage > local auth
let effectiveToken = token || get().quickConfig?.gatewayToken || getStoredGatewayToken();
if (!effectiveToken && isTauriRuntime()) {
try {
const localAuth = await getLocalGatewayAuth();
if (localAuth.gatewayToken) {
effectiveToken = localAuth.gatewayToken;
setStoredGatewayToken(localAuth.gatewayToken);
}
} catch {
/* ignore local auth lookup failures during connection bootstrap */
}
}
console.log('[ConnectionStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
const candidateUrls = await resolveCandidates();
let lastError: unknown = null;
let connectedUrl: string | null = null;
// Try each candidate URL
for (const candidateUrl of candidateUrls) {
try {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
} catch (err) {
lastError = err;
// Try device pairing if required
if (requiresLocalDevicePairing(err)) {
const approved = await approveCurrentLocalDevicePairing(candidateUrl);
if (approved) {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
}
}
// Check if we should try next candidate
if (!shouldRetryGatewayCandidate(err)) {
throw err;
}
}
}
if (!connectedUrl) {
throw (lastError instanceof Error ? lastError : new Error('Failed to connect to any available Gateway'));
}
// Store successful URL
setStoredGatewayUrl(connectedUrl);
// Fetch gateway version
try {
const health = await c.health();
set({ gatewayVersion: health?.version });
} catch { /* health may not return version */ }
console.log('[ConnectionStore] Connected to:', connectedUrl);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage });
throw err;
}
},
disconnect: () => {
get().client.disconnect();
set({
connectionState: 'disconnected',
gatewayVersion: null,
error: null,
});
},
clearLogs: () => set({ logs: [] }),
refreshLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true });
try {
const status = await getLocalGatewayStatus();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to read local Gateway status';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return nextStatus;
}
},
startLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await startLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to start local Gateway';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
stopLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await stopLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to stop local Gateway';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
restartLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await restartLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to restart local Gateway';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
};
});
// === Exported Accessors for Coordinator ===
/**
* Get current connection state.
*/
export const getConnectionState = () => useConnectionStore.getState().connectionState;
/**
* Get gateway client instance.
*/
export const getClient = () => useConnectionStore.getState().client;
/**
* Get current error message.
*/
export const getConnectionError = () => useConnectionStore.getState().error;
/**
* Get local gateway status.
*/
export const getLocalGatewayStatus = () => useConnectionStore.getState().localGateway;
/**
* Get gateway version.
*/
export const getGatewayVersion = () => useConnectionStore.getState().gatewayVersion;

View File

@@ -0,0 +1,498 @@
/**
* 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;
}
// === 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<void>;
cancelHand: (name: string, runId: string) => Promise<void>;
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<void>;
deleteTrigger: (id: string) => Promise<void>;
listApprovals: (status?: ApprovalStatus) => Promise<{ approvals?: RawApproval[] } | null>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
}
interface HandStore {
// State
hands: Hand[];
handRuns: Record<string, HandRun[]>;
triggers: Trigger[];
approvals: Approval[];
isLoading: boolean;
error: string | null;
// Client reference (injected via setHandStoreClient)
client: HandClient | null;
// Actions
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: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => 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;
}
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;
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 {
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) => {
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),
};
}

View File

@@ -0,0 +1,255 @@
import { create } from 'zustand';
import { Workflow, WorkflowRun } from './gatewayStore';
// === Types ===
interface RawWorkflowRun {
runId?: string;
run_id?: string;
id?: string;
workflowId?: string;
workflow_id?: string;
status?: string;
startedAt?: string;
started_at?: string;
completedAt?: string;
completed_at?: string;
currentStep?: number;
current_step?: number;
totalSteps?: number;
total_steps?: number;
error?: string;
result?: unknown;
step?: string;
}
export interface WorkflowStep {
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}
export interface CreateWorkflowInput {
name: string;
description?: string;
steps: WorkflowStep[];
}
export interface UpdateWorkflowInput {
name?: string;
description?: string;
steps?: WorkflowStep[];
}
// Extended WorkflowRun with additional fields from API
export interface ExtendedWorkflowRun extends WorkflowRun {
startedAt?: string;
completedAt?: string;
error?: string;
}
// === Client Interface ===
interface WorkflowClient {
listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number; description?: string; createdAt?: string }[] } | null>;
createWorkflow(workflow: CreateWorkflowInput): Promise<{ id: string; name: string } | null>;
updateWorkflow(id: string, updates: UpdateWorkflowInput): Promise<{ id: string; name: string } | null>;
deleteWorkflow(id: string): Promise<{ status: string }>;
executeWorkflow(id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string } | null>;
cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }>;
listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: RawWorkflowRun[] } | null>;
}
// === Store State ===
interface WorkflowState {
workflows: Workflow[];
workflowRuns: Record<string, ExtendedWorkflowRun[]>;
isLoading: boolean;
error: string | null;
client: WorkflowClient;
}
// === Store Actions ===
interface WorkflowActions {
setWorkflowStoreClient: (client: WorkflowClient) => void;
loadWorkflows: () => Promise<void>;
getWorkflow: (id: string) => Workflow | undefined;
createWorkflow: (workflow: CreateWorkflowInput) => Promise<Workflow | undefined>;
updateWorkflow: (id: string, updates: UpdateWorkflowInput) => Promise<Workflow | undefined>;
deleteWorkflow: (id: string) => Promise<void>;
triggerWorkflow: (id: string, input?: Record<string, unknown>) => Promise<{ runId: string; status: string } | undefined>;
cancelWorkflow: (id: string, runId: string) => Promise<void>;
loadWorkflowRuns: (workflowId: string, opts?: { limit?: number; offset?: number }) => Promise<ExtendedWorkflowRun[]>;
clearError: () => void;
reset: () => void;
}
// === Initial State ===
const initialState = {
workflows: [],
workflowRuns: {},
isLoading: false,
error: null,
client: null as unknown as WorkflowClient,
};
// === Store ===
export const useWorkflowStore = create<WorkflowState & WorkflowActions>((set, get) => ({
...initialState,
setWorkflowStoreClient: (client: WorkflowClient) => {
set({ client });
},
loadWorkflows: async () => {
set({ isLoading: true, error: null });
try {
const result = await get().client.listWorkflows();
const workflows: Workflow[] = (result?.workflows || []).map(w => ({
id: w.id,
name: w.name,
steps: w.steps,
description: w.description,
createdAt: w.createdAt,
}));
set({ workflows, isLoading: false });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load workflows';
set({ error: message, isLoading: false });
}
},
getWorkflow: (id: string) => {
return get().workflows.find(w => w.id === id);
},
createWorkflow: async (workflow: CreateWorkflowInput) => {
set({ error: null });
try {
const result = await get().client.createWorkflow(workflow);
if (result) {
const newWorkflow: Workflow = {
id: result.id,
name: result.name,
steps: workflow.steps.length,
description: workflow.description,
};
set(state => ({ workflows: [...state.workflows, newWorkflow] }));
return newWorkflow;
}
return undefined;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create workflow';
set({ error: message });
return undefined;
}
},
updateWorkflow: async (id: string, updates: UpdateWorkflowInput) => {
set({ error: null });
try {
const result = await get().client.updateWorkflow(id, updates);
if (result) {
set(state => ({
workflows: state.workflows.map(w =>
w.id === id
? {
...w,
name: updates.name ?? w.name,
description: updates.description ?? w.description,
steps: updates.steps?.length ?? w.steps,
}
: w
),
}));
return get().workflows.find(w => w.id === id);
}
return undefined;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update workflow';
set({ error: message });
return undefined;
}
},
deleteWorkflow: async (id: string) => {
set({ error: null });
try {
await get().client.deleteWorkflow(id);
set(state => ({
workflows: state.workflows.filter(w => w.id !== id),
workflowRuns: (() => {
const { [id]: _, ...rest } = state.workflowRuns;
return rest;
})(),
}));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to delete workflow';
set({ error: message });
throw err;
}
},
triggerWorkflow: async (id: string, input?: Record<string, unknown>) => {
set({ error: null });
try {
const result = await get().client.executeWorkflow(id, input);
return result ? { runId: result.runId, status: result.status } : undefined;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to trigger workflow';
set({ error: message });
return undefined;
}
},
cancelWorkflow: async (id: string, runId: string) => {
set({ error: null });
try {
await get().client.cancelWorkflow(id, runId);
// Refresh workflows to update status
await get().loadWorkflows();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to cancel workflow';
set({ error: message });
throw err;
}
},
loadWorkflowRuns: async (workflowId: string, opts?: { limit?: number; offset?: number }) => {
try {
const result = await get().client.listWorkflowRuns(workflowId, opts);
const runs: ExtendedWorkflowRun[] = (result?.runs || []).map((r: RawWorkflowRun) => ({
runId: r.runId || r.run_id || r.id || '',
status: r.status || 'unknown',
startedAt: r.startedAt || r.started_at,
completedAt: r.completedAt || r.completed_at,
step: r.currentStep?.toString() || r.current_step?.toString() || r.step,
result: r.result,
error: r.error,
}));
// Store runs by workflow ID
set(state => ({
workflowRuns: { ...state.workflowRuns, [workflowId]: runs },
}));
return runs;
} catch {
return [];
}
},
clearError: () => {
set({ error: null });
},
reset: () => {
set(initialState);
},
}));
// Re-export types from gatewayStore for convenience
export type { Workflow, WorkflowRun };