dev-manager-tauri/src-tauri/src/db.rs
lanrtop eb346ae1fd feat: add runtime diagnostics, health center, and project mentor
运行时可观测性:
- 新增 runtime_logs SQLite 表,提供全局 log_event 写入函数(保留最近 500 条)
- mcp_server/mcp_inject/groups 关键路径接入结构化日志
- MCP 新增 get_diagnostics 工具,返回注入摘要与近期错误
- add_group_member 注入失败时前端显示黄色 toast 警告

健康中心:
- 新增 run_health_check 命令(6 项并行检查:MCP/Git 路径/项目路径/GitHub Token/编辑器/DB 延迟)
- 新增质量指标聚合(休眠项目/孤立项目/注入异常组/无路径项目)
- 侧边栏新增健康中心页面,支持手动刷新和诊断报告复制
- 设置页 MCP 状态改为真实 HTTP ping 检测

项目导师:
- 新增 get_mentor_context 后端命令,聚合健康状态/蓝图摘要/近期错误
- MCP 新增 get_project_mentor_context 工具(含产品组归属校验)
- 项目卡片底部新增 🎓 导师按钮,面板支持复制结构化 Markdown 报告
- 修复 WSL 项目路径识别:wsl_path 存 UNC 格式时自动提取 distro 和 Linux 路径,改用 wsl 命令检查路径存在性(规避 Path::exists() 对 \wsl.localhost\... 不可靠的问题)

AI 工作流:
- 新增 AI-WORKFLOW.md、.claude/skills/ 技能体系配置
- 更新 CLAUDE.md 补充蓝图驱动开发规范
2026-04-04 19:58:49 +09:00

