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

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

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

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

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

View File

@@ -0,0 +1,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;