feat(domains): create domain-driven architecture for Phase 2
Chat Domain: - Add types.ts with Message, Conversation, Agent types - Add store.ts with Valtio-based state management - Add hooks.ts with useChatState, useMessages, etc. - Add index.ts for public API export Hands Domain: - Add types.ts with Hand, Trigger, Approval types - Add machine.ts with XState state machine - Add store.ts with Valtio-based state management - Add hooks.ts with useHands, useApprovalQueue, etc. Shared Module: - Add types.ts with Result, AsyncResult, PaginatedResponse - Add error-handling.ts with AppError, NetworkError, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
76
desktop/src/domains/chat/hooks.ts
Normal file
76
desktop/src/domains/chat/hooks.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Chat Domain Hooks
|
||||||
|
*
|
||||||
|
* React hooks for accessing chat state with Valtio.
|
||||||
|
* Only re-renders when accessed properties change.
|
||||||
|
*/
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
import { chatStore } from './store';
|
||||||
|
import type { Message, Agent, Conversation } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the full chat state snapshot.
|
||||||
|
* Only re-renders when accessed properties change.
|
||||||
|
*/
|
||||||
|
export function useChatState() {
|
||||||
|
return useSnapshot(chatStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access messages only.
|
||||||
|
* Only re-renders when messages change.
|
||||||
|
*/
|
||||||
|
export function useMessages(): readonly Message[] {
|
||||||
|
const { messages } = useSnapshot(chatStore);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access streaming state.
|
||||||
|
* Only re-renders when isStreaming changes.
|
||||||
|
*/
|
||||||
|
export function useIsStreaming(): boolean {
|
||||||
|
const { isStreaming } = useSnapshot(chatStore);
|
||||||
|
return isStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access current agent.
|
||||||
|
*/
|
||||||
|
export function useCurrentAgent(): Agent | null {
|
||||||
|
const { currentAgent } = useSnapshot(chatStore);
|
||||||
|
return currentAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access all agents.
|
||||||
|
*/
|
||||||
|
export function useAgents(): readonly Agent[] {
|
||||||
|
const { agents } = useSnapshot(chatStore);
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access conversations.
|
||||||
|
*/
|
||||||
|
export function useConversations(): readonly Conversation[] {
|
||||||
|
const { conversations } = useSnapshot(chatStore);
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access current model.
|
||||||
|
*/
|
||||||
|
export function useCurrentModel(): string {
|
||||||
|
const { currentModel } = useSnapshot(chatStore);
|
||||||
|
return currentModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access chat actions.
|
||||||
|
* Returns the store directly for calling actions.
|
||||||
|
* Does not cause re-renders.
|
||||||
|
*/
|
||||||
|
export function useChatActions() {
|
||||||
|
return chatStore;
|
||||||
|
}
|
||||||
48
desktop/src/domains/chat/index.ts
Normal file
48
desktop/src/domains/chat/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Chat Domain
|
||||||
|
*
|
||||||
|
* Core chat functionality including messaging, conversations, and agents.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Using hooks (recommended)
|
||||||
|
* import { useMessages, useChatActions } from '@/domains/chat';
|
||||||
|
*
|
||||||
|
* function ChatComponent() {
|
||||||
|
* const messages = useMessages();
|
||||||
|
* const { addMessage } = useChatActions();
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Using store directly (for actions)
|
||||||
|
* import { chatStore } from '@/domains/chat';
|
||||||
|
*
|
||||||
|
* chatStore.addMessage({ id: '1', role: 'user', content: 'Hello', timestamp: new Date() });
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
Message,
|
||||||
|
MessageFile,
|
||||||
|
CodeBlock,
|
||||||
|
Conversation,
|
||||||
|
Agent,
|
||||||
|
AgentProfileLike,
|
||||||
|
ChatState,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Store
|
||||||
|
export { chatStore, toChatAgent } from './store';
|
||||||
|
export type { ChatStore } from './store';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useChatState,
|
||||||
|
useMessages,
|
||||||
|
useIsStreaming,
|
||||||
|
useCurrentAgent,
|
||||||
|
useAgents,
|
||||||
|
useConversations,
|
||||||
|
useCurrentModel,
|
||||||
|
useChatActions,
|
||||||
|
} from './hooks';
|
||||||
222
desktop/src/domains/chat/store.ts
Normal file
222
desktop/src/domains/chat/store.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Chat Domain Store
|
||||||
|
*
|
||||||
|
* Valtio-based state management for chat.
|
||||||
|
* Replaces Zustand for better performance with fine-grained reactivity.
|
||||||
|
*/
|
||||||
|
import { proxy, subscribe } from 'valtio';
|
||||||
|
import type { Message, Conversation, Agent, AgentProfileLike } from './types';
|
||||||
|
|
||||||
|
// === Constants ===
|
||||||
|
|
||||||
|
const DEFAULT_AGENT: Agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'ZCLAW',
|
||||||
|
icon: '🦞',
|
||||||
|
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||||
|
lastMessage: '发送消息开始对话',
|
||||||
|
time: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Helper Functions ===
|
||||||
|
|
||||||
|
function generateConvId(): string {
|
||||||
|
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTitle(messages: Message[]): string {
|
||||||
|
const firstUser = messages.find(m => m.role === 'user');
|
||||||
|
if (firstUser) {
|
||||||
|
const text = firstUser.content.trim();
|
||||||
|
return text.length > 30 ? text.slice(0, 30) + '...' : text;
|
||||||
|
}
|
||||||
|
return '新对话';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toChatAgent(profile: AgentProfileLike): Agent {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
icon: profile.nickname?.slice(0, 1) || profile.name.slice(0, 1) || '🦞',
|
||||||
|
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||||
|
lastMessage: profile.role || '新分身',
|
||||||
|
time: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Interface ===
|
||||||
|
|
||||||
|
export interface ChatStore {
|
||||||
|
// State
|
||||||
|
messages: Message[];
|
||||||
|
conversations: Conversation[];
|
||||||
|
currentConversationId: string | null;
|
||||||
|
agents: Agent[];
|
||||||
|
currentAgent: Agent | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentModel: string;
|
||||||
|
sessionKey: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||||
|
deleteMessage: (id: string) => void;
|
||||||
|
setCurrentAgent: (agent: Agent) => void;
|
||||||
|
syncAgents: (profiles: AgentProfileLike[]) => void;
|
||||||
|
setCurrentModel: (model: string) => void;
|
||||||
|
setStreaming: (streaming: boolean) => void;
|
||||||
|
setSessionKey: (key: string | null) => void;
|
||||||
|
newConversation: () => void;
|
||||||
|
switchConversation: (id: string) => void;
|
||||||
|
deleteConversation: (id: string) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Create Proxy State ===
|
||||||
|
|
||||||
|
export const chatStore = proxy<ChatStore>({
|
||||||
|
// Initial state
|
||||||
|
messages: [],
|
||||||
|
conversations: [],
|
||||||
|
currentConversationId: null,
|
||||||
|
agents: [DEFAULT_AGENT],
|
||||||
|
currentAgent: DEFAULT_AGENT,
|
||||||
|
isStreaming: false,
|
||||||
|
currentModel: 'glm-5',
|
||||||
|
sessionKey: null,
|
||||||
|
|
||||||
|
// === Actions ===
|
||||||
|
|
||||||
|
addMessage: (message: Message) => {
|
||||||
|
chatStore.messages.push(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessage: (id: string, updates: Partial<Message>) => {
|
||||||
|
const msg = chatStore.messages.find(m => m.id === id);
|
||||||
|
if (msg) {
|
||||||
|
Object.assign(msg, updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMessage: (id: string) => {
|
||||||
|
const index = chatStore.messages.findIndex(m => m.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
chatStore.messages.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentAgent: (agent: Agent) => {
|
||||||
|
chatStore.currentAgent = agent;
|
||||||
|
},
|
||||||
|
|
||||||
|
syncAgents: (profiles: AgentProfileLike[]) => {
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
chatStore.agents = [DEFAULT_AGENT];
|
||||||
|
} else {
|
||||||
|
chatStore.agents = profiles.map(toChatAgent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentModel: (model: string) => {
|
||||||
|
chatStore.currentModel = model;
|
||||||
|
},
|
||||||
|
|
||||||
|
setStreaming: (streaming: boolean) => {
|
||||||
|
chatStore.isStreaming = streaming;
|
||||||
|
},
|
||||||
|
|
||||||
|
setSessionKey: (key: string | null) => {
|
||||||
|
chatStore.sessionKey = key;
|
||||||
|
},
|
||||||
|
|
||||||
|
newConversation: () => {
|
||||||
|
// Save current conversation if has messages
|
||||||
|
if (chatStore.messages.length > 0) {
|
||||||
|
const conversation: Conversation = {
|
||||||
|
id: chatStore.currentConversationId || generateConvId(),
|
||||||
|
title: deriveTitle(chatStore.messages),
|
||||||
|
messages: [...chatStore.messages],
|
||||||
|
sessionKey: chatStore.sessionKey,
|
||||||
|
agentId: chatStore.currentAgent?.id || null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if conversation already exists
|
||||||
|
const existingIndex = chatStore.conversations.findIndex(
|
||||||
|
c => c.id === chatStore.currentConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
chatStore.conversations[existingIndex] = conversation;
|
||||||
|
} else {
|
||||||
|
chatStore.conversations.unshift(conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset for new conversation
|
||||||
|
chatStore.messages = [];
|
||||||
|
chatStore.sessionKey = null;
|
||||||
|
chatStore.isStreaming = false;
|
||||||
|
chatStore.currentConversationId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
switchConversation: (id: string) => {
|
||||||
|
const conv = chatStore.conversations.find(c => c.id === id);
|
||||||
|
if (conv) {
|
||||||
|
// Save current first
|
||||||
|
if (chatStore.messages.length > 0) {
|
||||||
|
const currentConv: Conversation = {
|
||||||
|
id: chatStore.currentConversationId || generateConvId(),
|
||||||
|
title: deriveTitle(chatStore.messages),
|
||||||
|
messages: [...chatStore.messages],
|
||||||
|
sessionKey: chatStore.sessionKey,
|
||||||
|
agentId: chatStore.currentAgent?.id || null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingIndex = chatStore.conversations.findIndex(
|
||||||
|
c => c.id === chatStore.currentConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
chatStore.conversations[existingIndex] = currentConv;
|
||||||
|
} else {
|
||||||
|
chatStore.conversations.unshift(currentConv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to new
|
||||||
|
chatStore.messages = [...conv.messages];
|
||||||
|
chatStore.sessionKey = conv.sessionKey;
|
||||||
|
chatStore.currentConversationId = conv.id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteConversation: (id: string) => {
|
||||||
|
const index = chatStore.conversations.findIndex(c => c.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
chatStore.conversations.splice(index, 1);
|
||||||
|
|
||||||
|
// If deleting current, clear messages
|
||||||
|
if (chatStore.currentConversationId === id) {
|
||||||
|
chatStore.messages = [];
|
||||||
|
chatStore.sessionKey = null;
|
||||||
|
chatStore.currentConversationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearMessages: () => {
|
||||||
|
chatStore.messages = [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Dev Mode Logging ===
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
subscribe(chatStore, (ops) => {
|
||||||
|
console.log('[ChatStore] Changes:', ops);
|
||||||
|
});
|
||||||
|
}
|
||||||
81
desktop/src/domains/chat/types.ts
Normal file
81
desktop/src/domains/chat/types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Chat Domain Types
|
||||||
|
*
|
||||||
|
* Core types for the chat system.
|
||||||
|
* Extracted from chatStore.ts for domain-driven organization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MessageFile {
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
size?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeBlock {
|
||||||
|
language?: string;
|
||||||
|
filename?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
runId?: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: string;
|
||||||
|
toolOutput?: string;
|
||||||
|
error?: string;
|
||||||
|
// Hand event fields
|
||||||
|
handName?: string;
|
||||||
|
handStatus?: string;
|
||||||
|
handResult?: unknown;
|
||||||
|
// Workflow event fields
|
||||||
|
workflowId?: string;
|
||||||
|
workflowStep?: string;
|
||||||
|
workflowStatus?: string;
|
||||||
|
workflowResult?: unknown;
|
||||||
|
// Output files and code blocks
|
||||||
|
files?: MessageFile[];
|
||||||
|
codeBlocks?: CodeBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
messages: Message[];
|
||||||
|
sessionKey: string | null;
|
||||||
|
agentId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
lastMessage: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentProfileLike {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nickname?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
messages: Message[];
|
||||||
|
conversations: Conversation[];
|
||||||
|
currentConversationId: string | null;
|
||||||
|
agents: Agent[];
|
||||||
|
currentAgent: Agent | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentModel: string;
|
||||||
|
sessionKey: string | null;
|
||||||
|
}
|
||||||
79
desktop/src/domains/hands/hooks.ts
Normal file
79
desktop/src/domains/hands/hooks.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Hands Domain Hooks
|
||||||
|
*
|
||||||
|
* React hooks for accessing hands state with Valtio.
|
||||||
|
*/
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
import { handsStore } from './store';
|
||||||
|
import type { Hand, ApprovalRequest, Trigger, HandRun } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the full hands state snapshot.
|
||||||
|
*/
|
||||||
|
export function useHandsState() {
|
||||||
|
return useSnapshot(handsStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access hands list.
|
||||||
|
*/
|
||||||
|
export function useHands(): readonly Hand[] {
|
||||||
|
const { hands } = useSnapshot(handsStore);
|
||||||
|
return hands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access a specific hand by ID.
|
||||||
|
*/
|
||||||
|
export function useHand(id: string): Hand | undefined {
|
||||||
|
const { hands } = useSnapshot(handsStore);
|
||||||
|
return hands.find(h => h.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access approval queue.
|
||||||
|
*/
|
||||||
|
export function useApprovalQueue(): readonly ApprovalRequest[] {
|
||||||
|
const { approvalQueue } = useSnapshot(handsStore);
|
||||||
|
return approvalQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access triggers.
|
||||||
|
*/
|
||||||
|
export function useTriggers(): readonly Trigger[] {
|
||||||
|
const { triggers } = useSnapshot(handsStore);
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access a specific run.
|
||||||
|
*/
|
||||||
|
export function useRun(runId: string): HandRun | undefined {
|
||||||
|
const { runs } = useSnapshot(handsStore);
|
||||||
|
return runs[runId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if any hand is loading.
|
||||||
|
*/
|
||||||
|
export function useHandsLoading(): boolean {
|
||||||
|
const { isLoading } = useSnapshot(handsStore);
|
||||||
|
return isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access hands error.
|
||||||
|
*/
|
||||||
|
export function useHandsError(): string | null {
|
||||||
|
const { error } = useSnapshot(handsStore);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access hands actions.
|
||||||
|
* Returns the store directly for calling actions.
|
||||||
|
*/
|
||||||
|
export function useHandsActions() {
|
||||||
|
return handsStore;
|
||||||
|
}
|
||||||
51
desktop/src/domains/hands/index.ts
Normal file
51
desktop/src/domains/hands/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Hands Domain
|
||||||
|
*
|
||||||
|
* Automation and hands management functionality.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Using hooks
|
||||||
|
* import { useHands, useHandsActions } from '@/domains/hands';
|
||||||
|
*
|
||||||
|
* function HandsComponent() {
|
||||||
|
* const hands = useHands();
|
||||||
|
* const { setHands, updateHand } = useHandsActions();
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
Hand,
|
||||||
|
HandStatus,
|
||||||
|
HandRequirement,
|
||||||
|
HandRun,
|
||||||
|
HandLog,
|
||||||
|
Trigger,
|
||||||
|
TriggerType,
|
||||||
|
TriggerConfig,
|
||||||
|
ApprovalRequest,
|
||||||
|
HandsState,
|
||||||
|
HandsEvent,
|
||||||
|
HandContext,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Machine
|
||||||
|
export { handMachine, getHandStatusFromState } from './machine';
|
||||||
|
|
||||||
|
// Store
|
||||||
|
export { handsStore } from './store';
|
||||||
|
export type { HandsStore } from './store';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useHandsState,
|
||||||
|
useHands,
|
||||||
|
useHand,
|
||||||
|
useApprovalQueue,
|
||||||
|
useTriggers,
|
||||||
|
useRun,
|
||||||
|
useHandsLoading,
|
||||||
|
useHandsError,
|
||||||
|
useHandsActions,
|
||||||
|
} from './hooks';
|
||||||
166
desktop/src/domains/hands/machine.ts
Normal file
166
desktop/src/domains/hands/machine.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Hands State Machine
|
||||||
|
*
|
||||||
|
* XState machine for managing hand execution lifecycle.
|
||||||
|
* Provides predictable state transitions for automation tasks.
|
||||||
|
*/
|
||||||
|
import { setup, assign, fromPromise } from 'xstate';
|
||||||
|
import type { HandContext, HandsEvent } from './types';
|
||||||
|
|
||||||
|
// === Machine Setup ===
|
||||||
|
|
||||||
|
export const handMachine = setup({
|
||||||
|
types: {
|
||||||
|
context: {} as HandContext,
|
||||||
|
events: {} as HandsEvent,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setRunId: assign({
|
||||||
|
runId: (_, params: { runId: string }) => params.runId,
|
||||||
|
}),
|
||||||
|
setError: assign({
|
||||||
|
error: (_, params: { error: string }) => params.error,
|
||||||
|
}),
|
||||||
|
setResult: assign({
|
||||||
|
result: (_, params: { result: unknown }) => params.result,
|
||||||
|
}),
|
||||||
|
setProgress: assign({
|
||||||
|
progress: (_, params: { progress: number }) => params.progress,
|
||||||
|
}),
|
||||||
|
clearError: assign({
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
resetContext: assign({
|
||||||
|
runId: null,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
progress: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
guards: {
|
||||||
|
hasError: ({ context }) => context.error !== null,
|
||||||
|
isApproved: ({ event }) => event.type === 'APPROVE',
|
||||||
|
},
|
||||||
|
}).createMachine({
|
||||||
|
id: 'hand',
|
||||||
|
initial: 'idle',
|
||||||
|
context: {
|
||||||
|
handId: '',
|
||||||
|
handName: '',
|
||||||
|
runId: null,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
progress: 0,
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
idle: {
|
||||||
|
on: {
|
||||||
|
START: {
|
||||||
|
target: 'running',
|
||||||
|
actions: {
|
||||||
|
type: 'setRunId',
|
||||||
|
params: () => ({ runId: `run_${Date.now()}` }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
entry: assign({ progress: 0 }),
|
||||||
|
on: {
|
||||||
|
APPROVE: {
|
||||||
|
target: 'needs_approval',
|
||||||
|
},
|
||||||
|
COMPLETE: {
|
||||||
|
target: 'success',
|
||||||
|
actions: {
|
||||||
|
type: 'setResult',
|
||||||
|
params: ({ event }) => ({ result: (event as { result: unknown }).result }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ERROR: {
|
||||||
|
target: 'error',
|
||||||
|
actions: {
|
||||||
|
type: 'setError',
|
||||||
|
params: ({ event }) => ({ error: (event as { error: string }).error }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CANCEL: {
|
||||||
|
target: 'cancelled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
needs_approval: {
|
||||||
|
on: {
|
||||||
|
APPROVE: 'running',
|
||||||
|
REJECT: 'idle',
|
||||||
|
CANCEL: 'idle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
on: {
|
||||||
|
RESET: {
|
||||||
|
target: 'idle',
|
||||||
|
actions: 'resetContext',
|
||||||
|
},
|
||||||
|
START: {
|
||||||
|
target: 'running',
|
||||||
|
actions: {
|
||||||
|
type: 'setRunId',
|
||||||
|
params: () => ({ runId: `run_${Date.now()}` }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
on: {
|
||||||
|
RESET: {
|
||||||
|
target: 'idle',
|
||||||
|
actions: 'resetContext',
|
||||||
|
},
|
||||||
|
START: {
|
||||||
|
target: 'running',
|
||||||
|
actions: {
|
||||||
|
type: 'setRunId',
|
||||||
|
params: () => ({ runId: `run_${Date.now()}` }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
on: {
|
||||||
|
RESET: {
|
||||||
|
target: 'idle',
|
||||||
|
actions: 'resetContext',
|
||||||
|
},
|
||||||
|
START: {
|
||||||
|
target: 'running',
|
||||||
|
actions: {
|
||||||
|
type: 'setRunId',
|
||||||
|
params: () => ({ runId: `run_${Date.now()}` }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Helper to get status from machine state ===
|
||||||
|
|
||||||
|
export function getHandStatusFromState(stateValue: string): import('./types').HandStatus {
|
||||||
|
switch (stateValue) {
|
||||||
|
case 'idle':
|
||||||
|
return 'idle';
|
||||||
|
case 'running':
|
||||||
|
return 'running';
|
||||||
|
case 'needs_approval':
|
||||||
|
return 'needs_approval';
|
||||||
|
case 'success':
|
||||||
|
return 'idle'; // Success maps back to idle
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'idle';
|
||||||
|
default:
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
105
desktop/src/domains/hands/store.ts
Normal file
105
desktop/src/domains/hands/store.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Hands Domain Store
|
||||||
|
*
|
||||||
|
* Valtio-based state management for hands/automation.
|
||||||
|
*/
|
||||||
|
import { proxy, subscribe } from 'valtio';
|
||||||
|
import type { Hand, HandRun, Trigger, ApprovalRequest, HandsState } from './types';
|
||||||
|
|
||||||
|
// === Store Interface ===
|
||||||
|
|
||||||
|
export interface HandsStore extends HandsState {
|
||||||
|
// Actions
|
||||||
|
setHands: (hands: Hand[]) => void;
|
||||||
|
updateHand: (id: string, updates: Partial<Hand>) => void;
|
||||||
|
addRun: (run: HandRun) => void;
|
||||||
|
updateRun: (runId: string, updates: Partial<HandRun>) => void;
|
||||||
|
setTriggers: (triggers: Trigger[]) => void;
|
||||||
|
updateTrigger: (id: string, updates: Partial<Trigger>) => void;
|
||||||
|
addApproval: (request: ApprovalRequest) => void;
|
||||||
|
removeApproval: (id: string) => void;
|
||||||
|
clearApprovals: () => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Create Proxy State ===
|
||||||
|
|
||||||
|
export const handsStore = proxy<HandsStore>({
|
||||||
|
// Initial state
|
||||||
|
hands: [],
|
||||||
|
runs: {},
|
||||||
|
triggers: [],
|
||||||
|
approvalQueue: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// === Actions ===
|
||||||
|
|
||||||
|
setHands: (hands: Hand[]) => {
|
||||||
|
handsStore.hands = hands;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHand: (id: string, updates: Partial<Hand>) => {
|
||||||
|
const hand = handsStore.hands.find(h => h.id === id);
|
||||||
|
if (hand) {
|
||||||
|
Object.assign(hand, updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addRun: (run: HandRun) => {
|
||||||
|
handsStore.runs[run.runId] = run;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRun: (runId: string, updates: Partial<HandRun>) => {
|
||||||
|
if (handsStore.runs[runId]) {
|
||||||
|
Object.assign(handsStore.runs[runId], updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTriggers: (triggers: Trigger[]) => {
|
||||||
|
handsStore.triggers = triggers;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTrigger: (id: string, updates: Partial<Trigger>) => {
|
||||||
|
const trigger = handsStore.triggers.find(t => t.id === id);
|
||||||
|
if (trigger) {
|
||||||
|
Object.assign(trigger, updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addApproval: (request: ApprovalRequest) => {
|
||||||
|
// Check if already exists
|
||||||
|
const exists = handsStore.approvalQueue.some(a => a.id === request.id);
|
||||||
|
if (!exists) {
|
||||||
|
handsStore.approvalQueue.push(request);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeApproval: (id: string) => {
|
||||||
|
const index = handsStore.approvalQueue.findIndex(a => a.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
handsStore.approvalQueue.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearApprovals: () => {
|
||||||
|
handsStore.approvalQueue = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading: boolean) => {
|
||||||
|
handsStore.isLoading = loading;
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
handsStore.error = error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Dev Mode Logging ===
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
subscribe(handsStore, (ops) => {
|
||||||
|
console.log('[HandsStore] Changes:', ops);
|
||||||
|
});
|
||||||
|
}
|
||||||
123
desktop/src/domains/hands/types.ts
Normal file
123
desktop/src/domains/hands/types.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Hands Domain Types
|
||||||
|
*
|
||||||
|
* Core types for the automation/hands system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HandRequirement {
|
||||||
|
description: string;
|
||||||
|
met: boolean;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hand {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: HandStatus;
|
||||||
|
currentRunId?: string;
|
||||||
|
requirements_met?: boolean;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
requirements?: HandRequirement[];
|
||||||
|
tools?: string[];
|
||||||
|
metrics?: string[];
|
||||||
|
toolCount?: number;
|
||||||
|
metricCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HandStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'running'
|
||||||
|
| 'needs_approval'
|
||||||
|
| 'error'
|
||||||
|
| 'unavailable'
|
||||||
|
| 'setup_needed';
|
||||||
|
|
||||||
|
export interface HandRun {
|
||||||
|
runId: string;
|
||||||
|
handId: string;
|
||||||
|
handName: string;
|
||||||
|
status: 'running' | 'completed' | 'error' | 'cancelled';
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
progress?: number;
|
||||||
|
logs?: HandLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandLog {
|
||||||
|
timestamp: Date;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trigger {
|
||||||
|
id: string;
|
||||||
|
handId: string;
|
||||||
|
type: TriggerType;
|
||||||
|
enabled: boolean;
|
||||||
|
config: TriggerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerType = 'manual' | 'schedule' | 'event' | 'webhook';
|
||||||
|
|
||||||
|
export interface TriggerConfig {
|
||||||
|
schedule?: string; // Cron expression
|
||||||
|
event?: string; // Event name
|
||||||
|
webhook?: {
|
||||||
|
path: string;
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRequest {
|
||||||
|
id: string;
|
||||||
|
handName: string;
|
||||||
|
runId: string;
|
||||||
|
action: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandsState {
|
||||||
|
hands: Hand[];
|
||||||
|
runs: Record<string, HandRun>;
|
||||||
|
triggers: Trigger[];
|
||||||
|
approvalQueue: ApprovalRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === XState Types ===
|
||||||
|
|
||||||
|
export type HandsEventType =
|
||||||
|
| 'START'
|
||||||
|
| 'APPROVE'
|
||||||
|
| 'REJECT'
|
||||||
|
| 'COMPLETE'
|
||||||
|
| 'ERROR'
|
||||||
|
| 'RESET'
|
||||||
|
| 'CANCEL';
|
||||||
|
|
||||||
|
export interface HandsEvent {
|
||||||
|
type: HandsEventType;
|
||||||
|
handId?: string;
|
||||||
|
runId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandContext {
|
||||||
|
handId: string;
|
||||||
|
handName: string;
|
||||||
|
runId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
result: unknown;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
105
desktop/src/shared/error-handling.ts
Normal file
105
desktop/src/shared/error-handling.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Shared Error Handling
|
||||||
|
*
|
||||||
|
* Unified error handling utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application error class with error code.
|
||||||
|
*/
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly cause?: Error
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a AppError from an unknown error.
|
||||||
|
*/
|
||||||
|
static fromUnknown(error: unknown, code: string): AppError {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return new AppError(getErrorMessage(error), code, isError(error) ? error : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network error class.
|
||||||
|
*/
|
||||||
|
export class NetworkError extends AppError {
|
||||||
|
constructor(message: string, public readonly statusCode?: number, cause?: Error) {
|
||||||
|
super(message, 'NETWORK_ERROR', cause);
|
||||||
|
this.name = 'NetworkError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error class.
|
||||||
|
*/
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string, public readonly field?: string, cause?: Error) {
|
||||||
|
super(message, 'VALIDATION_ERROR', cause);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication error class.
|
||||||
|
*/
|
||||||
|
export class AuthError extends AppError {
|
||||||
|
constructor(message: string = 'Authentication required', cause?: Error) {
|
||||||
|
super(message, 'AUTH_ERROR', cause);
|
||||||
|
this.name = 'AuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for Error.
|
||||||
|
*/
|
||||||
|
export function isError(error: unknown): error is Error {
|
||||||
|
return error instanceof Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error message from unknown error.
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (isError(error)) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap error with code.
|
||||||
|
*/
|
||||||
|
export function wrapError(error: unknown, code: string): AppError {
|
||||||
|
return AppError.fromUnknown(error, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a specific error class.
|
||||||
|
*/
|
||||||
|
export function isAppError(error: unknown): error is AppError {
|
||||||
|
return error instanceof AppError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNetworkError(error: unknown): error is NetworkError {
|
||||||
|
return error instanceof NetworkError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidationError(error: unknown): error is ValidationError {
|
||||||
|
return error instanceof ValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthError(error: unknown): error is AuthError {
|
||||||
|
return error instanceof AuthError;
|
||||||
|
}
|
||||||
31
desktop/src/shared/index.ts
Normal file
31
desktop/src/shared/index.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Shared Module
|
||||||
|
*
|
||||||
|
* Common utilities, types, and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
Result,
|
||||||
|
AsyncResult,
|
||||||
|
PaginatedResponse,
|
||||||
|
AsyncStatus,
|
||||||
|
AsyncState,
|
||||||
|
Entity,
|
||||||
|
NamedEntity,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
export {
|
||||||
|
AppError,
|
||||||
|
NetworkError,
|
||||||
|
ValidationError,
|
||||||
|
AuthError,
|
||||||
|
isError,
|
||||||
|
getErrorMessage,
|
||||||
|
wrapError,
|
||||||
|
isAppError,
|
||||||
|
isNetworkError,
|
||||||
|
isValidationError,
|
||||||
|
isAuthError,
|
||||||
|
} from './error-handling';
|
||||||
58
desktop/src/shared/types.ts
Normal file
58
desktop/src/shared/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Shared Types
|
||||||
|
*
|
||||||
|
* Common types used across domains.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result type for functional error handling.
|
||||||
|
*/
|
||||||
|
export type Result<T, E = Error> =
|
||||||
|
| { ok: true; value: T }
|
||||||
|
| { ok: false; error: E };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async result for promises.
|
||||||
|
*/
|
||||||
|
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated response for list endpoints.
|
||||||
|
*/
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common status for async operations.
|
||||||
|
*/
|
||||||
|
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic async state wrapper.
|
||||||
|
*/
|
||||||
|
export interface AsyncState<T, E = Error> {
|
||||||
|
status: AsyncStatus;
|
||||||
|
data: T | null;
|
||||||
|
error: E | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity with common fields.
|
||||||
|
*/
|
||||||
|
export interface Entity {
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named entity with name field.
|
||||||
|
*/
|
||||||
|
export interface NamedEntity extends Entity {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
1174
docs/superpowers/plans/2026-03-21-phase2-domain-reorganization.md
Normal file
1174
docs/superpowers/plans/2026-03-21-phase2-domain-reorganization.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user