502 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use once_cell::sync::OnceCell;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
pub type DbPool = Pool<SqliteConnectionManager>;
static POOL: OnceCell<DbPool> = OnceCell::new();
pub fn init(data_dir: PathBuf) {
std::fs::create_dir_all(&data_dir).expect("create data dir");
let db_path = data_dir.join("dev-manager.db");
let manager = SqliteConnectionManager::file(db_path);
let pool = Pool::new(manager).expect("create sqlite pool");
{
let conn = pool.get().expect("get conn for migration");
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.expect("pragma");
migrate(&conn);
}
POOL.set(pool).expect("pool already initialized");
}
pub fn pool() -> &'static DbPool {
POOL.get().expect("db not initialized")
}
fn migrate(conn: &rusqlite::Connection) {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
wsl_path TEXT,
win_path TEXT,
tech_stack TEXT,
recent_focus TEXT,
startup_commands TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS product_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT
);
CREATE TABLE IF NOT EXISTS project_group_map (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE,
role TEXT,
PRIMARY KEY (project_id, group_id)
);
CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
to_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
relation_type TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_tools (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS dev_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
cmd TEXT,
path TEXT,
version TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_env_tools (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
layer INTEGER NOT NULL,
name TEXT NOT NULL,
source TEXT,
version TEXT,
note TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS network_monitored_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
exe_path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS network_mode_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO settings (key, value) VALUES ('editor', 'cursor');
INSERT OR IGNORE INTO settings (key, value) VALUES ('wsl_distro', 'Ubuntu');
INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
",
)
.expect("migrate");
// 增量迁移:为 dev_tools 添加 env 字段(已存在则忽略)
let _ = conn.execute(
"ALTER TABLE dev_tools ADD COLUMN env TEXT NOT NULL DEFAULT 'windows'",
[],
);
// 增量迁移Git 多空间功能所需新表
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS git_accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
avatar_url TEXT,
provider TEXT NOT NULL DEFAULT 'github',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
local_root TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deploy_commands (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
command TEXT NOT NULL,
run_in TEXT NOT NULL DEFAULT 'windows'
);
",
)
.expect("migrate git spaces");
// 增量迁移git_accounts 补 UNIQUE 索引(已存在则忽略)
let _ = conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_git_accounts_username ON git_accounts(username)",
[],
);
// 增量迁移projects 表新增 Git 相关字段(已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN upstream_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN default_branch TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN last_push_at TEXT", []);
// 增量迁移platform 字段("windows" | "wsl",已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN platform TEXT", []);
// 增量迁移product_groups 表新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE product_groups ADD COLUMN space_id TEXT", []);
// 增量迁移dev_tools 表新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE dev_tools ADD COLUMN space_id TEXT", []);
// 增量迁移network_monitored_tools 新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE network_monitored_tools ADD COLUMN space_id TEXT", []);
// 增量迁移:空间级别的 settings 覆盖表
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS space_settings (
space_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (space_id, key)
);"
);
// 增量迁移:项目生命周期字段
let _ = conn.execute("ALTER TABLE projects ADD COLUMN staging_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN prod_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN server_note TEXT", []);
// ── 方案A重构project_profiles + project_workspaces ──────────────────────
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
tech_stack TEXT,
repo_url TEXT,
upstream_url TEXT,
default_branch TEXT,
staging_url TEXT,
prod_url TEXT,
server_note TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_workspaces (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES project_profiles(id) ON DELETE CASCADE,
space_id TEXT,
wsl_path TEXT,
win_path TEXT,
platform TEXT DEFAULT 'windows',
recent_focus TEXT,
startup_commands TEXT,
last_push_at TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pw_profile_space
ON project_workspaces(profile_id, ifnull(space_id, ''));
").expect("migrate project profiles/workspaces");
// ── git_reposGitHub 仓库注册表(全局锚点)────────────────────────────────
conn.execute_batch("
CREATE TABLE IF NOT EXISTS git_repos (
id TEXT PRIMARY KEY,
repo_url TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
default_branch TEXT DEFAULT 'main',
upstream_url TEXT,
staging_url TEXT,
prod_url TEXT,
server_note TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
tech_stack TEXT,
last_synced_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_git_repos_url ON git_repos(repo_url);
").expect("migrate git_repos");
// project_workspaces 添加 repo_id 外键(已存在则忽略)
let _ = conn.execute(
"ALTER TABLE project_workspaces ADD COLUMN repo_id TEXT REFERENCES git_repos(id) ON DELETE SET NULL",
[],
);
// 增量迁移:项目模板系统
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
platform TEXT NOT NULL DEFAULT 'both',
tech_stack TEXT,
init_commands TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_template_files (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE,
path TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
UNIQUE(template_id, path)
);
").expect("migrate project templates");
// 迁移 project_group_map将外键从 projects(id) 改为 project_workspaces(id)
let pgm_sql: Option<String> = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_group_map'",
[],
|row| row.get(0),
).ok();
if pgm_sql.map(|s| s.contains("REFERENCES projects")).unwrap_or(false) {
conn.execute_batch("
PRAGMA foreign_keys=OFF;
CREATE TABLE project_group_map_new (
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE,
role TEXT,
PRIMARY KEY (project_id, group_id)
);
INSERT OR IGNORE INTO project_group_map_new (project_id, group_id, role)
SELECT m.project_id, m.group_id, m.role
FROM project_group_map m
INNER JOIN project_workspaces pw ON pw.id = m.project_id;
DROP TABLE project_group_map;
ALTER TABLE project_group_map_new RENAME TO project_group_map;
PRAGMA foreign_keys=ON;
").expect("migrate project_group_map FK to project_workspaces");
}
// 从旧 projects 表迁移数据INSERT OR IGNORE 保证幂等)
let _ = conn.execute_batch("
INSERT OR IGNORE INTO project_profiles
(id, name, description, type, status, priority, tech_stack,
repo_url, upstream_url, default_branch,
staging_url, prod_url, server_note, updated_at)
SELECT
'prof-' || id,
name, description, type, status, priority, tech_stack,
repo_url, upstream_url, default_branch,
staging_url, prod_url, server_note, updated_at
FROM projects;
INSERT OR IGNORE INTO project_workspaces
(id, profile_id, space_id, wsl_path, win_path, platform,
recent_focus, startup_commands, last_push_at, updated_at)
SELECT
id,
'prof-' || id,
space_id, wsl_path, win_path, COALESCE(platform, 'windows'),
recent_focus, startup_commands, last_push_at, updated_at
FROM projects;
");
// 增量迁移project_profiles 加 tags 字段
let _ = conn.execute("ALTER TABLE project_profiles ADD COLUMN tags TEXT", []);
// 标签管理表
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#8b5cf6'
);
CREATE TABLE IF NOT EXISTS project_tag_map (
profile_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (profile_id, tag_id)
);
").expect("create tag tables");
// 增量迁移product_groups 加 sort_order 字段
let _ = conn.execute(
"ALTER TABLE product_groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
[],
);
// CI/CD 配置表(替代 deploy_commands
conn.execute_batch("
CREATE TABLE IF NOT EXISTS cicd_configs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE,
config_type TEXT NOT NULL DEFAULT 'web_ssh',
trigger_branch TEXT NOT NULL DEFAULT 'main',
build_command TEXT DEFAULT 'pnpm build',
node_version TEXT DEFAULT '20',
server_host TEXT,
server_user TEXT DEFAULT 'ubuntu',
deploy_path TEXT,
start_command TEXT DEFAULT 'pm2 restart app',
oss_endpoint TEXT DEFAULT 'oss-cn-hangzhou.aliyuncs.com',
created_at TEXT DEFAULT (datetime('now'))
);
").expect("create cicd_configs table");
// 资源视角画布状态(每个产品组独立保存)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS canvas_states (
group_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create canvas_states table");
// 运行时可观测性日志(最多保留 500 条)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS runtime_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
category TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
context TEXT
);
").expect("create runtime_logs table");
}
#[cfg(test)]
pub fn conn_with_schema() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap();
migrate(&conn);
conn
}
/// 写入运行时日志。失败时静默(不能让日志写入反过来崩溃业务逻辑)
pub fn log_event(category: &str, level: &str, message: &str, context: Option<&str>) {
let pool = match POOL.get() {
Some(p) => p,
None => return,
};
let conn = match pool.get() {
Ok(c) => c,
Err(_) => return,
};
let _ = conn.execute(
"INSERT INTO runtime_logs (category, level, message, context) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![category, level, message, context],
);
// 超出 550 条时才触发清理,保留最新 500 条(避免每次写入都全表扫描)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap_or(0);
if count > 550 {
let _ = conn.execute(
"DELETE FROM runtime_logs WHERE id NOT IN (SELECT id FROM runtime_logs ORDER BY id DESC LIMIT 500)",
[],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_idempotent() {
// 连续两次 migrate 不应 panic
let conn = conn_with_schema();
migrate(&conn);
}
#[test]
fn core_tables_exist() {
let conn = conn_with_schema();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' \
AND name IN ('projects','settings','spaces','git_repos','runtime_logs',\
'project_profiles','project_workspaces')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 7, "缺少核心表");
}
#[test]
fn default_settings_seeded() {
let conn = conn_with_schema();
let editor: String = conn
.query_row("SELECT value FROM settings WHERE key='editor'", [], |r| r.get(0))
.unwrap();
assert_eq!(editor, "cursor");
let port: String = conn
.query_row("SELECT value FROM settings WHERE key='mcp_port'", [], |r| r.get(0))
.unwrap();
assert_eq!(port, "27190");
}
#[test]
fn runtime_logs_insert_and_count() {
let conn = conn_with_schema();
conn.execute(
"INSERT INTO runtime_logs (category, level, message) VALUES ('test','info','hello')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn log_event_silent_when_no_pool() {
// pool 未初始化时应静默返回,不 panic
log_event("test", "info", "no-pool test", None);
}
}