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:
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;
|
||||
Reference in New Issue
Block a user