feat(types): 错误体系重构 — ErrorKind + error code + Serialize
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

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 ✓
This commit is contained in:
iven
2026-04-17 19:38:19 +08:00
parent 0754ea19c2
commit f9290ea683
2 changed files with 416 additions and 3 deletions

View File

@@ -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<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
ErrorDetail::from(self).serialize(serializer)
}
}
/// Result type alias for ZCLAW operations
pub type Result<T> = std::result::Result<T, ZclawError>;
@@ -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);
}
}

View File

@@ -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<RustErrorKind, ErrorCategory> = {
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<RustErrorKind, ErrorSeverity> = {
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<RustErrorKind, string> = {
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<string, unknown>;
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) {