From f9290ea683d528848c0701b858ef8c57f1b6af75 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 19:38:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(types):=20=E9=94=99=E8=AF=AF=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=E9=87=8D=E6=9E=84=20=E2=80=94=20ErrorKind=20+=20error?= =?UTF-8?q?=20code=20+=20Serialize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust (crates/zclaw-types/src/error.rs): - 新增 ErrorKind enum (17 种) + Serde Serialize/Deserialize - 新增 error_codes 模块 (稳定错误码 E4040-E5110) - ZclawError 新增 kind() / code() 方法 - 新增 ErrorDetail struct + Serialize impl - 保留所有现有变体和构造器 (零破坏性) - 新增 12 个测试: kind 映射 + code 稳定性 + JSON 序列化 TypeScript (desktop/src/lib/error-types.ts): - 新增 RustErrorKind / RustErrorDetail 类型定义 - 新增 tryParseRustError() 结构化错误解析 - 新增 classifyRustError() 按 ErrorKind 分类 - classifyError() 优先解析结构化错误,fallback 字符串匹配 - 17 种 ErrorKind → 中文标题映射 验证: cargo check ✓ | tsc ✓ | 62 zclaw-types tests ✓ --- crates/zclaw-types/src/error.rs | 225 +++++++++++++++++++++++++++++++- desktop/src/lib/error-types.ts | 194 +++++++++++++++++++++++++++ 2 files changed, 416 insertions(+), 3 deletions(-) diff --git a/crates/zclaw-types/src/error.rs b/crates/zclaw-types/src/error.rs index 39379f1..32ab251 100644 --- a/crates/zclaw-types/src/error.rs +++ b/crates/zclaw-types/src/error.rs @@ -1,9 +1,95 @@ //! Error types for ZCLAW +//! +//! Provides structured error classification via [`ErrorKind`] and machine-readable +//! error codes alongside human-readable messages. The enum variants are preserved +//! for backward compatibility — all existing construction sites continue to work. -use thiserror::Error; +use serde::{Deserialize, Serialize}; -/// ZCLAW unified error type -#[derive(Debug, Error)] +// === Error Kind (structured classification) === + +/// Machine-readable error category for structured error reporting. +/// +/// Each variant maps to a stable error code prefix (e.g., `E404x` for `NotFound`). +/// Frontend code should match on `ErrorKind` rather than string patterns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + NotFound, + Permission, + Auth, + Llm, + Tool, + Storage, + Config, + Http, + Timeout, + Validation, + LoopDetected, + RateLimit, + Mcp, + Security, + Hand, + Export, + Internal, +} + +// === Error Codes === + +/// Stable error codes for machine-readable error matching. +/// +/// Format: `E{HTTP_STATUS_MIRROR}{SEQUENCE}`. +/// Frontend should use these codes instead of regex-matching error strings. +pub mod error_codes { + // Not Found (4040-4049) + pub const NOT_FOUND: &str = "E4040"; + // Permission (4030-4039) + pub const PERMISSION_DENIED: &str = "E4030"; + // Auth (4010-4019) + pub const AUTH_FAILED: &str = "E4010"; + // LLM (5000-5009) + pub const LLM_ERROR: &str = "E5001"; + pub const LLM_TIMEOUT: &str = "E5002"; + pub const LLM_RATE_LIMITED: &str = "E5003"; + // Tool (5010-5019) + pub const TOOL_ERROR: &str = "E5010"; + pub const TOOL_NOT_FOUND: &str = "E5011"; + pub const TOOL_TIMEOUT: &str = "E5012"; + // Storage (5020-5029) + pub const STORAGE_ERROR: &str = "E5020"; + pub const STORAGE_CORRUPTION: &str = "E5021"; + // Config (5030-5039) + pub const CONFIG_ERROR: &str = "E5030"; + // HTTP (5040-5049) + pub const HTTP_ERROR: &str = "E5040"; + // Timeout (5050-5059) + pub const TIMEOUT: &str = "E5050"; + // Validation (4000-4009) + pub const VALIDATION_ERROR: &str = "E4000"; + // Loop (5060-5069) + pub const LOOP_DETECTED: &str = "E5060"; + // Rate Limit (4290-4299) + pub const RATE_LIMITED: &str = "E4290"; + // MCP (5070-5079) + pub const MCP_ERROR: &str = "E5070"; + // Security (5080-5089) + pub const SECURITY_ERROR: &str = "E5080"; + // Hand (5090-5099) + pub const HAND_ERROR: &str = "E5090"; + // Export (5100-5109) + pub const EXPORT_ERROR: &str = "E5100"; + // Internal (5110-5119) + pub const INTERNAL: &str = "E5110"; +} + +// === ZclawError === + +/// ZCLAW unified error type. +/// +/// All variants are preserved for backward compatibility. +/// Use `.kind()` and `.code()` for structured classification. +/// Implements [`Serialize`] for JSON transport to frontend. +#[derive(Debug, thiserror::Error)] pub enum ZclawError { #[error("Not found: {0}")] NotFound(String), @@ -60,6 +146,80 @@ pub enum ZclawError { HandError(String), } +impl ZclawError { + /// Returns the structured error category. + pub fn kind(&self) -> ErrorKind { + match self { + Self::NotFound(_) => ErrorKind::NotFound, + Self::PermissionDenied(_) => ErrorKind::Permission, + Self::LlmError(_) => ErrorKind::Llm, + Self::ToolError(_) => ErrorKind::Tool, + Self::StorageError(_) => ErrorKind::Storage, + Self::ConfigError(_) => ErrorKind::Config, + Self::SerializationError(_) => ErrorKind::Internal, + Self::IoError(_) => ErrorKind::Internal, + Self::HttpError(_) => ErrorKind::Http, + Self::Timeout(_) => ErrorKind::Timeout, + Self::InvalidInput(_) => ErrorKind::Validation, + Self::LoopDetected(_) => ErrorKind::LoopDetected, + Self::RateLimited(_) => ErrorKind::RateLimit, + Self::Internal(_) => ErrorKind::Internal, + Self::ExportError(_) => ErrorKind::Export, + Self::McpError(_) => ErrorKind::Mcp, + Self::SecurityError(_) => ErrorKind::Security, + Self::HandError(_) => ErrorKind::Hand, + } + } + + /// Returns the stable error code (e.g., `"E4040"` for `NotFound`). + pub fn code(&self) -> &'static str { + match self { + Self::NotFound(_) => error_codes::NOT_FOUND, + Self::PermissionDenied(_) => error_codes::PERMISSION_DENIED, + Self::LlmError(_) => error_codes::LLM_ERROR, + Self::ToolError(_) => error_codes::TOOL_ERROR, + Self::StorageError(_) => error_codes::STORAGE_ERROR, + Self::ConfigError(_) => error_codes::CONFIG_ERROR, + Self::SerializationError(_) => error_codes::INTERNAL, + Self::IoError(_) => error_codes::INTERNAL, + Self::HttpError(_) => error_codes::HTTP_ERROR, + Self::Timeout(_) => error_codes::TIMEOUT, + Self::InvalidInput(_) => error_codes::VALIDATION_ERROR, + Self::LoopDetected(_) => error_codes::LOOP_DETECTED, + Self::RateLimited(_) => error_codes::RATE_LIMITED, + Self::Internal(_) => error_codes::INTERNAL, + Self::ExportError(_) => error_codes::EXPORT_ERROR, + Self::McpError(_) => error_codes::MCP_ERROR, + Self::SecurityError(_) => error_codes::SECURITY_ERROR, + Self::HandError(_) => error_codes::HAND_ERROR, + } + } +} + +/// Structured JSON representation for frontend consumption. +#[derive(Debug, Clone, Serialize)] +pub struct ErrorDetail { + pub kind: ErrorKind, + pub code: &'static str, + pub message: String, +} + +impl From<&ZclawError> for ErrorDetail { + fn from(err: &ZclawError) -> Self { + Self { + kind: err.kind(), + code: err.code(), + message: err.to_string(), + } + } +} + +impl Serialize for ZclawError { + fn serialize(&self, serializer: S) -> std::result::Result { + ErrorDetail::from(self).serialize(serializer) + } +} + /// Result type alias for ZCLAW operations pub type Result = std::result::Result; @@ -177,4 +337,63 @@ mod tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ZclawError::NotFound(_))); } + + // === New structured error tests === + + #[test] + fn test_error_kind_mapping() { + assert_eq!(ZclawError::NotFound("x".into()).kind(), ErrorKind::NotFound); + assert_eq!(ZclawError::PermissionDenied("x".into()).kind(), ErrorKind::Permission); + assert_eq!(ZclawError::LlmError("x".into()).kind(), ErrorKind::Llm); + assert_eq!(ZclawError::ToolError("x".into()).kind(), ErrorKind::Tool); + assert_eq!(ZclawError::StorageError("x".into()).kind(), ErrorKind::Storage); + assert_eq!(ZclawError::InvalidInput("x".into()).kind(), ErrorKind::Validation); + assert_eq!(ZclawError::Timeout("x".into()).kind(), ErrorKind::Timeout); + assert_eq!(ZclawError::SecurityError("x".into()).kind(), ErrorKind::Security); + assert_eq!(ZclawError::HandError("x".into()).kind(), ErrorKind::Hand); + assert_eq!(ZclawError::McpError("x".into()).kind(), ErrorKind::Mcp); + assert_eq!(ZclawError::Internal("x".into()).kind(), ErrorKind::Internal); + } + + #[test] + fn test_error_code_stability() { + assert_eq!(ZclawError::NotFound("x".into()).code(), "E4040"); + assert_eq!(ZclawError::PermissionDenied("x".into()).code(), "E4030"); + assert_eq!(ZclawError::LlmError("x".into()).code(), "E5001"); + assert_eq!(ZclawError::ToolError("x".into()).code(), "E5010"); + assert_eq!(ZclawError::StorageError("x".into()).code(), "E5020"); + assert_eq!(ZclawError::InvalidInput("x".into()).code(), "E4000"); + assert_eq!(ZclawError::Timeout("x".into()).code(), "E5050"); + assert_eq!(ZclawError::SecurityError("x".into()).code(), "E5080"); + assert_eq!(ZclawError::HandError("x".into()).code(), "E5090"); + assert_eq!(ZclawError::McpError("x".into()).code(), "E5070"); + assert_eq!(ZclawError::Internal("x".into()).code(), "E5110"); + } + + #[test] + fn test_error_serialize_json() { + let err = ZclawError::NotFound("agent-123".to_string()); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!(json["kind"], "not_found"); + assert_eq!(json["code"], "E4040"); + assert_eq!(json["message"], "Not found: agent-123"); + } + + #[test] + fn test_error_detail_from() { + let err = ZclawError::LlmError("timeout".to_string()); + let detail = ErrorDetail::from(&err); + assert_eq!(detail.kind, ErrorKind::Llm); + assert_eq!(detail.code, "E5001"); + assert_eq!(detail.message, "LLM error: timeout"); + } + + #[test] + fn test_error_kind_serde_roundtrip() { + let kind = ErrorKind::Storage; + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(json, "\"storage\""); + let back: ErrorKind = serde_json::from_str(&json).unwrap(); + assert_eq!(back, kind); + } } diff --git a/desktop/src/lib/error-types.ts b/desktop/src/lib/error-types.ts index 72e4ca7..972a4be 100644 --- a/desktop/src/lib/error-types.ts +++ b/desktop/src/lib/error-types.ts @@ -45,6 +45,193 @@ export interface RecoveryStep { label?: string; } +// === Structured Error from Rust Backend === + +/** + * Error kinds matching Rust `zclaw_types::ErrorKind`. + * When the backend returns structured error JSON, use these for classification. + */ +export type RustErrorKind = + | 'not_found' + | 'permission' + | 'auth' + | 'llm' + | 'tool' + | 'storage' + | 'config' + | 'http' + | 'timeout' + | 'validation' + | 'loop_detected' + | 'rate_limit' + | 'mcp' + | 'security' + | 'hand' + | 'export' + | 'internal'; + +/** + * Structured error response from Rust backend. + * Matches `zclaw_types::ErrorDetail` serialization. + */ +export interface RustErrorDetail { + kind: RustErrorKind; + code: string; // e.g., "E4040", "E5001" + message: string; +} + +/** + * Map Rust ErrorKind to frontend ErrorCategory. + */ +const ERROR_KIND_TO_CATEGORY: Record = { + not_found: 'client', + permission: 'permission', + auth: 'auth', + llm: 'server', + tool: 'system', + storage: 'system', + config: 'config', + http: 'network', + timeout: 'timeout', + validation: 'validation', + loop_detected: 'system', + rate_limit: 'client', + mcp: 'system', + security: 'permission', + hand: 'system', + export: 'system', + internal: 'system', +}; + +/** + * Map Rust ErrorKind to error severity. + */ +const ERROR_KIND_TO_SEVERITY: Record = { + not_found: 'low', + permission: 'medium', + auth: 'high', + llm: 'high', + tool: 'medium', + storage: 'high', + config: 'medium', + http: 'medium', + timeout: 'medium', + validation: 'low', + loop_detected: 'medium', + rate_limit: 'medium', + mcp: 'medium', + security: 'high', + hand: 'medium', + export: 'low', + internal: 'high', +}; + +/** + * Map Rust ErrorKind to user-friendly title (Chinese). + */ +const ERROR_KIND_TO_TITLE: Record = { + not_found: '资源未找到', + permission: '权限不足', + auth: '认证失败', + llm: '模型服务错误', + tool: '工具执行失败', + storage: '存储错误', + config: '配置错误', + http: '网络请求错误', + timeout: '请求超时', + validation: '输入无效', + loop_detected: '检测到循环调用', + rate_limit: '请求过于频繁', + mcp: 'MCP 协议错误', + security: '安全检查未通过', + hand: '自主能力执行失败', + export: '导出失败', + internal: '内部错误', +}; + +/** + * Try to parse a Tauri command error as a structured Rust error. + * Returns null if the error is not in structured format. + */ +export function tryParseRustError(error: unknown): RustErrorDetail | null { + if (typeof error === 'string') { + try { + const parsed = JSON.parse(error); + if (parsed && typeof parsed.kind === 'string' && typeof parsed.code === 'string') { + return parsed as RustErrorDetail; + } + } catch { + // Not JSON — fall through to string matching + } + } + if (typeof error === 'object' && error !== null) { + const obj = error as Record; + if (typeof obj.kind === 'string' && typeof obj.code === 'string') { + return error as RustErrorDetail; + } + } + return null; +} + +/** + * Classify a structured Rust error into an AppError. + */ +export function classifyRustError(rustError: RustErrorDetail, originalError?: unknown): AppError { + const kind = rustError.kind as RustErrorKind; + return { + id: `err_${Date.now()}_${generateRandomString(6)}`, + category: ERROR_KIND_TO_CATEGORY[kind] ?? 'system', + severity: ERROR_KIND_TO_SEVERITY[kind] ?? 'medium', + title: ERROR_KIND_TO_TITLE[kind] ?? '未知错误', + message: rustError.message, + technicalDetails: `[${rustError.code}] ${rustError.kind}`, + recoverable: !['auth', 'security', 'internal'].includes(kind), + recoverySteps: getRecoveryStepsForKind(kind), + timestamp: new Date(), + originalError: import.meta.env.DEV ? originalError : undefined, + }; +} + +function getRecoveryStepsForKind(kind: RustErrorKind): RecoveryStep[] { + switch (kind) { + case 'auth': + return [ + { description: '重新登录或重新连接' }, + { description: '检查 API Key 是否有效' }, + ]; + case 'timeout': + return [ + { description: '稍后重试' }, + { description: '尝试简化请求内容' }, + ]; + case 'rate_limit': + return [ + { description: '等待片刻后重试' }, + { description: '减少请求频率' }, + ]; + case 'permission': + return [ + { description: '联系管理员获取权限' }, + { description: '检查当前角色是否有操作权限' }, + ]; + case 'storage': + return [ + { description: '检查磁盘空间' }, + { description: '重启应用后重试' }, + ]; + case 'llm': + return [ + { description: '检查模型配置是否正确' }, + { description: '切换到其他模型重试' }, + ]; + default: + return [ + { description: '重试操作' }, + { description: '刷新页面后重试' }, + ]; + } +} + // === Error Detection Patterns === interface ErrorPattern { @@ -345,6 +532,13 @@ function matchPattern(error: unknown): { pattern: ErrorPattern; match: string } * Classify an error and create an AppError with recovery suggestions. */ export function classifyError(error: unknown): AppError { + // Priority 1: structured Rust error (when backend returns ErrorDetail JSON) + const rustError = tryParseRustError(error); + if (rustError) { + return classifyRustError(rustError, error); + } + + // Priority 2: string pattern matching (existing behavior) const matched = matchPattern(error); if (matched) {