refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules
Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines) into focused sub-modules under kernel_commands/ and pipeline_commands/ directories. Add gateway module (commands, config, io, runtime), health_check, and 15 new TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel sub-systems (a2a, agent, chat, hands, skills, triggers). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,9 @@
|
||||
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
import { hashSha256, generateRandomString } from './crypto-utils';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('api-key-storage');
|
||||
|
||||
// Storage key prefixes
|
||||
const API_KEY_PREFIX = 'zclaw_api_key_';
|
||||
@@ -248,8 +251,8 @@ export async function getApiKey(type: ApiKeyType): Promise<string | null> {
|
||||
// Update last used timestamp
|
||||
metadata.lastUsedAt = Date.now();
|
||||
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
|
||||
} catch {
|
||||
// Ignore metadata parsing errors
|
||||
} catch (e) {
|
||||
logger.debug('Failed to update API key metadata', { type, error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +274,8 @@ export function getApiKeyMetadata(type: ApiKeyType): ApiKeyMetadata | null {
|
||||
|
||||
try {
|
||||
return JSON.parse(metaJson) as ApiKeyMetadata;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse API key metadata', { type, error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -290,8 +294,8 @@ export function listApiKeyMetadata(): ApiKeyMetadata[] {
|
||||
try {
|
||||
const meta = JSON.parse(localStorage.getItem(key) || '');
|
||||
metadata.push(meta);
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse API key metadata entry', { key, error: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,8 +435,8 @@ function logSecurityEvent(
|
||||
}
|
||||
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||
} catch {
|
||||
// Ignore logging failures
|
||||
} catch (e) {
|
||||
logger.debug('Failed to persist security event log', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +446,8 @@ function logSecurityEvent(
|
||||
export function getSecurityLog(): SecurityEvent[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SECURITY_LOG_KEY) || '[]');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read security event log', { error: e });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ function loadLocalLogs(): FrontendAuditEntry[] {
|
||||
if (!stored) return [];
|
||||
const logs = JSON.parse(stored) as FrontendAuditEntry[];
|
||||
return Array.isArray(logs) ? logs : [];
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to parse audit logs from localStorage', { error: e });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,8 +446,8 @@ export class AutonomyManager {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_AUTONOMY_CONFIGS.assisted, ...parsed };
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
log.debug('Failed to load autonomy config from localStorage', { error: e });
|
||||
}
|
||||
return DEFAULT_AUTONOMY_CONFIGS.assisted;
|
||||
}
|
||||
@@ -455,8 +455,8 @@ export class AutonomyManager {
|
||||
private saveConfig(): void {
|
||||
try {
|
||||
localStorage.setItem(AUTONOMY_CONFIG_KEY, JSON.stringify(this.config));
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
log.debug('Failed to save autonomy config to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +466,8 @@ export class AutonomyManager {
|
||||
if (raw) {
|
||||
this.auditLog = JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to load audit log from localStorage', { error: e });
|
||||
this.auditLog = [];
|
||||
}
|
||||
}
|
||||
@@ -474,8 +475,8 @@ export class AutonomyManager {
|
||||
private saveAuditLog(): void {
|
||||
try {
|
||||
localStorage.setItem(AUDIT_LOG_KEY, JSON.stringify(this.auditLog.slice(-100)));
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
log.debug('Failed to save audit log to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,8 +484,8 @@ export class AutonomyManager {
|
||||
try {
|
||||
const pending = Array.from(this.pendingApprovals.entries());
|
||||
localStorage.setItem('zclaw-pending-approvals', JSON.stringify(pending));
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
log.debug('Failed to persist pending approvals to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { secureStorage } from './secure-storage';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('embedding-client');
|
||||
|
||||
export interface EmbeddingConfig {
|
||||
provider: string;
|
||||
@@ -46,8 +49,8 @@ export function loadEmbeddingConfig(): EmbeddingConfig {
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...parsed, apiKey: '' };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
logger.debug('Failed to load embedding config', { error: e });
|
||||
}
|
||||
return {
|
||||
provider: 'local',
|
||||
@@ -66,8 +69,8 @@ export function saveEmbeddingConfig(config: EmbeddingConfig): void {
|
||||
try {
|
||||
const { apiKey: _, ...rest } = config;
|
||||
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(rest));
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
logger.debug('Failed to save embedding config', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +206,7 @@ export class EmbeddingClient {
|
||||
saveEmbeddingConfig(this.config);
|
||||
// Save apiKey to secure storage (fire-and-forget)
|
||||
if (config.apiKey !== undefined) {
|
||||
saveEmbeddingApiKey(config.apiKey).catch(() => {});
|
||||
saveEmbeddingApiKey(config.apiKey).catch(e => logger.debug('Failed to save embedding API key', { error: e }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +343,8 @@ export async function isEncryptedStorageActive(): Promise<boolean> {
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
return container.metadata?.version === STORAGE_VERSION;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to check encrypted storage version', { error: e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -369,8 +370,8 @@ export async function getStorageStats(): Promise<{
|
||||
// Count conversations without full decryption
|
||||
const conversations = await loadConversations();
|
||||
conversationCount = conversations.length;
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
} catch (e) {
|
||||
log.debug('Failed to parse storage stats', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
getSecurityStatusFallback,
|
||||
isNotFoundError,
|
||||
} from './api-fallbacks';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('GatewayApi');
|
||||
|
||||
// === Install all API methods onto GatewayClient prototype ===
|
||||
|
||||
@@ -131,7 +134,8 @@ export function installApiMethods(ClientClass: { prototype: GatewayClient }): vo
|
||||
proto.getSessionStats = async function (this: GatewayClient): Promise<any> {
|
||||
try {
|
||||
return await this.restGet('/api/stats/sessions');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('getSessionStats failed', { error: e });
|
||||
return { sessions: [] };
|
||||
}
|
||||
};
|
||||
@@ -619,7 +623,8 @@ export function installApiMethods(ClientClass: { prototype: GatewayClient }): vo
|
||||
proto.getCapabilities = async function (this: GatewayClient): Promise<{ capabilities: string[] }> {
|
||||
try {
|
||||
return await this.restGet('/api/capabilities');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('getCapabilities failed, using defaults', { error: e });
|
||||
return { capabilities: ['chat', 'agents', 'hands', 'workflows'] };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
|
||||
import { installApiMethods } from './gateway-api';
|
||||
import { createLogger } from './logger';
|
||||
import { GatewayHttpError } from './gateway-errors';
|
||||
|
||||
const log = createLogger('GatewayClient');
|
||||
|
||||
@@ -712,10 +713,8 @@ export class GatewayClient {
|
||||
const baseUrl = this.getRestBaseUrl();
|
||||
const response = await fetch(`${baseUrl}${path}`);
|
||||
if (!response.ok) {
|
||||
// For 404 errors, throw with status code so callers can handle gracefully
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}`);
|
||||
(error as any).status = response.status;
|
||||
throw error;
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -734,10 +733,7 @@ export class GatewayClient {
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
log.error(`POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}`);
|
||||
(error as any).status = response.status;
|
||||
(error as any).body = errorBody;
|
||||
throw error;
|
||||
throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -932,8 +928,8 @@ export class GatewayClient {
|
||||
} else {
|
||||
originalHandler?.call(this.ws!, evt);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
} catch (e) {
|
||||
log.debug('Parse error in handshake response handler', { error: e });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -969,14 +965,14 @@ export class GatewayClient {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(payload); } catch { /* ignore listener errors */ }
|
||||
try { cb(payload); } catch (e) { log.debug('Event listener error', { error: e }); }
|
||||
}
|
||||
}
|
||||
// Also emit wildcard
|
||||
const wildcardListeners = this.eventListeners.get('*');
|
||||
if (wildcardListeners) {
|
||||
for (const cb of wildcardListeners) {
|
||||
try { cb({ event, payload }); } catch { /* ignore */ }
|
||||
try { cb({ event, payload }); } catch (e) { log.debug('Wildcard event listener error', { error: e }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1003,7 +999,7 @@ export class GatewayClient {
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
try { this.ws.close(); } catch { /* ignore */ }
|
||||
try { this.ws.close(); } catch (e) { log.debug('WebSocket close failed during cleanup', { error: e }); }
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
@@ -1117,9 +1113,9 @@ export class GatewayClient {
|
||||
this.reconnectTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch {
|
||||
} catch (e) {
|
||||
/* close handler will trigger another reconnect */
|
||||
this.log('warn', `Reconnect attempt ${this.reconnectAttempts} failed`);
|
||||
this.log('warn', `Reconnect attempt ${this.reconnectAttempts} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
108
desktop/src/lib/gateway-errors.ts
Normal file
108
desktop/src/lib/gateway-errors.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* gateway-errors.ts - Gateway Error Classes & Security Utilities
|
||||
*
|
||||
* Extracted from gateway-client.ts for modularity.
|
||||
* Contains error classes and WebSocket security validation.
|
||||
*/
|
||||
|
||||
import { isLocalhost } from './gateway-storage';
|
||||
|
||||
// === Error Classes ===
|
||||
|
||||
/**
|
||||
* Security error for invalid WebSocket connections.
|
||||
* Thrown when non-localhost URLs use ws:// instead of wss://.
|
||||
*/
|
||||
export class SecurityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecurityError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection error for WebSocket/HTTP connection failures.
|
||||
*/
|
||||
export class ConnectionError extends Error {
|
||||
public readonly code?: string;
|
||||
public readonly recoverable: boolean;
|
||||
|
||||
constructor(message: string, code?: string, recoverable: boolean = true) {
|
||||
super(message);
|
||||
this.name = 'ConnectionError';
|
||||
this.code = code;
|
||||
this.recoverable = recoverable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout error for request/response timeouts.
|
||||
*/
|
||||
export class TimeoutError extends Error {
|
||||
public readonly timeout: number;
|
||||
|
||||
constructor(message: string, timeout: number) {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
this.timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error for handshake/token failures.
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
public readonly code?: string;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP error for REST API responses with non-2xx status codes.
|
||||
*/
|
||||
export class GatewayHttpError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly body?: unknown;
|
||||
|
||||
constructor(message: string, status: number, body?: unknown) {
|
||||
super(message);
|
||||
this.name = 'GatewayHttpError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
// === Utility Functions ===
|
||||
|
||||
/**
|
||||
* Validate WebSocket URL security.
|
||||
* Ensures non-localhost connections use WSS protocol.
|
||||
*
|
||||
* @param url - The WebSocket URL to validate
|
||||
* @throws SecurityError if non-localhost URL uses ws:// instead of wss://
|
||||
*/
|
||||
export function validateWebSocketSecurity(url: string): void {
|
||||
if (!url.startsWith('wss://') && !isLocalhost(url)) {
|
||||
throw new SecurityError(
|
||||
'Non-localhost connections must use WSS protocol for security. ' +
|
||||
`URL: ${url.replace(/:[^:@]+@/, ':****@')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique idempotency key for requests.
|
||||
* Uses crypto.randomUUID when available, otherwise falls back to manual generation.
|
||||
*/
|
||||
export function createIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
||||
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
|
||||
return `idem_${Date.now()}_${suffix}`;
|
||||
}
|
||||
117
desktop/src/lib/gateway-heartbeat.ts
Normal file
117
desktop/src/lib/gateway-heartbeat.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* gateway-heartbeat.ts - Gateway Heartbeat Methods
|
||||
*
|
||||
* Extracted from gateway-client.ts for modularity.
|
||||
* Installs heartbeat methods onto GatewayClient.prototype via mixin pattern.
|
||||
*
|
||||
* Heartbeat constants are defined here as module-level values
|
||||
* to avoid static field coupling with the main class.
|
||||
*/
|
||||
|
||||
import type { GatewayClient } from './gateway-client';
|
||||
|
||||
// === Heartbeat Constants ===
|
||||
|
||||
/** Interval between heartbeat pings (30 seconds) */
|
||||
export const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
/** Timeout for waiting for pong response (10 seconds) */
|
||||
export const HEARTBEAT_TIMEOUT = 10000;
|
||||
|
||||
/** Maximum missed heartbeats before reconnecting */
|
||||
export const MAX_MISSED_HEARTBEATS = 3;
|
||||
|
||||
// === Mixin Installer ===
|
||||
|
||||
/**
|
||||
* Install heartbeat methods onto GatewayClient.prototype.
|
||||
*
|
||||
* These methods access instance properties:
|
||||
* - this.ws: WebSocket | null
|
||||
* - this.heartbeatInterval: number | null
|
||||
* - this.heartbeatTimeout: number | null
|
||||
* - this.missedHeartbeats: number
|
||||
* - this.log(level, message): void
|
||||
* - this.stopHeartbeat(): void
|
||||
*/
|
||||
export function installHeartbeatMethods(ClientClass: { prototype: GatewayClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
/**
|
||||
* Start heartbeat to keep connection alive.
|
||||
* Called after successful connection.
|
||||
*/
|
||||
proto.startHeartbeat = function (this: GatewayClient): void {
|
||||
(this as any).stopHeartbeat();
|
||||
(this as any).missedHeartbeats = 0;
|
||||
|
||||
(this as any).heartbeatInterval = window.setInterval(() => {
|
||||
(this as any).sendHeartbeat();
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
(this as any).log('debug', 'Heartbeat started');
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop heartbeat.
|
||||
* Called on cleanup or disconnect.
|
||||
*/
|
||||
proto.stopHeartbeat = function (this: GatewayClient): void {
|
||||
const self = this as any;
|
||||
if (self.heartbeatInterval) {
|
||||
clearInterval(self.heartbeatInterval);
|
||||
self.heartbeatInterval = null;
|
||||
}
|
||||
if (self.heartbeatTimeout) {
|
||||
clearTimeout(self.heartbeatTimeout);
|
||||
self.heartbeatTimeout = null;
|
||||
}
|
||||
self.log('debug', 'Heartbeat stopped');
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a ping heartbeat to the server.
|
||||
*/
|
||||
proto.sendHeartbeat = function (this: GatewayClient): void {
|
||||
const self = this as any;
|
||||
if (self.ws?.readyState !== WebSocket.OPEN) {
|
||||
self.log('debug', 'Skipping heartbeat - WebSocket not open');
|
||||
return;
|
||||
}
|
||||
|
||||
self.missedHeartbeats++;
|
||||
if (self.missedHeartbeats > MAX_MISSED_HEARTBEATS) {
|
||||
self.log('warn', `Max missed heartbeats (${MAX_MISSED_HEARTBEATS}), reconnecting`);
|
||||
self.stopHeartbeat();
|
||||
self.ws.close(4000, 'Heartbeat timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send ping frame
|
||||
try {
|
||||
self.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
self.log('debug', `Ping sent (missed: ${self.missedHeartbeats})`);
|
||||
|
||||
// Set timeout for pong
|
||||
self.heartbeatTimeout = window.setTimeout(() => {
|
||||
self.log('warn', 'Heartbeat pong timeout');
|
||||
// Don't reconnect immediately, let the next heartbeat check
|
||||
}, HEARTBEAT_TIMEOUT);
|
||||
} catch (error) {
|
||||
self.log('error', `Failed to send heartbeat: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle pong response from server.
|
||||
*/
|
||||
proto.handlePong = function (this: GatewayClient): void {
|
||||
const self = this as any;
|
||||
self.missedHeartbeats = 0;
|
||||
if (self.heartbeatTimeout) {
|
||||
clearTimeout(self.heartbeatTimeout);
|
||||
self.heartbeatTimeout = null;
|
||||
}
|
||||
self.log('debug', 'Pong received, heartbeat reset');
|
||||
};
|
||||
}
|
||||
80
desktop/src/lib/gateway-reconnect.ts
Normal file
80
desktop/src/lib/gateway-reconnect.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* gateway-reconnect.ts - Gateway Reconnect Methods
|
||||
*
|
||||
* Extracted from gateway-client.ts for modularity.
|
||||
* Installs reconnect methods onto GatewayClient.prototype via mixin pattern.
|
||||
*/
|
||||
|
||||
import type { GatewayClient } from './gateway-client';
|
||||
|
||||
// === Reconnect Constants ===
|
||||
|
||||
/** Maximum number of reconnect attempts before giving up */
|
||||
export const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
|
||||
// === Mixin Installer ===
|
||||
|
||||
/**
|
||||
* Install reconnect methods onto GatewayClient.prototype.
|
||||
*
|
||||
* These methods access instance properties:
|
||||
* - this.reconnectAttempts: number
|
||||
* - this.reconnectInterval: number
|
||||
* - this.reconnectTimer: number | null
|
||||
* - this.log(level, message): void
|
||||
* - this.connect(): Promise<void>
|
||||
* - this.setState(state): void
|
||||
* - this.emitEvent(event, payload): void
|
||||
*/
|
||||
export function installReconnectMethods(ClientClass: { prototype: GatewayClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
/**
|
||||
* Schedule a reconnect attempt with exponential backoff.
|
||||
*/
|
||||
proto.scheduleReconnect = function (this: GatewayClient): void {
|
||||
const self = this as any;
|
||||
if (self.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
self.log('error', `Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Please reconnect manually.`);
|
||||
self.setState('disconnected');
|
||||
self.emitEvent('reconnect_failed', {
|
||||
attempts: self.reconnectAttempts,
|
||||
maxAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.reconnectAttempts++;
|
||||
self.setState('reconnecting');
|
||||
const delay = Math.min(self.reconnectInterval * Math.pow(1.5, self.reconnectAttempts - 1), 30000);
|
||||
|
||||
self.log('info', `Scheduling reconnect attempt ${self.reconnectAttempts} in ${delay}ms`);
|
||||
|
||||
// Emit reconnecting event for UI
|
||||
self.emitEvent('reconnecting', {
|
||||
attempt: self.reconnectAttempts,
|
||||
delay,
|
||||
maxAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
});
|
||||
|
||||
self.reconnectTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await self.connect();
|
||||
} catch (e) {
|
||||
/* close handler will trigger another reconnect */
|
||||
self.log('warn', `Reconnect attempt ${self.reconnectAttempts} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a pending reconnect attempt.
|
||||
*/
|
||||
proto.cancelReconnect = function (this: GatewayClient): void {
|
||||
const self = this as any;
|
||||
if (self.reconnectTimer !== null) {
|
||||
clearTimeout(self.reconnectTimer);
|
||||
self.reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
import { secureStorage } from './secure-storage';
|
||||
import { logKeyEvent, logSecurityEvent } from './security-audit';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('GatewayStorage');
|
||||
|
||||
// === WSS Configuration ===
|
||||
|
||||
@@ -35,7 +38,8 @@ export function isLocalhost(url: string): boolean {
|
||||
return parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '[::1]';
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('URL parsing failed in isLocalhost', { error: e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -87,7 +91,8 @@ export function getStoredGatewayUrl(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(GATEWAY_URL_STORAGE_KEY);
|
||||
return normalizeGatewayUrl(stored || DEFAULT_GATEWAY_URL);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('localStorage unavailable for gateway URL read', { error: e });
|
||||
return DEFAULT_GATEWAY_URL;
|
||||
}
|
||||
}
|
||||
@@ -96,7 +101,7 @@ export function setStoredGatewayUrl(url: string): string {
|
||||
const normalized = normalizeGatewayUrl(url || DEFAULT_GATEWAY_URL);
|
||||
try {
|
||||
localStorage.setItem(GATEWAY_URL_STORAGE_KEY, normalized);
|
||||
} catch { /* ignore localStorage failures */ }
|
||||
} catch (e) { logger.debug('localStorage unavailable for gateway URL write', { error: e }); }
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -142,13 +147,15 @@ export function getStoredGatewayToken(): string {
|
||||
console.warn('[GatewayStorage] Token is encrypted - use async version');
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Not JSON, so it's plaintext (legacy format)
|
||||
logger.debug('Legacy plaintext token format detected', { error: e });
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.warn('Failed to read gateway token from localStorage', { error: e });
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -202,8 +209,8 @@ export function setStoredGatewayToken(token: string): string {
|
||||
} else {
|
||||
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
/* ignore localStorage failures */
|
||||
} catch (e) {
|
||||
logger.warn('Failed to write gateway token to localStorage', { error: e });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
288
desktop/src/lib/gateway-stream.ts
Normal file
288
desktop/src/lib/gateway-stream.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* gateway-stream.ts - Gateway Stream Methods
|
||||
*
|
||||
* Extracted from gateway-client.ts for modularity.
|
||||
* Installs streaming methods onto GatewayClient.prototype via mixin pattern.
|
||||
*
|
||||
* Contains:
|
||||
* - chatStream (public): Send message with streaming response
|
||||
* - connectZclawStream (private): Connect to ZCLAW WebSocket for streaming
|
||||
* - handleZclawStreamEvent (private): Parse and dispatch stream events
|
||||
* - cancelStream (public): Cancel an ongoing stream
|
||||
*/
|
||||
|
||||
import type { ZclawStreamEvent } from './gateway-types';
|
||||
import type { GatewayClient } from './gateway-client';
|
||||
import { createIdempotencyKey } from './gateway-errors';
|
||||
|
||||
// === Mixin Installer ===
|
||||
|
||||
/**
|
||||
* Install streaming methods onto GatewayClient.prototype.
|
||||
*
|
||||
* These methods access instance properties:
|
||||
* - this.defaultAgentId: string
|
||||
* - this.zclawWs: WebSocket | null
|
||||
* - this.streamCallbacks: Map<string, StreamCallbacks>
|
||||
* - this.log(level, message): void
|
||||
* - this.getRestBaseUrl(): string
|
||||
* - this.fetchDefaultAgentId(): Promise<string | null>
|
||||
* - this.emitEvent(event, payload): void
|
||||
*/
|
||||
export function installStreamMethods(ClientClass: { prototype: GatewayClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
/**
|
||||
* Send message with streaming response (ZCLAW WebSocket).
|
||||
*/
|
||||
proto.chatStream = async function (
|
||||
this: GatewayClient,
|
||||
message: string,
|
||||
callbacks: {
|
||||
onDelta: (delta: string) => void;
|
||||
onTool?: (tool: string, input: string, output: string) => void;
|
||||
onHand?: (name: string, status: string, result?: unknown) => void;
|
||||
onComplete: () => void;
|
||||
onError: (error: string) => void;
|
||||
},
|
||||
opts?: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
): Promise<{ runId: string }> {
|
||||
const self = this as any;
|
||||
const agentId = opts?.agentId || self.defaultAgentId;
|
||||
const runId = createIdempotencyKey();
|
||||
const sessionId = opts?.sessionKey || crypto.randomUUID();
|
||||
|
||||
// If no agent ID, try to fetch from ZCLAW status (async, but we'll handle it in connectZclawStream)
|
||||
if (!agentId) {
|
||||
// Try to get default agent asynchronously
|
||||
self.fetchDefaultAgentId().then(() => {
|
||||
const resolvedAgentId = self.defaultAgentId;
|
||||
if (resolvedAgentId) {
|
||||
self.streamCallbacks.set(runId, callbacks);
|
||||
self.connectZclawStream(resolvedAgentId, runId, sessionId, message);
|
||||
} else {
|
||||
callbacks.onError('No agent available. Please ensure ZCLAW has at least one agent.');
|
||||
callbacks.onComplete();
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
callbacks.onError(`Failed to get agent: ${err}`);
|
||||
callbacks.onComplete();
|
||||
});
|
||||
return { runId };
|
||||
}
|
||||
|
||||
// Store callbacks for this run
|
||||
self.streamCallbacks.set(runId, callbacks);
|
||||
|
||||
// Connect to ZCLAW WebSocket if not connected
|
||||
self.connectZclawStream(agentId, runId, sessionId, message);
|
||||
|
||||
return { runId };
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to ZCLAW streaming WebSocket.
|
||||
*/
|
||||
proto.connectZclawStream = function (
|
||||
this: GatewayClient,
|
||||
agentId: string,
|
||||
runId: string,
|
||||
sessionId: string,
|
||||
message: string
|
||||
): void {
|
||||
const self = this as any;
|
||||
// Close existing connection if any
|
||||
if (self.zclawWs && self.zclawWs.readyState !== WebSocket.CLOSED) {
|
||||
self.zclawWs.close();
|
||||
}
|
||||
|
||||
// Build WebSocket URL
|
||||
// In dev mode, use Vite proxy; in production, use direct connection
|
||||
let wsUrl: string;
|
||||
if (typeof window !== 'undefined' && window.location.port === '1420') {
|
||||
// Dev mode: use Vite proxy with relative path
|
||||
wsUrl = `ws://${window.location.host}/api/agents/${agentId}/ws`;
|
||||
} else {
|
||||
// Production: extract from stored URL
|
||||
const httpUrl = self.getRestBaseUrl();
|
||||
wsUrl = httpUrl.replace(/^http/, 'ws') + `/api/agents/${agentId}/ws`;
|
||||
}
|
||||
|
||||
self.log('info', `Connecting to ZCLAW stream: ${wsUrl}`);
|
||||
|
||||
try {
|
||||
self.zclawWs = new WebSocket(wsUrl);
|
||||
|
||||
self.zclawWs.onopen = () => {
|
||||
self.log('info', 'ZCLAW WebSocket connected');
|
||||
// Send chat message using ZCLAW actual protocol
|
||||
const chatRequest = {
|
||||
type: 'message',
|
||||
content: message,
|
||||
session_id: sessionId,
|
||||
};
|
||||
self.zclawWs?.send(JSON.stringify(chatRequest));
|
||||
};
|
||||
|
||||
self.zclawWs.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
self.handleZclawStreamEvent(runId, data, sessionId);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
self.log('error', `Failed to parse stream event: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
self.zclawWs.onerror = (_event: Event) => {
|
||||
self.log('error', 'ZCLAW WebSocket error');
|
||||
const callbacks = self.streamCallbacks.get(runId);
|
||||
if (callbacks) {
|
||||
callbacks.onError('WebSocket connection failed');
|
||||
self.streamCallbacks.delete(runId);
|
||||
}
|
||||
};
|
||||
|
||||
self.zclawWs.onclose = (event: CloseEvent) => {
|
||||
self.log('info', `ZCLAW WebSocket closed: ${event.code} ${event.reason}`);
|
||||
const callbacks = self.streamCallbacks.get(runId);
|
||||
if (callbacks && event.code !== 1000) {
|
||||
callbacks.onError(`Connection closed: ${event.reason || 'unknown'}`);
|
||||
}
|
||||
self.streamCallbacks.delete(runId);
|
||||
self.zclawWs = null;
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
self.log('error', `Failed to create WebSocket: ${errorMessage}`);
|
||||
const callbacks = self.streamCallbacks.get(runId);
|
||||
if (callbacks) {
|
||||
callbacks.onError(errorMessage);
|
||||
self.streamCallbacks.delete(runId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle ZCLAW stream events.
|
||||
*/
|
||||
proto.handleZclawStreamEvent = function (
|
||||
this: GatewayClient,
|
||||
runId: string,
|
||||
data: ZclawStreamEvent,
|
||||
sessionId: string
|
||||
): void {
|
||||
const self = this as any;
|
||||
const callbacks = self.streamCallbacks.get(runId);
|
||||
if (!callbacks) return;
|
||||
|
||||
switch (data.type) {
|
||||
// ZCLAW actual event types
|
||||
case 'text_delta':
|
||||
// Stream delta content
|
||||
if (data.content) {
|
||||
callbacks.onDelta(data.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'phase':
|
||||
// Phase change: streaming | done
|
||||
if (data.phase === 'done') {
|
||||
callbacks.onComplete();
|
||||
self.streamCallbacks.delete(runId);
|
||||
if (self.zclawWs) {
|
||||
self.zclawWs.close(1000, 'Stream complete');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
// Final response with tokens info
|
||||
if (data.content) {
|
||||
// If we haven't received any deltas yet, send the full response
|
||||
// This handles non-streaming responses
|
||||
}
|
||||
// Mark complete if phase done wasn't sent
|
||||
callbacks.onComplete();
|
||||
self.streamCallbacks.delete(runId);
|
||||
if (self.zclawWs) {
|
||||
self.zclawWs.close(1000, 'Stream complete');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
// Typing indicator: { state: 'start' | 'stop' }
|
||||
// Can be used for UI feedback
|
||||
break;
|
||||
|
||||
case 'tool_call':
|
||||
// Tool call event
|
||||
if (callbacks.onTool && data.tool) {
|
||||
callbacks.onTool(data.tool, JSON.stringify(data.input || {}), data.output || '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
if (callbacks.onTool && data.tool) {
|
||||
callbacks.onTool(data.tool, '', String(data.result || data.output || ''));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
if (callbacks.onHand && data.hand_name) {
|
||||
callbacks.onHand(data.hand_name, data.hand_status || 'triggered', data.hand_result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
callbacks.onError(data.message || data.code || data.content || 'Unknown error');
|
||||
self.streamCallbacks.delete(runId);
|
||||
if (self.zclawWs) {
|
||||
self.zclawWs.close(1011, 'Error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'connected':
|
||||
// Connection established
|
||||
self.log('info', `ZCLAW agent connected: ${data.agent_id}`);
|
||||
break;
|
||||
|
||||
case 'agents_updated':
|
||||
// Agents list updated
|
||||
self.log('debug', 'Agents list updated');
|
||||
break;
|
||||
|
||||
default:
|
||||
// Emit unknown events for debugging
|
||||
self.log('debug', `Stream event: ${data.type}`);
|
||||
}
|
||||
|
||||
// Also emit to general 'agent' event listeners
|
||||
self.emitEvent('agent', {
|
||||
stream: data.type === 'text_delta' ? 'assistant' : data.type,
|
||||
delta: data.content,
|
||||
content: data.content,
|
||||
runId,
|
||||
sessionId,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel an ongoing stream.
|
||||
*/
|
||||
proto.cancelStream = function (this: GatewayClient, runId: string): void {
|
||||
const self = this as any;
|
||||
const callbacks = self.streamCallbacks.get(runId);
|
||||
if (callbacks) {
|
||||
callbacks.onError('Stream cancelled');
|
||||
self.streamCallbacks.delete(runId);
|
||||
}
|
||||
if (self.zclawWs && self.zclawWs.readyState === WebSocket.OPEN) {
|
||||
self.zclawWs.close(1000, 'User cancelled');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,9 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { isTauriRuntime } from './tauri-gateway';
|
||||
import { generateRandomString } from './crypto-utils';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('intelligence-client');
|
||||
|
||||
import {
|
||||
intelligence,
|
||||
@@ -339,7 +342,8 @@ function parseTags(tags: string | string[]): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
return JSON.parse(tags);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('JSON parse failed for tags, using fallback', { error: e });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -358,8 +362,8 @@ function getFallbackStore(): FallbackMemoryStore {
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read fallback store from localStorage', { error: e });
|
||||
}
|
||||
return { memories: [] };
|
||||
}
|
||||
@@ -367,8 +371,8 @@ function getFallbackStore(): FallbackMemoryStore {
|
||||
function saveFallbackStore(store: FallbackMemoryStore): void {
|
||||
try {
|
||||
localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save to localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save fallback store to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,8 +471,8 @@ const fallbackMemory = {
|
||||
try {
|
||||
const serialized = JSON.stringify(store.memories);
|
||||
storageSizeBytes = new Blob([serialized]).size;
|
||||
} catch {
|
||||
// Ignore serialization errors
|
||||
} catch (e) {
|
||||
logger.debug('Failed to estimate storage size', { error: e });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -718,8 +722,8 @@ function loadIdentitiesFromStorage(): Map<string, IdentityFiles> {
|
||||
const parsed = JSON.parse(stored) as Record<string, IdentityFiles>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load identities from localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load identities from localStorage', { error: e });
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
@@ -728,8 +732,8 @@ function saveIdentitiesToStorage(identities: Map<string, IdentityFiles>): void {
|
||||
try {
|
||||
const obj = Object.fromEntries(identities);
|
||||
localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(obj));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save identities to localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save identities to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,8 +743,8 @@ function loadProposalsFromStorage(): IdentityChangeProposal[] {
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentityChangeProposal[];
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load proposals from localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load proposals from localStorage', { error: e });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -748,8 +752,8 @@ function loadProposalsFromStorage(): IdentityChangeProposal[] {
|
||||
function saveProposalsToStorage(proposals: IdentityChangeProposal[]): void {
|
||||
try {
|
||||
localStorage.setItem(PROPOSALS_STORAGE_KEY, JSON.stringify(proposals));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save proposals to localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save proposals to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,8 +763,8 @@ function loadSnapshotsFromStorage(): IdentitySnapshot[] {
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentitySnapshot[];
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load snapshots from localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load snapshots from localStorage', { error: e });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -768,8 +772,8 @@ function loadSnapshotsFromStorage(): IdentitySnapshot[] {
|
||||
function saveSnapshotsToStorage(snapshots: IdentitySnapshot[]): void {
|
||||
try {
|
||||
localStorage.setItem(SNAPSHOTS_STORAGE_KEY, JSON.stringify(snapshots));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save snapshots to localStorage');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save snapshots to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export function isJsonSerializable(value: unknown): boolean {
|
||||
try {
|
||||
JSON.stringify(value);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
59
desktop/src/lib/kernel-a2a.ts
Normal file
59
desktop/src/lib/kernel-a2a.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* kernel-a2a.ts - Agent-to-Agent (A2A) methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installA2aMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
|
||||
export function installA2aMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
// ─── A2A (Agent-to-Agent) API ───
|
||||
|
||||
/**
|
||||
* Send a direct A2A message from one agent to another
|
||||
*/
|
||||
proto.a2aSend = async function (this: KernelClient, from: string, to: string, payload: unknown, messageType?: string): Promise<void> {
|
||||
await invoke('agent_a2a_send', {
|
||||
from,
|
||||
to,
|
||||
payload,
|
||||
messageType: messageType || 'notification',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Broadcast a message from an agent to all other agents
|
||||
*/
|
||||
proto.a2aBroadcast = async function (this: KernelClient, from: string, payload: unknown): Promise<void> {
|
||||
await invoke('agent_a2a_broadcast', { from, payload });
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover agents that have a specific capability
|
||||
*/
|
||||
proto.a2aDiscover = async function (this: KernelClient, capability: string): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
capabilities: Array<{ name: string; description: string }>;
|
||||
role: string;
|
||||
priority: number;
|
||||
}>> {
|
||||
return await invoke('agent_a2a_discover', { capability });
|
||||
};
|
||||
|
||||
/**
|
||||
* Delegate a task to another agent and wait for response
|
||||
*/
|
||||
proto.a2aDelegateTask = async function (this: KernelClient, from: string, to: string, task: string, timeoutMs?: number): Promise<unknown> {
|
||||
return await invoke('agent_a2a_delegate_task', {
|
||||
from,
|
||||
to,
|
||||
task,
|
||||
timeoutMs: timeoutMs || 30000,
|
||||
});
|
||||
};
|
||||
}
|
||||
135
desktop/src/lib/kernel-agent.ts
Normal file
135
desktop/src/lib/kernel-agent.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* kernel-agent.ts - Agent & Clone management methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installAgentMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
import type { AgentInfo, CreateAgentRequest, CreateAgentResponse } from './kernel-types';
|
||||
|
||||
export function installAgentMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
// ─── Agent Management ───
|
||||
|
||||
/**
|
||||
* List all agents
|
||||
*/
|
||||
proto.listAgents = async function (this: KernelClient): Promise<AgentInfo[]> {
|
||||
return invoke<AgentInfo[]>('agent_list');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get agent by ID
|
||||
*/
|
||||
proto.getAgent = async function (this: KernelClient, agentId: string): Promise<AgentInfo | null> {
|
||||
return invoke<AgentInfo | null>('agent_get', { agentId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new agent
|
||||
*/
|
||||
proto.createAgent = async function (this: KernelClient, request: CreateAgentRequest): Promise<CreateAgentResponse> {
|
||||
return invoke<CreateAgentResponse>('agent_create', {
|
||||
request: {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
systemPrompt: request.systemPrompt,
|
||||
provider: request.provider || 'anthropic',
|
||||
model: request.model || 'claude-sonnet-4-20250514',
|
||||
maxTokens: request.maxTokens || 4096,
|
||||
temperature: request.temperature || 0.7,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an agent
|
||||
*/
|
||||
proto.deleteAgent = async function (this: KernelClient, agentId: string): Promise<void> {
|
||||
return invoke('agent_delete', { agentId });
|
||||
};
|
||||
|
||||
// ─── Clone/Agent Adaptation (GatewayClient interface compatibility) ───
|
||||
|
||||
/**
|
||||
* List clones — maps to listAgents() with field adaptation
|
||||
*/
|
||||
proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
|
||||
const agents = await this.listAgents();
|
||||
const clones = agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.description,
|
||||
model: agent.model,
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
return { clones };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create clone — maps to createAgent()
|
||||
*/
|
||||
proto.createClone = async function (this: KernelClient, opts: {
|
||||
name: string;
|
||||
role?: string;
|
||||
model?: string;
|
||||
personality?: string;
|
||||
communicationStyle?: string;
|
||||
[key: string]: unknown;
|
||||
}): Promise<{ clone: any }> {
|
||||
const response = await this.createAgent({
|
||||
name: opts.name,
|
||||
description: opts.role,
|
||||
model: opts.model,
|
||||
});
|
||||
const clone = {
|
||||
id: response.id,
|
||||
name: response.name,
|
||||
role: opts.role,
|
||||
model: opts.model,
|
||||
personality: opts.personality,
|
||||
communicationStyle: opts.communicationStyle,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return { clone };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete clone — maps to deleteAgent()
|
||||
*/
|
||||
proto.deleteClone = async function (this: KernelClient, id: string): Promise<void> {
|
||||
return this.deleteAgent(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update clone — maps to kernel agent_update
|
||||
*/
|
||||
proto.updateClone = async function (this: KernelClient, id: string, updates: Record<string, unknown>): Promise<{ clone: unknown }> {
|
||||
await invoke('agent_update', {
|
||||
agentId: id,
|
||||
updates: {
|
||||
name: updates.name as string | undefined,
|
||||
description: updates.description as string | undefined,
|
||||
systemPrompt: updates.systemPrompt as string | undefined,
|
||||
model: updates.model as string | undefined,
|
||||
provider: updates.provider as string | undefined,
|
||||
maxTokens: updates.maxTokens as number | undefined,
|
||||
temperature: updates.temperature as number | undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Return updated clone representation
|
||||
const clone = {
|
||||
id,
|
||||
name: updates.name,
|
||||
role: updates.description || updates.role,
|
||||
model: updates.model,
|
||||
personality: updates.personality,
|
||||
communicationStyle: updates.communicationStyle,
|
||||
systemPrompt: updates.systemPrompt,
|
||||
};
|
||||
return { clone };
|
||||
};
|
||||
}
|
||||
202
desktop/src/lib/kernel-chat.ts
Normal file
202
desktop/src/lib/kernel-chat.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* kernel-chat.ts - Chat & streaming methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installChatMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { createLogger } from './logger';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
import type { ChatResponse, StreamCallbacks, StreamChunkPayload } from './kernel-types';
|
||||
|
||||
const log = createLogger('KernelClient');
|
||||
|
||||
export function installChatMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
/**
|
||||
* Send a message and get a response
|
||||
*/
|
||||
proto.chat = async function (
|
||||
this: KernelClient,
|
||||
message: string,
|
||||
opts?: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
): Promise<{ runId: string; sessionId?: string; response?: string }> {
|
||||
const agentId = opts?.agentId || this.getDefaultAgentId();
|
||||
|
||||
if (!agentId) {
|
||||
throw new Error('No agent available');
|
||||
}
|
||||
|
||||
const response = await invoke<ChatResponse>('agent_chat', {
|
||||
request: {
|
||||
agentId,
|
||||
message,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
runId: `run_${Date.now()}`,
|
||||
sessionId: opts?.sessionKey,
|
||||
response: response.content,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message with streaming response via Tauri events
|
||||
*/
|
||||
proto.chatStream = async function (
|
||||
this: KernelClient,
|
||||
message: string,
|
||||
callbacks: StreamCallbacks,
|
||||
opts?: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
): Promise<{ runId: string }> {
|
||||
const runId = crypto.randomUUID();
|
||||
const sessionId = opts?.sessionKey || runId;
|
||||
const agentId = opts?.agentId || this.getDefaultAgentId();
|
||||
|
||||
if (!agentId) {
|
||||
callbacks.onError('No agent available');
|
||||
return { runId };
|
||||
}
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
try {
|
||||
// Set up event listener for stream chunks
|
||||
unlisten = await listen<StreamChunkPayload>('stream:chunk', (event) => {
|
||||
const payload = event.payload;
|
||||
|
||||
// Only process events for this session
|
||||
if (payload.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamEvent = payload.event;
|
||||
|
||||
switch (streamEvent.type) {
|
||||
case 'delta':
|
||||
callbacks.onDelta(streamEvent.delta);
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
log.debug('Tool started:', streamEvent.name, streamEvent.input);
|
||||
if (callbacks.onTool) {
|
||||
callbacks.onTool(
|
||||
streamEvent.name,
|
||||
JSON.stringify(streamEvent.input),
|
||||
''
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
log.debug('Tool ended:', streamEvent.name, streamEvent.output);
|
||||
if (callbacks.onTool) {
|
||||
callbacks.onTool(
|
||||
streamEvent.name,
|
||||
'',
|
||||
JSON.stringify(streamEvent.output)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'handStart':
|
||||
log.debug('Hand started:', streamEvent.name, streamEvent.params);
|
||||
if (callbacks.onHand) {
|
||||
callbacks.onHand(streamEvent.name, 'running', undefined);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'handEnd':
|
||||
log.debug('Hand ended:', streamEvent.name, streamEvent.result);
|
||||
if (callbacks.onHand) {
|
||||
callbacks.onHand(streamEvent.name, 'completed', streamEvent.result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'iteration_start':
|
||||
log.debug('Iteration started:', streamEvent.iteration, '/', streamEvent.maxIterations);
|
||||
// Don't need to notify user about iterations
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
log.debug('Stream complete:', streamEvent.inputTokens, streamEvent.outputTokens);
|
||||
callbacks.onComplete(streamEvent.inputTokens, streamEvent.outputTokens);
|
||||
// Clean up listener
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
log.error('Stream error:', streamEvent.message);
|
||||
callbacks.onError(streamEvent.message);
|
||||
// Clean up listener
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Invoke the streaming command
|
||||
await invoke('agent_chat_stream', {
|
||||
request: {
|
||||
agentId,
|
||||
sessionId,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
callbacks.onError(errorMessage);
|
||||
|
||||
// Clean up listener on error
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
return { runId };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a stream (no-op for internal kernel)
|
||||
*/
|
||||
proto.cancelStream = function (this: KernelClient, _runId: string): void {
|
||||
// No-op: internal kernel doesn't support stream cancellation
|
||||
};
|
||||
|
||||
// ─── Default Agent ───
|
||||
|
||||
/**
|
||||
* Fetch default agent ID (returns current default)
|
||||
*/
|
||||
proto.fetchDefaultAgentId = async function (this: KernelClient): Promise<string | null> {
|
||||
return this.getDefaultAgentId();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set default agent ID
|
||||
*/
|
||||
proto.setDefaultAgentId = function (this: KernelClient, agentId: string): void {
|
||||
(this as any).defaultAgentId = agentId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default agent ID
|
||||
*/
|
||||
proto.getDefaultAgentId = function (this: KernelClient): string {
|
||||
return (this as any).defaultAgentId || '';
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
174
desktop/src/lib/kernel-hands.ts
Normal file
174
desktop/src/lib/kernel-hands.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* kernel-hands.ts - Hands API methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installHandMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
|
||||
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
// ─── Hands API ───
|
||||
|
||||
/**
|
||||
* List all available hands
|
||||
*/
|
||||
proto.listHands = async function (this: KernelClient): Promise<{
|
||||
hands: {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
requirements_met?: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
tool_count?: number;
|
||||
tools?: string[];
|
||||
metric_count?: number;
|
||||
metrics?: string[];
|
||||
}[]
|
||||
}> {
|
||||
const hands = await invoke<Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
requirements_met?: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
tool_count?: number;
|
||||
tools?: string[];
|
||||
metric_count?: number;
|
||||
metrics?: string[];
|
||||
}>>('hand_list');
|
||||
return { hands: hands || [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get hand details
|
||||
*/
|
||||
proto.getHand = async function (this: KernelClient, name: string): Promise<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
requirements_met?: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
requirements?: { description?: string; name?: string; met?: boolean; satisfied?: boolean; details?: string; hint?: string }[];
|
||||
tools?: string[];
|
||||
metrics?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
tool_count?: number;
|
||||
metric_count?: number;
|
||||
}> {
|
||||
try {
|
||||
return await invoke('hand_get', { name });
|
||||
} catch (e) {
|
||||
const { createLogger } = await import('./logger');
|
||||
createLogger('KernelHands').debug('hand_get failed', { name, error: e });
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger/execute a hand
|
||||
*/
|
||||
proto.triggerHand = async function (this: KernelClient, name: string, params?: Record<string, unknown>, autonomyLevel?: string): Promise<{ runId: string; status: string }> {
|
||||
const result = await invoke<{ instance_id: string; status: string }>('hand_execute', {
|
||||
id: name,
|
||||
input: params || {},
|
||||
...(autonomyLevel ? { autonomyLevel } : {}),
|
||||
});
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get hand run status
|
||||
*/
|
||||
proto.getHandStatus = async function (this: KernelClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
|
||||
try {
|
||||
return await invoke('hand_run_status', { handName: name, runId });
|
||||
} catch (e) {
|
||||
const { createLogger } = await import('./logger');
|
||||
createLogger('KernelHands').debug('hand_run_status failed', { name, runId, error: e });
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Approve a hand execution
|
||||
*/
|
||||
proto.approveHand = async function (this: KernelClient, name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
|
||||
return await invoke('hand_approve', { handName: name, runId, approved, reason });
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a hand execution
|
||||
*/
|
||||
proto.cancelHand = async function (this: KernelClient, name: string, runId: string): Promise<{ status: string }> {
|
||||
return await invoke('hand_cancel', { handName: name, runId });
|
||||
};
|
||||
|
||||
/**
|
||||
* List hand runs (execution history)
|
||||
*/
|
||||
proto.listHandRuns = async function (this: KernelClient, name: string, opts?: { limit?: number; offset?: number }): Promise<{
|
||||
runs: {
|
||||
runId?: string;
|
||||
run_id?: string;
|
||||
id?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
started_at?: string;
|
||||
completedAt?: string;
|
||||
completed_at?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}[]
|
||||
}> {
|
||||
// Hand run history
|
||||
try {
|
||||
return await invoke('hand_run_list', { handName: name, ...opts });
|
||||
} catch (e) {
|
||||
const { createLogger } = await import('./logger');
|
||||
createLogger('KernelHands').debug('hand_run_list failed', { name, error: e });
|
||||
return { runs: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Approvals API ───
|
||||
|
||||
proto.listApprovals = async function (this: KernelClient, _status?: string): Promise<{
|
||||
approvals: Array<{
|
||||
id: string;
|
||||
handId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
input: Record<string, unknown>;
|
||||
}>
|
||||
}> {
|
||||
try {
|
||||
const approvals = await invoke<Array<{
|
||||
id: string;
|
||||
handId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
input: Record<string, unknown>;
|
||||
}>>('approval_list');
|
||||
return { approvals };
|
||||
} catch (error) {
|
||||
const { createLogger } = await import('./logger');
|
||||
createLogger('KernelClient').error('listApprovals error:', error);
|
||||
return { approvals: [] };
|
||||
}
|
||||
};
|
||||
|
||||
proto.respondToApproval = async function (this: KernelClient, approvalId: string, approved: boolean, reason?: string): Promise<void> {
|
||||
return invoke('approval_respond', { id: approvalId, approved, reason });
|
||||
};
|
||||
}
|
||||
116
desktop/src/lib/kernel-skills.ts
Normal file
116
desktop/src/lib/kernel-skills.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* kernel-skills.ts - Skills API methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installSkillMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
|
||||
/** Skill shape returned by list/refresh/create/update operations. */
|
||||
type SkillItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
tags: string[];
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
triggers: string[];
|
||||
category?: string;
|
||||
};
|
||||
|
||||
/** Skill list container shared by list/refresh responses. */
|
||||
type SkillListResult = { skills: SkillItem[] };
|
||||
|
||||
export function installSkillMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
// ─── Skills API ───
|
||||
|
||||
/**
|
||||
* List all discovered skills
|
||||
*/
|
||||
proto.listSkills = async function (this: KernelClient): Promise<SkillListResult> {
|
||||
const skills = await invoke<SkillItem[]>('skill_list');
|
||||
return { skills: skills || [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh skills from directory
|
||||
*/
|
||||
proto.refreshSkills = async function (this: KernelClient, skillDir?: string): Promise<SkillListResult> {
|
||||
const skills = await invoke<SkillItem[]>('skill_refresh', { skillDir: skillDir || null });
|
||||
return { skills: skills || [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new skill
|
||||
*/
|
||||
proto.createSkill = async function (this: KernelClient, skill: {
|
||||
name: string;
|
||||
description?: string;
|
||||
triggers: Array<{ type: string; pattern?: string }>;
|
||||
actions: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||
enabled?: boolean;
|
||||
}): Promise<{ skill?: SkillItem }> {
|
||||
const result = await invoke<SkillItem>('skill_create', {
|
||||
request: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
triggers: skill.triggers.map(t => t.pattern || t.type),
|
||||
actions: skill.actions.map(a => a.type),
|
||||
enabled: skill.enabled,
|
||||
},
|
||||
});
|
||||
return { skill: result };
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing skill
|
||||
*/
|
||||
proto.updateSkill = async function (this: KernelClient, 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?: SkillItem }> {
|
||||
const result = await invoke<SkillItem>('skill_update', {
|
||||
id,
|
||||
request: {
|
||||
name: updates.name,
|
||||
description: updates.description,
|
||||
triggers: updates.triggers?.map(t => t.pattern || t.type),
|
||||
actions: updates.actions?.map(a => a.type),
|
||||
enabled: updates.enabled,
|
||||
},
|
||||
});
|
||||
return { skill: result };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a skill
|
||||
*/
|
||||
proto.deleteSkill = async function (this: KernelClient, id: string): Promise<void> {
|
||||
await invoke('skill_delete', { id });
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a skill by ID with optional input parameters.
|
||||
* Checks autonomy level before execution.
|
||||
*/
|
||||
proto.executeSkill = async function (this: KernelClient, id: string, input?: Record<string, unknown>): Promise<{
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
}> {
|
||||
return invoke('skill_execute', {
|
||||
id,
|
||||
context: {},
|
||||
input: input || {},
|
||||
});
|
||||
};
|
||||
}
|
||||
131
desktop/src/lib/kernel-triggers.ts
Normal file
131
desktop/src/lib/kernel-triggers.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* kernel-triggers.ts - Triggers API methods for KernelClient
|
||||
*
|
||||
* Installed onto KernelClient.prototype via installTriggerMethods().
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
|
||||
/** Trigger shape shared across trigger operations. */
|
||||
type TriggerItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
handId: string;
|
||||
triggerType: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
/** Trigger type definition for create/update operations. */
|
||||
type TriggerTypeSpec = {
|
||||
type: string;
|
||||
cron?: string;
|
||||
pattern?: string;
|
||||
path?: string;
|
||||
secret?: string;
|
||||
events?: string[];
|
||||
};
|
||||
|
||||
export function installTriggerMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
// ─── Triggers API ───
|
||||
|
||||
/**
|
||||
* List all triggers
|
||||
* Returns empty array on error for graceful degradation
|
||||
*/
|
||||
proto.listTriggers = async function (this: KernelClient): Promise<{
|
||||
triggers?: TriggerItem[]
|
||||
}> {
|
||||
try {
|
||||
const triggers = await invoke<TriggerItem[]>('trigger_list');
|
||||
return { triggers };
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] listTriggers failed: ${this.formatError(error)}`);
|
||||
return { triggers: [] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single trigger by ID
|
||||
* Returns null on error for graceful degradation
|
||||
*/
|
||||
proto.getTrigger = async function (this: KernelClient, id: string): Promise<TriggerItem | null> {
|
||||
try {
|
||||
return await invoke<TriggerItem | null>('trigger_get', { id });
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] getTrigger(${id}) failed: ${this.formatError(error)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new trigger
|
||||
* Returns null on error for graceful degradation
|
||||
*/
|
||||
proto.createTrigger = async function (this: KernelClient, trigger: {
|
||||
id: string;
|
||||
name: string;
|
||||
handId: string;
|
||||
triggerType: TriggerTypeSpec;
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}): Promise<TriggerItem | null> {
|
||||
try {
|
||||
return await invoke<TriggerItem>('trigger_create', { request: trigger });
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] createTrigger(${trigger.id}) failed: ${this.formatError(error)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing trigger
|
||||
* Throws on error as this is a mutation operation that callers need to handle
|
||||
*/
|
||||
proto.updateTrigger = async function (this: KernelClient, id: string, updates: {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
handId?: string;
|
||||
triggerType?: TriggerTypeSpec;
|
||||
}): Promise<TriggerItem> {
|
||||
try {
|
||||
return await invoke<TriggerItem>('trigger_update', { id, updates });
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] updateTrigger(${id}) failed: ${this.formatError(error)}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a trigger
|
||||
* Throws on error as this is a destructive operation that callers need to handle
|
||||
*/
|
||||
proto.deleteTrigger = async function (this: KernelClient, id: string): Promise<void> {
|
||||
try {
|
||||
await invoke('trigger_delete', { id });
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] deleteTrigger(${id}) failed: ${this.formatError(error)}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a trigger
|
||||
* Throws on error as callers need to know if execution failed
|
||||
*/
|
||||
proto.executeTrigger = async function (this: KernelClient, id: string, input?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await invoke<Record<string, unknown>>('trigger_execute', { id, input: input || {} });
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] executeTrigger(${id}) failed: ${this.formatError(error)}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
138
desktop/src/lib/kernel-types.ts
Normal file
138
desktop/src/lib/kernel-types.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* kernel-types.ts - Shared types for the Kernel Client subsystem
|
||||
*
|
||||
* Extracted from kernel-client.ts for modularity.
|
||||
* All type/interface definitions used across kernel-client and its mixin modules.
|
||||
*/
|
||||
|
||||
// === Connection & Status Types ===
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
export interface KernelStatus {
|
||||
initialized: boolean;
|
||||
agentCount: number;
|
||||
databaseUrl: string | null;
|
||||
defaultProvider: string | null;
|
||||
defaultModel: string | null;
|
||||
}
|
||||
|
||||
// === Agent Types ===
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
state: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface CreateAgentResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// === Chat Types ===
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface EventCallback {
|
||||
(payload: unknown): void;
|
||||
}
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onDelta: (delta: string) => void;
|
||||
onTool?: (tool: string, input: string, output: string) => void;
|
||||
onHand?: (name: string, status: string, result?: unknown) => void;
|
||||
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
// === Streaming Types (match Rust StreamChatEvent) ===
|
||||
|
||||
export interface StreamEventDelta {
|
||||
type: 'delta';
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface StreamEventToolStart {
|
||||
type: 'tool_start';
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface StreamEventToolEnd {
|
||||
type: 'tool_end';
|
||||
name: string;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
export interface StreamEventIterationStart {
|
||||
type: 'iteration_start';
|
||||
iteration: number;
|
||||
maxIterations: number;
|
||||
}
|
||||
|
||||
export interface StreamEventComplete {
|
||||
type: 'complete';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface StreamEventError {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StreamEventHandStart {
|
||||
type: 'handStart';
|
||||
name: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface StreamEventHandEnd {
|
||||
type: 'handEnd';
|
||||
name: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export type StreamChatEvent =
|
||||
| StreamEventDelta
|
||||
| StreamEventToolStart
|
||||
| StreamEventToolEnd
|
||||
| StreamEventIterationStart
|
||||
| StreamEventHandStart
|
||||
| StreamEventHandEnd
|
||||
| StreamEventComplete
|
||||
| StreamEventError;
|
||||
|
||||
export interface StreamChunkPayload {
|
||||
sessionId: string;
|
||||
event: StreamChatEvent;
|
||||
}
|
||||
|
||||
// === Config Types ===
|
||||
|
||||
export interface KernelConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: string; // openai, anthropic, custom
|
||||
}
|
||||
@@ -488,7 +488,9 @@ class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
result.tokensUsed.output,
|
||||
{ latencyMs, success: true, connectionMode: 'saas' },
|
||||
);
|
||||
} catch { /* non-blocking */ }
|
||||
} catch (e) {
|
||||
log.debug('Failed to record LLM telemetry', { error: e });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -500,7 +502,8 @@ class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
const mode = localStorage.getItem('zclaw-connection-mode');
|
||||
const saasUrl = localStorage.getItem('zclaw-saas-url');
|
||||
return mode === 'saas' && !!saasUrl;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to check SaaS adapter availability', { error: e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -556,8 +559,8 @@ export function loadConfig(): LLMConfig {
|
||||
if (saved) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
} catch (e) {
|
||||
log.debug('Failed to parse LLM config', { error: e });
|
||||
}
|
||||
|
||||
// Default to gateway (ZCLAW passthrough) for L4 self-evolution
|
||||
@@ -661,7 +664,8 @@ function loadPromptCache(): Record<string, CachedPrompt> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROMPT_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to parse prompt cache', { error: e });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -827,8 +831,8 @@ function trackLLMCall(
|
||||
connectionMode: adapter.getProvider() === 'saas' ? 'saas' : 'tauri',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// telemetry-collector may not be available (e.g., SSR)
|
||||
} catch (e) {
|
||||
log.debug('Telemetry recording failed (SSR or unavailable)', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,7 +201,8 @@ export class MemoryExtractor {
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
saved++;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to save memory item', { error: e });
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
@@ -406,8 +407,8 @@ export class MemoryExtractor {
|
||||
importance: Math.max(1, Math.min(10, Number(item.importance))),
|
||||
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
|
||||
}));
|
||||
} catch {
|
||||
log.warn('Failed to parse LLM extraction response');
|
||||
} catch (e) {
|
||||
log.warn('Failed to parse LLM extraction response', { error: e });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,8 @@ export async function requestWithRetry(
|
||||
// Try to read response body for error details
|
||||
try {
|
||||
responseBody = await response.text();
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to read response body', { error: e });
|
||||
responseBody = '';
|
||||
}
|
||||
|
||||
|
||||
233
desktop/src/lib/saas-admin.ts
Normal file
233
desktop/src/lib/saas-admin.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* SaaS Admin Methods — Mixin
|
||||
*
|
||||
* Installs admin panel API methods onto SaaSClient.prototype.
|
||||
* Uses the same mixin pattern as gateway-api.ts.
|
||||
*
|
||||
* Reserved for future admin UI (Next.js admin dashboard).
|
||||
* These methods are not called by the desktop app but are kept as thin API
|
||||
* wrappers for when the admin panel is built.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderInfo,
|
||||
CreateProviderRequest,
|
||||
UpdateProviderRequest,
|
||||
ModelInfo,
|
||||
CreateModelRequest,
|
||||
UpdateModelRequest,
|
||||
AccountApiKeyInfo,
|
||||
CreateApiKeyRequest,
|
||||
AccountPublic,
|
||||
UpdateAccountRequest,
|
||||
PaginatedResponse,
|
||||
TokenInfo,
|
||||
CreateTokenRequest,
|
||||
OperationLogInfo,
|
||||
DashboardStats,
|
||||
RoleInfo,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
PermissionTemplate,
|
||||
CreateTemplateRequest,
|
||||
} from './saas-types';
|
||||
|
||||
export function installAdminMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
// --- Provider Management (Admin) ---
|
||||
|
||||
/** List all providers */
|
||||
proto.listProviders = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<ProviderInfo[]> {
|
||||
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
|
||||
};
|
||||
|
||||
/** Get provider by ID */
|
||||
proto.getProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new provider (admin only) */
|
||||
proto.createProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
|
||||
};
|
||||
|
||||
/** Update a provider (admin only) */
|
||||
proto.updateProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a provider (admin only) */
|
||||
proto.deleteProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
|
||||
};
|
||||
|
||||
// --- Model Management (Admin) ---
|
||||
|
||||
/** List models, optionally filtered by provider */
|
||||
proto.listModelsAdmin = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<ModelInfo[]> {
|
||||
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
|
||||
};
|
||||
|
||||
/** Get model by ID */
|
||||
proto.getModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new model (admin only) */
|
||||
proto.createModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('POST', '/api/v1/models', data);
|
||||
};
|
||||
|
||||
/** Update a model (admin only) */
|
||||
proto.updateModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a model (admin only) */
|
||||
proto.deleteModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/models/${id}`);
|
||||
};
|
||||
|
||||
// --- Account API Keys ---
|
||||
|
||||
/** List account's API keys */
|
||||
proto.listApiKeys = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<AccountApiKeyInfo[]> {
|
||||
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
|
||||
};
|
||||
|
||||
/** Create a new API key */
|
||||
proto.createApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
|
||||
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
|
||||
};
|
||||
|
||||
/** Rotate an API key */
|
||||
proto.rotateApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, newKeyValue: string): Promise<void> {
|
||||
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
|
||||
};
|
||||
|
||||
/** Revoke an API key */
|
||||
proto.revokeApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
|
||||
};
|
||||
|
||||
// --- Account Management (Admin) ---
|
||||
|
||||
/** List all accounts (admin only) */
|
||||
proto.listAccounts = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.page_size) qs.set('page_size', String(params.page_size));
|
||||
if (params?.role) qs.set('role', params.role);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
const query = qs.toString();
|
||||
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
|
||||
};
|
||||
|
||||
/** Get account by ID (admin or self) */
|
||||
proto.getAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
|
||||
};
|
||||
|
||||
/** Update account (admin or self) */
|
||||
proto.updateAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
|
||||
};
|
||||
|
||||
/** Update account status (admin only) */
|
||||
proto.updateAccountStatus = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
|
||||
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
|
||||
};
|
||||
|
||||
// --- API Token Management ---
|
||||
|
||||
/** List API tokens for current account */
|
||||
proto.listTokens = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<TokenInfo[]> {
|
||||
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
|
||||
};
|
||||
|
||||
/** Create a new API token */
|
||||
proto.createToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
|
||||
};
|
||||
|
||||
/** Revoke an API token */
|
||||
proto.revokeToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
|
||||
};
|
||||
|
||||
// --- Operation Logs (Admin) ---
|
||||
|
||||
/** List operation logs (admin only) */
|
||||
proto.listOperationLogs = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.page_size) qs.set('page_size', String(params.page_size));
|
||||
const query = qs.toString();
|
||||
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
|
||||
};
|
||||
|
||||
// --- Dashboard Statistics (Admin) ---
|
||||
|
||||
/** Get dashboard statistics (admin only) */
|
||||
proto.getDashboardStats = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<DashboardStats> {
|
||||
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
|
||||
};
|
||||
|
||||
// --- Role Management (Admin) ---
|
||||
|
||||
/** List all roles */
|
||||
proto.listRoles = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<RoleInfo[]> {
|
||||
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
|
||||
};
|
||||
|
||||
/** Get role by ID */
|
||||
proto.getRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new role (admin only) */
|
||||
proto.createRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
|
||||
};
|
||||
|
||||
/** Update a role (admin only) */
|
||||
proto.updateRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a role (admin only) */
|
||||
proto.deleteRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
|
||||
};
|
||||
|
||||
// --- Permission Templates ---
|
||||
|
||||
/** List permission templates */
|
||||
proto.listPermissionTemplates = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<PermissionTemplate[]> {
|
||||
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
|
||||
};
|
||||
|
||||
/** Get permission template by ID */
|
||||
proto.getPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
|
||||
};
|
||||
|
||||
/** Create a permission template (admin only) */
|
||||
proto.createPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTemplateRequest): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
|
||||
};
|
||||
|
||||
/** Delete a permission template (admin only) */
|
||||
proto.deletePermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
|
||||
};
|
||||
|
||||
/** Apply permission template to accounts (admin only) */
|
||||
proto.applyPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
|
||||
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
|
||||
};
|
||||
}
|
||||
97
desktop/src/lib/saas-auth.ts
Normal file
97
desktop/src/lib/saas-auth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* SaaS Auth Methods — Mixin
|
||||
*
|
||||
* Installs authentication-related methods onto SaaSClient.prototype.
|
||||
* Uses the same mixin pattern as gateway-api.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SaaSAccountInfo,
|
||||
SaaSLoginResponse,
|
||||
SaaSRefreshResponse,
|
||||
TotpSetupResponse,
|
||||
TotpResultResponse,
|
||||
} from './saas-types';
|
||||
|
||||
export function installAuthMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
/**
|
||||
* Login with username and password.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
proto.login = async function (this: { token: string | null; request<T>(method: string, path: string, body?: unknown): Promise<T> }, username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
|
||||
const body: Record<string, string> = { username, password };
|
||||
if (totpCode) body.totp_code = totpCode;
|
||||
// Clear stale token before login — avoid sending expired token on auth endpoint
|
||||
this.token = null;
|
||||
const data = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/login', body,
|
||||
);
|
||||
this.token = data.token;
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new account.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
proto.register = async function (this: { token: string | null; request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}): Promise<SaaSLoginResponse> {
|
||||
// Clear stale token before register
|
||||
this.token = null;
|
||||
const result = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/register', data,
|
||||
);
|
||||
this.token = result.token;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's account info.
|
||||
*/
|
||||
proto.me = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<SaaSAccountInfo> {
|
||||
return this.request<SaaSAccountInfo>('GET', '/api/v1/auth/me');
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh the current token.
|
||||
* Auto-updates the client token on success.
|
||||
*/
|
||||
proto.refreshToken = async function (this: { token: string | null; request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<string> {
|
||||
const data = await this.request<SaaSRefreshResponse>('POST', '/api/v1/auth/refresh');
|
||||
this.token = data.token;
|
||||
return data.token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the current user's password.
|
||||
*/
|
||||
proto.changePassword = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, oldPassword: string, newPassword: string): Promise<void> {
|
||||
await this.request<unknown>('PUT', '/api/v1/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
// --- TOTP Endpoints ---
|
||||
|
||||
/** Generate a TOTP secret and otpauth URI */
|
||||
proto.setupTotp = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<TotpSetupResponse> {
|
||||
return this.request<TotpSetupResponse>('POST', '/api/v1/auth/totp/setup');
|
||||
};
|
||||
|
||||
/** Verify a TOTP code and enable 2FA */
|
||||
proto.verifyTotp = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, code: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/verify', { code });
|
||||
};
|
||||
|
||||
/** Disable 2FA (requires password confirmation) */
|
||||
proto.disableTotp = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, password: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/disable', { password });
|
||||
};
|
||||
}
|
||||
16
desktop/src/lib/saas-errors.ts
Normal file
16
desktop/src/lib/saas-errors.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* SaaS Error Class
|
||||
*
|
||||
* Custom error for SaaS API responses.
|
||||
*/
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SaaSApiError';
|
||||
}
|
||||
}
|
||||
46
desktop/src/lib/saas-prompt.ts
Normal file
46
desktop/src/lib/saas-prompt.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* SaaS Prompt OTA Methods — Mixin
|
||||
*
|
||||
* Installs prompt OTA methods onto SaaSClient.prototype.
|
||||
* Uses the same mixin pattern as gateway-api.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PromptCheckResult,
|
||||
PromptTemplateInfo,
|
||||
PromptVersionInfo,
|
||||
PaginatedResponse,
|
||||
} from './saas-types';
|
||||
|
||||
export function installPromptMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
/** Check for prompt updates (OTA) */
|
||||
proto.checkPromptUpdates = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult> {
|
||||
return this.request<PromptCheckResult>('POST', '/api/v1/prompts/check', {
|
||||
device_id: deviceId,
|
||||
versions: currentVersions,
|
||||
});
|
||||
};
|
||||
|
||||
/** List all prompt templates */
|
||||
proto.listPrompts = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise<PaginatedResponse<PromptTemplateInfo>> {
|
||||
const qs = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
|
||||
return this.request<PaginatedResponse<PromptTemplateInfo>>('GET', `/api/v1/prompts${qs}`);
|
||||
};
|
||||
|
||||
/** Get prompt template by name */
|
||||
proto.getPrompt = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, name: string): Promise<PromptTemplateInfo> {
|
||||
return this.request<PromptTemplateInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}`);
|
||||
};
|
||||
|
||||
/** List prompt versions */
|
||||
proto.listPromptVersions = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, name: string): Promise<PromptVersionInfo[]> {
|
||||
return this.request<PromptVersionInfo[]>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions`);
|
||||
};
|
||||
|
||||
/** Get specific prompt version */
|
||||
proto.getPromptVersion = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, name: string, version: number): Promise<PromptVersionInfo> {
|
||||
return this.request<PromptVersionInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions/${version}`);
|
||||
};
|
||||
}
|
||||
131
desktop/src/lib/saas-relay.ts
Normal file
131
desktop/src/lib/saas-relay.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* SaaS Relay Methods — Mixin
|
||||
*
|
||||
* Installs relay-related methods (tasks, chat completion, usage) onto
|
||||
* SaaSClient.prototype. Uses the same mixin pattern as gateway-api.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
RelayTaskInfo,
|
||||
UsageStats,
|
||||
} from './saas-types';
|
||||
import { createLogger } from './logger';
|
||||
const logger = createLogger('SaaSRelay');
|
||||
|
||||
export function installRelayMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
// --- Relay Task Management ---
|
||||
|
||||
/** List relay tasks for the current user */
|
||||
proto.listRelayTasks = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.page) params.set('page', String(query.page));
|
||||
if (query?.page_size) params.set('page_size', String(query.page_size));
|
||||
const qs = params.toString();
|
||||
return this.request<RelayTaskInfo[]>('GET', `/api/v1/relay/tasks${qs ? '?' + qs : ''}`);
|
||||
};
|
||||
|
||||
/** Get a single relay task */
|
||||
proto.getRelayTask = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, taskId: string): Promise<RelayTaskInfo> {
|
||||
return this.request<RelayTaskInfo>('GET', `/api/v1/relay/tasks/${taskId}`);
|
||||
};
|
||||
|
||||
/** Retry a failed relay task (admin only) */
|
||||
proto.retryRelayTask = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, taskId: string): Promise<{ ok: boolean; task_id: string }> {
|
||||
return this.request<{ ok: boolean; task_id: string }>('POST', `/api/v1/relay/tasks/${taskId}/retry`);
|
||||
};
|
||||
|
||||
// --- Chat Relay ---
|
||||
|
||||
/**
|
||||
* Send a chat completion request via the SaaS relay.
|
||||
* Returns the raw Response object to support both streaming and non-streaming.
|
||||
*
|
||||
* Includes one retry on 401 (auto token refresh) and on network errors.
|
||||
* The caller is responsible for:
|
||||
* - Reading the response body (JSON or SSE stream)
|
||||
* - Handling errors from the response
|
||||
*/
|
||||
proto.chatCompletion = async function (
|
||||
this: {
|
||||
baseUrl: string;
|
||||
token: string | null;
|
||||
_serverReachable: boolean;
|
||||
_isAuthEndpoint(path: string): boolean;
|
||||
refreshToken(): Promise<string>;
|
||||
},
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const maxAttempts = 2; // 1 initial + 1 retry
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// Use caller's AbortSignal if provided, otherwise default 5min timeout
|
||||
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v1/relay/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include', // Send HttpOnly cookies
|
||||
body: JSON.stringify(body),
|
||||
signal: effectiveSignal,
|
||||
},
|
||||
);
|
||||
|
||||
// On 401, attempt token refresh once
|
||||
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
if (newToken) continue; // Retry with refreshed token
|
||||
} catch (e) {
|
||||
logger.debug('Token refresh failed', { error: e });
|
||||
// Refresh failed, return the 401 response
|
||||
}
|
||||
}
|
||||
|
||||
this._serverReachable = true;
|
||||
return response;
|
||||
} catch (err: unknown) {
|
||||
this._serverReachable = false;
|
||||
const isNetworkError = err instanceof TypeError
|
||||
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
|
||||
|
||||
if (isNetworkError && attempt < maxAttempts - 1) {
|
||||
// Brief backoff before retry
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable but TypeScript needs it
|
||||
throw new Error('chatCompletion: all attempts exhausted');
|
||||
};
|
||||
|
||||
// --- Usage Statistics ---
|
||||
|
||||
/** Get usage statistics for current account */
|
||||
proto.getUsage = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.from) qs.set('from', params.from);
|
||||
if (params?.to) qs.set('to', params.to);
|
||||
if (params?.provider_id) qs.set('provider_id', params.provider_id);
|
||||
if (params?.model_id) qs.set('model_id', params.model_id);
|
||||
const query = qs.toString();
|
||||
return this.request<UsageStats>('GET', `/api/v1/usage${query ? '?' + query : ''}`);
|
||||
};
|
||||
}
|
||||
153
desktop/src/lib/saas-session.ts
Normal file
153
desktop/src/lib/saas-session.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* SaaS Session Persistence
|
||||
*
|
||||
* Handles loading/saving SaaS auth session data.
|
||||
* Token is stored in secure storage (OS keyring), not plain localStorage.
|
||||
* Auth state is carried by HttpOnly cookies when possible (same-origin).
|
||||
*/
|
||||
|
||||
import type { SaaSAccountInfo } from './saas-types';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('saas-session');
|
||||
|
||||
// === Storage Keys ===
|
||||
const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup
|
||||
const SAASURL_KEY = 'zclaw-saas-url';
|
||||
const SAASACCOUNT_KEY = 'zclaw-saas-account';
|
||||
const SAASMODE_KEY = 'zclaw-connection-mode';
|
||||
|
||||
// === Session Interface ===
|
||||
|
||||
export interface SaaSSession {
|
||||
token: string | null; // null when using cookie-based auth (page reload)
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
}
|
||||
|
||||
// === Session Functions ===
|
||||
|
||||
/**
|
||||
* Load a persisted SaaS session.
|
||||
* Token is stored in secure storage (OS keyring), not plain localStorage.
|
||||
* Returns null if no URL is stored (never logged in).
|
||||
*
|
||||
* NOTE: Token loading is async due to secure storage access.
|
||||
* For synchronous checks, use loadSaaSSessionSync() (URL + account only).
|
||||
*/
|
||||
export async function loadSaaSSession(): Promise<SaaSSession | null> {
|
||||
try {
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
if (!saasUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clean up any legacy plaintext token from localStorage
|
||||
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
|
||||
if (legacyToken) {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
}
|
||||
|
||||
// Load token from secure storage
|
||||
let token: string | null = null;
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY);
|
||||
} catch (e) {
|
||||
logger.debug('Secure storage unavailable for token load', { error: e });
|
||||
// Secure storage unavailable — token stays null (cookie auth will be attempted)
|
||||
}
|
||||
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
const account: SaaSAccountInfo | null = accountRaw
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { token, account, saasUrl };
|
||||
} catch (e) {
|
||||
logger.debug('Corrupted session data, clearing', { error: e });
|
||||
// Corrupted data - clear all
|
||||
clearSaaSSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version — returns URL + account only (no token).
|
||||
* Used during store initialization where async is not available.
|
||||
*/
|
||||
export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountInfo | null } | null {
|
||||
try {
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
if (!saasUrl) return null;
|
||||
|
||||
// Clean up legacy plaintext token
|
||||
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
|
||||
if (legacyToken) {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
}
|
||||
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
const account: SaaSAccountInfo | null = accountRaw
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { saasUrl, account };
|
||||
} catch (e) {
|
||||
logger.debug('Failed to load sync session', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist SaaS session.
|
||||
* Token goes to secure storage (OS keyring), metadata to localStorage.
|
||||
*/
|
||||
export async function saveSaaSSession(session: SaaSSession): Promise<void> {
|
||||
// Store token in secure storage (OS keyring), not plain localStorage
|
||||
if (session.token) {
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token);
|
||||
} catch (e) {
|
||||
logger.debug('Secure storage unavailable for token save', { error: e });
|
||||
// Secure storage unavailable — token only in memory
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(SAASURL_KEY, session.saasUrl);
|
||||
if (session.account) {
|
||||
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the persisted SaaS session from all storage.
|
||||
*/
|
||||
export async function clearSaaSSession(): Promise<void> {
|
||||
// Remove from secure storage
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, '');
|
||||
} catch (e) { logger.debug('Failed to clear secure storage token', { error: e }); }
|
||||
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
localStorage.removeItem(SAASURL_KEY);
|
||||
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the connection mode to localStorage.
|
||||
*/
|
||||
export function saveConnectionMode(mode: string): void {
|
||||
localStorage.setItem(SAASMODE_KEY, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the connection mode from localStorage.
|
||||
* Returns null if not set.
|
||||
*/
|
||||
export function loadConnectionMode(): string | null {
|
||||
return localStorage.getItem(SAASMODE_KEY);
|
||||
}
|
||||
45
desktop/src/lib/saas-telemetry.ts
Normal file
45
desktop/src/lib/saas-telemetry.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* SaaS Telemetry Methods — Mixin
|
||||
*
|
||||
* Installs telemetry reporting methods onto SaaSClient.prototype.
|
||||
* Uses the same mixin pattern as gateway-api.ts.
|
||||
*/
|
||||
|
||||
export function installTelemetryMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
/** Report anonymous usage telemetry (token counts only, no content) */
|
||||
proto.reportTelemetry = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: {
|
||||
device_id: string;
|
||||
app_version: string;
|
||||
entries: Array<{
|
||||
model_id: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
latency_ms?: number;
|
||||
success: boolean;
|
||||
error_type?: string;
|
||||
timestamp: string;
|
||||
connection_mode: string;
|
||||
}>;
|
||||
}): Promise<{ accepted: number; rejected: number }> {
|
||||
return this.request<{ accepted: number; rejected: number }>(
|
||||
'POST', '/api/v1/telemetry/report', data,
|
||||
);
|
||||
};
|
||||
|
||||
/** Report audit log summary (action types and counts only, no content) */
|
||||
proto.reportAuditSummary = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: {
|
||||
device_id: string;
|
||||
entries: Array<{
|
||||
action: string;
|
||||
target: string;
|
||||
result: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}): Promise<{ accepted: number; total: number }> {
|
||||
return this.request<{ accepted: number; total: number }>(
|
||||
'POST', '/api/v1/telemetry/audit', data,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
arrayToBase64,
|
||||
base64ToArray,
|
||||
} from './crypto-utils';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('secure-storage');
|
||||
|
||||
// Cache for keyring availability check
|
||||
let keyringAvailable: boolean | null = null;
|
||||
@@ -145,7 +148,8 @@ function isEncrypted(value: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('isEncrypted check failed', { error: e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -157,7 +161,8 @@ function isV2Encrypted(value: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && parsed.version === 2 && typeof parsed.salt === 'string' && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('isV2Encrypted check failed', { error: e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -254,7 +259,8 @@ async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('readEncryptedLocalStorage failed', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -266,8 +272,8 @@ function clearLocalStorageBackup(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(ENCRYPTED_PREFIX + key);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
} catch (e) {
|
||||
logger.debug('clearLocalStorageBackup failed', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +285,16 @@ function writeLocalStorageBackup(key: string, value: string): void {
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
} catch (e) {
|
||||
logger.debug('writeLocalStorageBackup failed', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageBackup(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('readLocalStorageBackup failed', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -400,8 +407,8 @@ export async function storeDeviceKeys(
|
||||
// Clear legacy format if present
|
||||
try {
|
||||
localStorage.removeItem(DEVICE_KEYS_LEGACY);
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
logger.debug('Failed to clear legacy device keys from localStorage', { error: e });
|
||||
}
|
||||
} else {
|
||||
// Fallback: store in localStorage (less secure, but better than nothing)
|
||||
@@ -477,8 +484,8 @@ export async function deleteDeviceKeys(): Promise<void> {
|
||||
localStorage.removeItem(DEVICE_KEYS_PUBLIC_KEY);
|
||||
localStorage.removeItem(DEVICE_KEYS_CREATED);
|
||||
localStorage.removeItem(DEVICE_KEYS_LEGACY);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
} catch (e) {
|
||||
logger.debug('Failed to delete device keys from localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,8 +519,8 @@ export async function getDeviceKeysCreatedAt(): Promise<number | null> {
|
||||
if (typeof parsed.createdAt === 'number' || typeof parsed.createdAt === 'string') {
|
||||
return parseInt(String(parsed.createdAt), 10);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse legacy device keys createdAt', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,12 @@ function persistEvent(event: SecurityEvent): void {
|
||||
}
|
||||
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore persistence failures to prevent application disruption
|
||||
// eslint-disable-next-line no-console
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('[SecurityAudit] Failed to persist security event', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +153,11 @@ function getStoredEvents(): SecurityEvent[] {
|
||||
const stored = localStorage.getItem(SECURITY_LOG_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored) as SecurityEvent[];
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('[SecurityAudit] Failed to read security events', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* - Content Security Policy helpers
|
||||
*/
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('SecurityUtils');
|
||||
|
||||
// ============================================================================
|
||||
// HTML Sanitization
|
||||
// ============================================================================
|
||||
@@ -232,7 +236,8 @@ export function validateUrl(
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('URL validation failed', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -341,7 +346,8 @@ export function validatePath(
|
||||
return null;
|
||||
}
|
||||
normalized = resolved;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('Path resolution failed', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -553,7 +559,8 @@ export function sanitizeJson<T = unknown>(json: string): T | null {
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logger.debug('JSON sanitize parse failed', { error: e });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ export class SkillDiscoveryEngine {
|
||||
matchedPatterns.push(`记忆中有${memories.length}条相关记录`);
|
||||
confidence += memories.length * 0.1;
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
} catch (e) { log.debug('Memory search in suggestSkills failed', { error: e }); }
|
||||
|
||||
if (matchedPatterns.length > 0 && confidence > 0) {
|
||||
suggestions.push({
|
||||
@@ -402,7 +402,8 @@ export class SkillDiscoveryEngine {
|
||||
try {
|
||||
const raw = localStorage.getItem(SKILL_INDEX_KEY);
|
||||
if (raw) this.skills = JSON.parse(raw);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to load skill index from localStorage', { error: e });
|
||||
this.skills = [];
|
||||
}
|
||||
}
|
||||
@@ -410,14 +411,15 @@ export class SkillDiscoveryEngine {
|
||||
private saveIndex(): void {
|
||||
try {
|
||||
localStorage.setItem(SKILL_INDEX_KEY, JSON.stringify(this.skills));
|
||||
} catch { /* silent */ }
|
||||
} catch (e) { log.debug('Failed to save skill index', { error: e }); }
|
||||
}
|
||||
|
||||
private loadSuggestions(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SKILL_SUGGESTIONS_KEY);
|
||||
if (raw) this.suggestionHistory = JSON.parse(raw);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.debug('Failed to load skill suggestions', { error: e });
|
||||
this.suggestionHistory = [];
|
||||
}
|
||||
}
|
||||
@@ -425,7 +427,7 @@ export class SkillDiscoveryEngine {
|
||||
private saveSuggestions(): void {
|
||||
try {
|
||||
localStorage.setItem(SKILL_SUGGESTIONS_KEY, JSON.stringify(this.suggestionHistory));
|
||||
} catch { /* silent */ }
|
||||
} catch (e) { log.debug('Failed to save skill suggestions', { error: e }); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ function getNotifiedProposals(): Set<string> {
|
||||
if (stored) {
|
||||
return new Set(JSON.parse(stored) as string[]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} catch (e) {
|
||||
log.debug('Failed to parse notified proposals from localStorage', { error: e });
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
@@ -48,8 +48,8 @@ function saveNotifiedProposals(ids: Set<string>): void {
|
||||
// Keep only last 100 IDs to prevent storage bloat
|
||||
const arr = Array.from(ids).slice(-100);
|
||||
localStorage.setItem(NOTIFIED_PROPOSALS_KEY, JSON.stringify(arr));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} catch (e) {
|
||||
log.debug('Failed to save notified proposals to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user