真分层(df-project): - ProjectManager 加 can_transition/transition/is_terminal 状态机 - 创建加 EmptyName 校验,返回 Result 而非裸实体 - 新增 ProjectTransitionError 三种错误变体 - 调用方(idea.rs promote_idea)适配 Result 传播 双源合并: - TaskStatus::as_str 改为 const fn(允许 const 上下文调用) - task_state_machine 字符串常量从 TaskStatus enum 派生 - IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性) 架构债设计方案: - #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费) - #15 AI 状态机抽离 df-ai-session(4 阶段) - #18 配置四源统一(AppConfig 单例 + 环境变量覆盖) - #19 SQLite 连接池(r2d2)
10 KiB
架构债迁移设计方案(2026-06-29)
创建: 2026-06-29 | 状态: 设计草案 关联: 全量走查报告-2026-06-28 §架构缺陷 #4/#6/#8 + SQLite 单连接 #14 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
一、概览
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出目标架构 + 迁移路径 + 风险评估,不在本批次实施代码(每项都需独立 PR + e2e 验证)。
| 编号 | 问题 | 风险 | 建议时机 |
|---|---|---|---|
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop) | Phase 4 之前 |
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
| #19 | SQLite 单连接 Mutex | 中(DB 层基础) | 性能瓶颈出现时 |
二、#15 — AI 核心状态机抽离
现状
src-tauri/src/commands/ai/
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState(应属 df-ai)
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai)
├── commands.rs ← IPC 层(应留在 src-tauri)
├── tool_registry ← 工具注册(IPC 层依赖,留在 src-tauri)
└── ...
crates/df-ai/src/
├── provider.rs ← LlmProvider trait(正确)
├── openai_compat.rs ← HTTP(正确)
├── anthropic_compat.rs← HTTP(正确)
├── coordinator.rs ← 空壳(应承担 agentic loop)
└── ...
目标架构
crates/df-ai-session/ ← 新 crate(避免 df-ai 引 reqwest 同时引会话状态)
├── session.rs ← AiSession / PerConvState / SessionState
├── agentic_loop.rs ← run_agentic_loop(纯逻辑,依赖 LlmProvider trait)
├── events.rs ← AiChatEvent enum
└── approvals.rs ← PendingApproval / ApprovalKind
src-tauri/src/commands/ai/
├── mod.rs ← 仅 IPC glue(invoke_handler 注册 + AppState 转发)
├── commands.rs ← ai_send_message / ai_approve / ai_reject
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types)
迁移路径(4 阶段)
阶段 1:创建 df-ai-session crate,仅迁移类型定义(AiSession / AiChatEvent 等),不改逻辑。src-tauri 改为 re-export。
阶段 2:迁移 run_agentic_loop 函数体。保持现有调用方接口不变(pub async fn run_agentic_loop(...)),仅改归属。
阶段 3:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」(依赖 IPC 层,保留)。
阶段 4:src-tauri 仅保留 IPC glue,所有 AI 状态机测试可在 df-ai-session 独立运行。
风险
- 循环依赖:
agentic_loop需要 LlmProvider(df-ai-core)+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。解决:把 AiToolRegistry trait 下沉到 df-ai-core,handler 实现留在 src-tauri。 - 测试覆盖:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
三、#8 — IPC 错误结构化
现状
// src-tauri/src/commands/mod.rs:26
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
// 所有 IPC 命令
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
df-types/error.rs 的 11 个变体(NotFound/Validation/InvalidState { current, expected })在 IPC 边界全部 .to_string() 拍平。前端只能拿到字符串,无法分辨错误类型。
目标架构
// df-types/src/error.rs 新增 Serialize
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum IpcError {
NotFound { entity: String, id: String },
Validation { field: String, reason: String },
InvalidState { entity: String, current: String, expected: Vec<String> },
Unauthorized { reason: String },
Internal { message: String },
}
// IPC 命令改为
async fn xxx() -> Result<T, IpcError> { ... }
Tauri invoke 自动 serialize 为 JSON:
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
前端消费
// src/api/types.ts
type IpcError =
| { kind: 'not_found'; data: { entity: string; id: string } }
| { kind: 'validation'; data: { field: string; reason: string } }
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
| { kind: 'unauthorized'; data: { reason: string } }
| { kind: 'internal'; data: { message: string } }
// 使用
try { await invoke(...) }
catch (e: unknown) {
const err = e as IpcError
if (err.kind === 'not_found') router.push('/projects')
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
}
迁移路径
阶段 1:df-types 新增 IpcError enum + From<df_types::error::Error> for IpcError。
阶段 2:src-tauri IPC 命令逐个从 Result<T, String> 改为 Result<T, IpcError>。保持向后兼容:旧前端读 err.message 时降级为字符串(IpcError 实现 Display 输出 message)。
阶段 3:前端 api 层加 IpcError 类型 + 在关键 view(Projects/Tasks/Ideas)按 kind 分支处理。
阶段 4:删除 err_str 辅助函数。
风险
- Tauri invoke 错误序列化:Tauri 默认把
Result::Err经 JSON 传前端,但前端invoke().catch()拿到的是string或object?需验证:Tauri v2 对自定义 error 类型的序列化行为。 - 向后兼容:旧前端代码
e?.toString()需仍工作。IpcError 的 Display 实现保证字符串友好。
四、#18 — 配置四源统一
现状(散落 env::var 读取)
| 读取点 | 用途 | 性质 |
|---|---|---|
df-relay/src/main.rs:17 DF_RELAY_ADDR |
relay 监听地址 | 启动配置 |
df-relay/src/relay.rs:38 DF_RELAY_TOKEN |
relay 鉴权 | 启动配置(panic 兜底) |
df-nodes/src/script_node.rs:141,155 DF_SCRIPT_BLACKLIST/WHITELIST |
脚本策略 | 运行时读取 |
df-nodes/src/ai_node.rs:389-398 GLM_BASE_URL/GLM_API_KEY/GLM_MODEL |
AINode 兜底 provider | 运行时读取 |
src-tauri/src/commands/ai/tool_registry.rs:2780 DEVFLOW_ENV_PROBE_ENABLED |
环境探测开关 | 运行时读取 |
src-tauri/src/main.rs:116-121 APPDATA/HOME |
OS 路径 | 系统变量 |
df-execute/src/env_snapshot.rs:216,253 SHELL/LANG |
Shell 探测 | 系统变量 |
目标架构
不强行统一所有读取(OS 路径 / Shell 探测等系统变量性质不同),而是:
- df-types 新增
AppConfigstruct(集中定义项目级配置项) - 启动时一次性读取 env + DB KV,构造
AppConfig实例 - AppState 持有
Arc<AppConfig>,各模块经 state 读取 - 文档化配置矩阵:env / DB KV / 默认值 / panic 的关系
// df-types/src/config.rs
pub struct AppConfig {
pub relay: RelayConfig,
pub script_policy: ScriptPolicyConfig,
pub ai_node: AiNodeConfig,
pub env_probe: bool,
}
pub struct RelayConfig {
pub addr: String, // 默认 "127.0.0.1:8080"
pub token: String, // 缺失 panic
}
pub struct ScriptPolicyConfig {
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
pub whitelist: Vec<String>, // 默认 []
}
// src-tauri/src/state.rs
pub struct AppState {
pub config: Arc<AppConfig>,
// ...
}
迁移路径
阶段 1:df-types 定义 AppConfig,在 src-tauri 启动时构造。
阶段 2:逐个替换散落的 std::env::var 为 state.config.xxx。保持 env var 作为 override(向后兼容)。
阶段 3:前端 Settings 增加「环境变量覆盖」可视化。
风险
- df-relay 独立 binary:relay 是独立进程,不经 src-tauri AppState。解决:df-relay 自己构造
RelayConfig(共享 df-types::config 类型)。
五、#19 — SQLite 连接池
现状
// crates/df-storage/src/db.rs:13-16
pub struct Database {
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
}
注释自承 TODO: 考虑使用 r2d2 连接池替代单连接 Mutex。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
目标架构
pub struct Database {
pool: r2d2::Pool<SqliteConnectionManager>,
}
impl Database {
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(...)
}
}
迁移路径
阶段 1:df-storage 加 r2d2 + r2d2_sqlite 依赖,改 Database 内部为 pool。
阶段 2:所有 Repo 的 conn.blocking_lock() 改为 pool.get()?(spawn_blocking 内获取连接)。
阶段 3:WAL 模式 + 合理 pool_size(桌面应用 4-8 即可)。
风险
- WAL 已开(
db.rs:22 PRAGMA journal_mode=WAL),读写不互斥,pool 能真正并行。 - 事务原子性:现有批量插入用
unchecked_transaction(),pool 模式下需确认PooledConnection的 transaction 行为一致。 - 测试改造:所有 Repo 测试的
Database::open_in_memory()需改为 pool 模式。
六、本批次已落地(参考)
| 编号 | 改动 | 文件 |
|---|---|---|
| #17 | TaskStatus 字符串常量从 enum 派生(as_str 改 const fn) |
crates/df-types/src/types.rs + crates/df-nodes/src/task_state_machine.rs |
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | crates/df-project/src/manager.rs + src-tauri/src/commands/idea.rs |
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
七、推进顺序建议
- #8 IPC 错误结构化 — 风险中等,收益直接(前端可差异化处理)
- #15 AI 状态机抽离 — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
- #18 配置统一 — 与 #15 协同(新 crate 也需要读配置)
- #19 连接池 — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)