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