运行时可观测性:
- 新增 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 补充蓝图驱动开发规范
502 lines
19 KiB
Rust
502 lines
19 KiB
Rust
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_repos:GitHub 仓库注册表(全局锚点)────────────────────────────────
|
||
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);
|
||
}
|
||
}
|