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:
256
desktop/src/store/agentStore.ts
Normal file
256
desktop/src/store/agentStore.ts
Normal 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;
|
||||
537
desktop/src/store/configStore.ts
Normal file
537
desktop/src/store/configStore.ts
Normal 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,
|
||||
};
|
||||
444
desktop/src/store/connectionStore.ts
Normal file
444
desktop/src/store/connectionStore.ts
Normal 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;
|
||||
498
desktop/src/store/handStore.ts
Normal file
498
desktop/src/store/handStore.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
255
desktop/src/store/workflowStore.ts
Normal file
255
desktop/src/store/workflowStore.ts
Normal 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 };
|
||||
@@ -560,4 +560,19 @@ ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面客户端,核
|
||||
* ✅ chatStore 测试通过 (11/11)
|
||||
* ✅ gatewayStore 测试通过 (17/17)
|
||||
|
||||
*下一步: Phase 11 Store 重构*
|
||||
*Phase 11 进行中 🔄 (2026-03-15)* - Store 重构分解
|
||||
* 新 Store 文件:
|
||||
* ✅ `connectionStore.ts` (444 行) - WebSocket 连接、认证、本地 Gateway
|
||||
* ✅ `agentStore.ts` (256 行) - Clones、使用统计、插件状态
|
||||
* ✅ `handStore.ts` (498 行) - Hands、Triggers、Approvals
|
||||
* ✅ `workflowStore.ts` (255 行) - Workflows、WorkflowRuns
|
||||
* ✅ `configStore.ts` (537 行) - QuickConfig、Channels、Skills、Models
|
||||
* Store 行数: gatewayStore 1660 → 5 个子 Store (平均 358 行)
|
||||
* 待完成:
|
||||
* 🔄 创建协调层 (coordinator)
|
||||
* 🔄 更新组件导入
|
||||
* 代码质量:
|
||||
* ✅ TypeScript 类型检查通过
|
||||
|
||||
*下一步: Phase 11 协调层创建*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user