Files
openhuman/app/src/services/socketService.ts
T
Steven EnamakelandGitHub 7bcf43314a Improve service lifecycle E2E coverage and align CI workflows (#62)
* Add JSON-RPC schema definition and HTTP schema endpoint

- Introduced a new `schema.json` file containing detailed definitions for various JSON-RPC methods, including their inputs, outputs, and descriptions.
- Implemented a new HTTP endpoint `/schema` in the core server to serve the JSON-RPC schema, enhancing API documentation and accessibility.
- Updated the core HTTP router to include the new schema route, improving the overall structure and usability of the API.
- Enhanced error handling and response formatting in the server to ensure consistent feedback for schema requests.

* Update TypeScript configuration and refactor core RPC client

- Changed TypeScript target from ES2020 to ESNext and updated library references in `tsconfig.json` for improved compatibility with modern features.
- Refactored `coreRpcClient.ts` to enhance JSON-RPC request handling, including the introduction of legacy method aliases and improved error handling.
- Updated API service methods in `authApi.ts` and `channelConnectionsApi.ts` to utilize the new core RPC client structure, streamlining authentication and channel connection processes.
- Added new utility functions for managing JSON-RPC requests and responses, improving code organization and maintainability.
- Enhanced test coverage for the new RPC client methods and refactored existing tests to align with the updated structure.

* Enhance Tauri configuration and refactor daemon program arguments

- Updated `tauri.conf.json` to include additional macOS infoPlist settings for better application identification and icon management.
- Refactored the `daemon_program_args` function in `common.rs` to improve clarity by renaming the parameter to `_exe`, indicating it is unused. This change enhances code readability and maintainability.

* Refactor Tauri configuration by removing unused macOS infoPlist settings

- Updated `tauri.conf.json` to streamline macOS configuration by removing unnecessary infoPlist entries while retaining essential settings for the application.
- This change enhances clarity and maintainability of the Tauri configuration file.

* Enhance authentication flow and testing documentation

- Introduced a new `isAuthBootstrapComplete` state in the authentication slice to manage the completion of the authentication bootstrap process.
- Updated the `UserProvider` to set the `isAuthBootstrapComplete` state based on the authentication status, improving session restoration logic.
- Modified route components (`DefaultRedirect`, `ProtectedRoute`, `PublicRoute`) to conditionally render based on the `isAuthBootstrapComplete` state, enhancing user experience during the authentication process.
- Added a comprehensive testing guide in `CLAUDE.md`, detailing unit and E2E testing practices, including setup, authoring rules, and a checklist for test coverage.
- Updated the `SettingsHome` component to redirect to the home page instead of the login page upon logout, streamlining user navigation.
- Enhanced the `LocalModelPanel` to track download progress and manage local AI assets more effectively, improving overall functionality.

* Add resolutions for @tauri-apps/api dependency in package.json

- Introduced a resolutions field in package.json to enforce the use of @tauri-apps/api version 2.10.1, ensuring compatibility across workspaces.
- Updated dependencies in app/package.json to include @tauri-apps/api version 2.10.1, aligning with the new resolution.
- Adjusted yarn.lock to reflect the updated version of @tauri-apps/api, enhancing dependency management and consistency.

* Refactor Tauri configuration and enhance E2E build process

- Updated `tauri.conf.json` to remove unused resource paths, streamlining the configuration for better maintainability.
- Modified `wdio.conf.ts` to improve application path resolution for macOS, allowing for multiple bundle base checks to enhance compatibility.
- Refactored `e2e-build.sh` to disable updater artifacts for E2E builds and introduced a conditional cargo clean mechanism, improving build efficiency and clarity.

* Enhance E2E testing setup and documentation

- Updated `CLAUDE.md` to clarify the default behavior of `OPENHUMAN_WORKSPACE` in `e2e-run-spec.sh`, emphasizing automatic creation and cleanup for reproducible E2E runs.
- Modified `e2e-run-spec.sh` to implement automatic temporary workspace creation when `OPENHUMAN_WORKSPACE` is not set, improving usability for debugging and testing.
- Enhanced cleanup logic in `e2e-run-spec.sh` to ensure proper removal of temporary workspaces after tests, contributing to a cleaner testing environment.

* Implement shared mock backend for testing and enhance documentation

- Introduced a shared mock backend for unit and integration tests, allowing for deterministic API behavior across app and Rust tests.
- Updated `CLAUDE.md` to include detailed instructions on using the shared mock backend, including key admin endpoints and manual run commands.
- Modified `package.json` to add scripts for running the mock API server and Rust tests with the mock backend.
- Refactored test setup to utilize the new mock backend, improving test reliability and isolation.
- Removed obsolete MSW handlers and server setup, streamlining the testing framework.

* Enhance authentication state management and testing coverage

- Introduced `isAuthBootstrapComplete` state in the authentication slice to track the completion of the authentication process.
- Updated `ProtectedRoute` and `PublicRoute` components to utilize the new state, improving user experience during authentication.
- Enhanced test cases for `ProtectedRoute` and `PublicRoute` to reflect the updated authentication state structure.
- Added a new end-to-end test for the authentication flow, ensuring proper handling of OAuth tokens and session management.
- Improved mock setup in tests to better simulate authentication scenarios, enhancing test reliability and coverage.

* Enhance README.md with architecture overview and component roles

- Added detailed descriptions of the OpenHuman architecture, highlighting the separation of business logic and UI components.
- Explained the roles of Rust and the UI in the monorepo, including the use of JSON-RPC, QuickJS, Vite, React, and Tauri.
- Documented the structure of controllers and the RPC surface, emphasizing the shared contract for automation and testing.
- Provided links to further documentation for architecture, frontend structure, and Tauri commands.

* Integrate ServiceBlockingGate component and enhance loading states

- Added the `ServiceBlockingGate` component to manage service availability and display appropriate loading screens.
- Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to show a loading indicator while authentication bootstrap is in progress.
- Implemented timeout handling in `UserProvider` for improved authentication state management.
- Introduced tests for `ServiceBlockingGate` to ensure proper rendering and functionality under various service states.

* Implement core RPC URL resolution and default hash route handling

- Added a function to resolve the core RPC URL based on the environment, improving flexibility for Tauri and non-Tauri contexts.
- Introduced a default hash route handler in the main application entry point to ensure proper navigation behavior.
- Updated the core RPC command to expose the resolved RPC URL, enhancing the integration with the frontend.
- Refactored the core RPC client to utilize the new URL resolution logic, ensuring consistent API calls.

* Refactor backend URL usage to API_BASE_URL

- Replaced all instances of BACKEND_URL with API_BASE_URL across various components and services to standardize API endpoint references.
- Updated OAuth provider configurations, settings panels, hooks, and services to ensure consistent API calls.
- Enhanced test setups to reflect the new API_BASE_URL, improving test reliability and alignment with the updated configuration.

* Implement RouteLoadingScreen for improved loading states

- Introduced a new `RouteLoadingScreen` component to provide a consistent loading experience across various routes.
- Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to utilize `RouteLoadingScreen` while waiting for authentication bootstrap completion.
- Enhanced `MiniSidebar` to hide on additional public/setup routes, improving user navigation experience.
- Refactored `UserProvider` to streamline authentication state management by removing unnecessary references.

* Add feature design workflow section to CLAUDE.md

- Introduced a comprehensive workflow for feature design, outlining steps from specification to UI implementation and testing.
- Emphasized the importance of grounding designs in existing codebases and defined planning rules for E2E scenarios.
- Provided detailed instructions for implementing features in Rust, conducting JSON-RPC tests, and building UI components in the Tauri app.

* Refactor backend URL handling and improve OAuth flow

- Replaced static API_BASE_URL references with dynamic backend URL resolution across various components and services, enhancing flexibility for Tauri and non-Tauri environments.
- Updated OAuth provider configurations to utilize the new backend URL logic, ensuring consistent login URL generation.
- Refactored API client and socket service to fetch the backend URL dynamically, improving reliability in different deployment contexts.
- Introduced a new service for resolving the backend URL, streamlining the configuration and enhancing test setups.

* Add debug logging guidelines to CLAUDE.md

- Introduced comprehensive guidelines for implementing development-oriented debug logging in both Rust and the app.
- Emphasized the importance of logging at appropriate levels (`debug`/`trace`) and following existing patterns for consistency.
- Provided instructions on avoiding sensitive information in logs and ensuring terminal output is grep-friendly for easier debugging during development.

* Enhance service management with new mock functionality and E2E tests

- Added a mock service manager to facilitate deterministic service behavior during end-to-end tests, enabling better simulation of service states.
- Introduced new buttons in the `ServiceBlockingGate` component for restarting and uninstalling services, improving user control over service management.
- Implemented a comprehensive E2E test suite for the service connectivity flow, covering installation, starting, stopping, restarting, and uninstalling services.
- Updated package scripts to include a new E2E test for service connectivity, enhancing testing coverage and reliability.
- Refactored service operations to support mock functionality, ensuring consistent behavior across testing and production environments.

* Enhance ServiceBlockingGate with improved logging and periodic health polling

- Introduced periodic health polling in the `ServiceBlockingGate` component to refresh service status every 3 seconds, enhancing responsiveness to service state changes.
- Added detailed logging for various operations, including service status checks and error handling, to improve traceability and debugging.
- Updated E2E tests to include logging steps for better visibility during service connectivity flow tests.
- Refactored error handling to ensure consistent logging of error messages across service operations.

* Refactor E2E testing scripts and enhance CLAUDE.md documentation

- Updated paths in CLAUDE.md to reflect the new location of the E2E run script, ensuring accurate instructions for running tests.
- Removed outdated E2E test scripts from package.json and migrated relevant functionality to app/package.json for better organization.
- Introduced new E2E testing scripts for specific flows (auth, login, payment, etc.) to streamline testing processes and improve modularity.
- Added a script to run all E2E flows sequentially, enhancing test coverage and simplifying execution.
- Improved documentation for E2E testing procedures in CLAUDE.md, providing clearer guidance for developers.

* Refactor imports and enhance code readability

- Removed duplicate import of `ServiceBlockingGate` in `App.tsx` for cleaner code.
- Improved readability in `MiniSidebar.tsx` by formatting conditional statements.
- Reordered imports in `PublicRoute.tsx` for consistency.
- Enhanced formatting in `ServiceBlockingGate.tsx` for better clarity in asynchronous operations.
- Streamlined import statements in various test files and components for improved organization.
- Updated `LocalModelPanel.tsx` to enhance button disable logic readability.
- Refactored CORS headers in `jsonrpc.rs` for better formatting.

* ci: align build and test workflows with app workspace e2e

* ci: align release and typecheck workflows with current workspace

* Enhance ServiceBlockingGate functionality with improved refresh options

- Introduced a new `RefreshOptions` type to customize the behavior of the `refreshStatus` function, allowing for conditional error clearing and status checking.
- Updated the `refreshStatus` function to utilize the new options, enhancing control over service state updates.
- Modified the button click handler to pass the new options, improving user experience during service refresh operations.
- Adjusted state updates to prevent unnecessary re-renders and maintain consistency in service state management.

* Refactor RefreshOptions type for improved clarity in ServiceBlockingGate

- Consolidated the definition of the `RefreshOptions` type into a single line for better readability.
- Maintained existing functionality while enhancing code clarity in the `ServiceBlockingGate` component.
2026-03-29 18:14:13 -07:00

380 lines
11 KiB
TypeScript

import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import debug from 'debug';
import { io, Socket } from 'socket.io-client';
import { MCPTool, MCPToolCall, SocketIOMCPTransportImpl } from '../lib/mcp';
import { skillManager, syncToolsToBackend } from '../lib/skills';
import { getBackendUrl } from '../services/backendUrl';
import { store } from '../store';
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels';
import { IS_DEV } from '../utils/config';
import { createSafeLogData, sanitizeError } from '../utils/sanitize';
// Socket service logger using debug package
// Enable logging by setting DEBUG=socket* in environment or localStorage
const socketLog = debug('socket');
const socketWarn = debug('socket:warn');
const socketError = debug('socket:error');
// Enable socket logging in development by default
if (IS_DEV) {
debug.enable('socket*');
}
interface JwtPayload {
tgUserId?: string;
userId?: string;
sub?: string;
}
interface ChannelConnectionUpdatedEvent {
channel: ChannelType;
authMode: ChannelAuthMode;
status: ChannelConnectionStatus;
lastError?: string;
capabilities?: string[];
}
function isChannelConnectionUpdatePayload(value: unknown): value is ChannelConnectionUpdatedEvent {
if (!value || typeof value !== 'object') return false;
const obj = value as Record<string, unknown>;
const channel = obj.channel;
const authMode = obj.authMode;
const status = obj.status;
return (
(channel === 'telegram' || channel === 'discord') &&
(authMode === 'managed_dm' ||
authMode === 'oauth' ||
authMode === 'bot_token' ||
authMode === 'api_key') &&
(status === 'connected' ||
status === 'connecting' ||
status === 'disconnected' ||
status === 'error')
);
}
function getSocketUserId(): string {
const token = store.getState().auth.token;
if (!token) return '__pending__';
try {
const parts = token.split('.');
if (parts.length !== 3) return '__pending__';
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const payloadJson = atob(payloadBase64);
const payload = JSON.parse(payloadJson) as JwtPayload;
const id = payload.tgUserId || payload.userId || payload.sub;
return id || '__pending__';
} catch {
return '__pending__';
}
}
/**
* Check if running in Tauri (where Rust handles Socket.io).
* When true, this service should NOT create its own socket connection
* because the Rust-native SocketManager handles the connection and MCP.
*/
function isRustSocketMode(): boolean {
try {
return coreIsTauri();
} catch {
return false;
}
}
class SocketService {
private socket: Socket | null = null;
private token: string | null = null;
private mcpTransport: SocketIOMCPTransportImpl | null = null;
/**
* Connect to the socket server with authentication.
*
* NOTE: In Tauri mode, this is a NO-OP. The Rust-native SocketManager
* handles the connection. The frontend calls `connectRustSocket()` instead.
*/
connect(token: string): void {
void this.connectAsync(token);
}
private async connectAsync(token: string): Promise<void> {
if (!token) return;
// In Tauri mode, Rust handles the socket connection.
// Don't create a duplicate frontend socket.
if (isRustSocketMode()) {
socketLog('Skipping frontend socket — Rust SocketManager handles connection');
this.token = token;
return;
}
// Don't connect if already connected with the same token
if (this.socket?.connected && this.token === token) return;
// Disconnect existing connection if token changed or socket exists
if (this.socket) {
if (this.token !== token) {
this.disconnect();
} else if (this.socket.connected) {
return;
} else if (!this.socket.disconnected) {
// Socket is connecting, wait for it
return;
}
}
this.token = token;
const uid = getSocketUserId();
store.dispatch(setStatusForUser({ userId: uid, status: 'connecting' }));
const backendUrl = await getBackendUrl();
socketLog('Connecting', { userId: uid, backendUrl });
// Ensure we're not connecting to the wrong URL
if (backendUrl.includes('localhost:1420') || backendUrl.includes(':1420')) {
return;
}
const socketOptions = {
auth: { token },
path: '/socket.io/',
transports: ['websocket', 'polling'] as ('websocket' | 'polling')[],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
forceNew: true,
timeout: 2000,
upgrade: true,
query: {},
};
this.socket = io(backendUrl, socketOptions);
// Initialize MCP transport for client→server MCP requests
this.mcpTransport = new SocketIOMCPTransportImpl(this.socket);
// Connection event handlers
this.socket.on('connect', () => {
const socketId = this.socket?.id || null;
const uid = getSocketUserId();
socketLog('Connected', { socketId, userId: uid });
store.dispatch(setStatusForUser({ userId: uid, status: 'connected' }));
store.dispatch(setSocketIdForUser({ userId: uid, socketId }));
syncToolsToBackend();
});
this.socket.on('ready', () => {
const uid = getSocketUserId();
socketLog('Server ready - authentication successful', { userId: uid });
});
this.socket.on('error', (error: unknown) => {
const uid = getSocketUserId();
socketError('Server error', { userId: uid, error: sanitizeError(error) });
});
this.socket.on('disconnect', (reason: string) => {
const uid = getSocketUserId();
socketLog('Disconnected', { userId: uid, reason });
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
store.dispatch(setSocketIdForUser({ userId: uid, socketId: null }));
});
this.socket.on('connect_error', (error: Error) => {
const uid = getSocketUserId();
socketError('Connection error', { userId: uid, error: sanitizeError(error) });
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
});
this.socket.on('channel:connection-updated', data => {
if (!isChannelConnectionUpdatePayload(data)) return;
store.dispatch(
upsertChannelConnection({
channel: data.channel,
authMode: data.authMode,
patch: {
status: data.status,
lastError: data.lastError,
capabilities: data.capabilities ?? [],
},
})
);
});
// MCP handlers — only in web mode (Rust handles MCP in Tauri mode)
this.socket.on('mcp:listTools', (data: { requestId: string }) => {
socketLog('MCP list tools request', { requestId: data.requestId });
// Aggregate tools from all ready skills
const skillsState = store.getState().skills.skills;
const allTools: MCPTool[] = [];
for (const [skillId, skill] of Object.entries(skillsState)) {
if (skill.status === 'ready' && skill.tools?.length) {
for (const tool of skill.tools) {
allTools.push({
name: `${skillId}__${tool.name}`,
description: tool.description,
inputSchema: tool.inputSchema,
});
}
}
}
socketLog('MCP list tools response', {
requestId: data.requestId,
toolCount: allTools.length,
});
this.socket?.emit('mcp:listToolsResponse', { requestId: data.requestId, tools: allTools });
});
this.socket.on('mcp:toolCall', async (data: { requestId: string; toolCall: MCPToolCall }) => {
const { requestId, toolCall } = data;
socketLog('MCP tool call', createSafeLogData({ requestId, toolName: toolCall?.name }, data));
const separatorIdx = toolCall.name.indexOf('__');
if (separatorIdx === -1) {
socketError('MCP tool call - invalid tool name format', { requestId, name: toolCall.name });
this.socket?.emit('mcp:toolCallResponse', {
requestId,
result: {
content: [
{
type: 'text',
text: `Invalid tool name: ${toolCall.name}. Expected format: skillId__toolName`,
},
],
isError: true,
},
});
return;
}
const skillId = toolCall.name.substring(0, separatorIdx);
const toolName = toolCall.name.substring(separatorIdx + 2);
try {
const result = await skillManager.callTool(skillId, toolName, toolCall.arguments);
socketLog('MCP tool call success', { requestId, skillId, toolName });
this.socket?.emit('mcp:toolCallResponse', { requestId, result });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
socketError('MCP tool call failed', {
requestId,
skillId,
toolName,
error: sanitizeError(err),
});
this.socket?.emit('mcp:toolCallResponse', {
requestId,
result: { content: [{ type: 'text', text: msg }], isError: true },
});
}
});
this.socket.connect();
}
/**
* Disconnect from the socket server
*/
disconnect(): void {
if (this.socket) {
const uid = getSocketUserId();
socketLog('Disconnecting', { userId: uid });
this.socket.disconnect();
this.socket = null;
this.token = null;
this.mcpTransport = null;
store.dispatch(resetForUser({ userId: uid }));
}
}
/**
* Get the current socket instance
*/
getSocket(): Socket | null {
return this.socket;
}
/**
* Get the MCP transport for making client→server MCP requests
*/
getMCPTransport(): SocketIOMCPTransportImpl | null {
return this.mcpTransport;
}
/**
* Check if socket is connected
*/
isConnected(): boolean {
return this.socket?.connected || false;
}
/**
* Emit an event to the server
*/
emit(event: string, data?: unknown): void {
if (this.socket?.connected) {
socketLog('Emitting event', createSafeLogData({ event }, data));
this.socket.emit(event, data);
} else {
socketWarn('Cannot emit event - socket not connected', { event });
}
}
/**
* Listen to an event from the server
*/
on(event: string, callback: (...args: unknown[]) => void): void {
if (this.socket) {
const wrappedCallback = (...args: unknown[]) => {
socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
callback(...args);
};
this.socket.on(event, wrappedCallback);
}
}
/**
* Remove an event listener
*/
off(event: string, callback?: (...args: unknown[]) => void): void {
if (this.socket) {
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
}
}
}
/**
* Listen to an event once
*/
once(event: string, callback: (...args: unknown[]) => void): void {
if (this.socket) {
const wrappedCallback = (...args: unknown[]) => {
socketLog('Received event (once)', {
event,
argsCount: args.length,
hasData: args.length > 0,
});
callback(...args);
};
this.socket.once(event, wrappedCallback);
}
}
}
export const socketService = new SocketService();