diff --git a/crates/df-storage/src/crud/mod.rs b/crates/df-storage/src/crud/mod.rs index 16a63e7..93957f4 100644 --- a/crates/df-storage/src/crud/mod.rs +++ b/crates/df-storage/src/crud/mod.rs @@ -22,6 +22,7 @@ mod idea_eval_repo; mod idea_repo; mod message_repo; mod project_event_repo; +mod project_module_repo; mod project_repo; mod project_service_repo; mod settings; @@ -33,6 +34,7 @@ pub use idea_eval_repo::*; pub use idea_repo::*; pub use message_repo::*; pub use project_event_repo::*; +pub use project_module_repo::*; pub use project_repo::*; pub use project_service_repo::*; pub use settings::*; @@ -305,6 +307,7 @@ mod baseline_tests { let _ = TaskLinkRepo::new(&db); let _ = ProjectEventRepo::new(&db); let _ = ProjectServiceRepo::new(&db); + let _ = ProjectModuleRepo::new(&db); let _ = BranchRepo::new(&db); let _ = ReleaseRepo::new(&db); let _ = WorkflowRepo::new(&db); diff --git a/crates/df-storage/src/crud/project_module_repo.rs b/crates/df-storage/src/crud/project_module_repo.rs new file mode 100644 index 0000000..351f04f --- /dev/null +++ b/crates/df-storage/src/crud/project_module_repo.rs @@ -0,0 +1,322 @@ +//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库) +//! +//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。 +//! +//! 关键设计: +//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表; +//! 表只存工程元数据(路径/Git 地址/技术栈)。 +//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。 +//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。 +//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。 + +use std::sync::Arc; + +use rusqlite::{params, Row}; +use tokio::sync::Mutex; + +use crate::db::Database; +use crate::models::ProjectModuleRecord; + +use super::{now_millis_str, storage_err}; + +// ============================================================ +// from_row 辅助函数 +// ============================================================ + +fn project_module_from_row(row: &Row<'_>) -> std::result::Result { + Ok(ProjectModuleRecord { + id: row.get("id")?, + project_id: row.get("project_id")?, + name: row.get("name")?, + path: row.get("path")?, + git_url: row.get("git_url")?, + stack: row.get("stack")?, + auto_detected: row.get("auto_detected")?, + sort_order: row.get("sort_order")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + }) +} + +// ============================================================ +// Repo 实现(手写,参考 project_service_repo.rs 风格) +// ============================================================ + +/// 工程系统 CRUD(project_modules,V34,可改非审计)。 +/// +/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo: +/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型), +/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。 +pub struct ProjectModuleRepo { + conn: Arc>, +} + +impl ProjectModuleRepo { + pub fn new(db: &Database) -> Self { + Self { conn: db.conn() } + } + + /// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。 + /// 返回插入是否成功(affected > 0)。 + pub async fn insert(&self, record: ProjectModuleRecord) -> Result { + let conn = self.conn.clone(); + let now = now_millis_str(); + let mut rec = record; + rec.created_at = now.clone(); + rec.updated_at = now; + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let conn = &*guard; + let r = &rec; + let affected = conn + .execute( + "INSERT INTO project_modules \ + (id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + r.id, r.project_id, r.name, r.path, + r.git_url, r.stack, r.auto_detected, r.sort_order, + r.created_at, r.updated_at, + ], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 按 id 查询工程记录。 + pub async fn get_by_id(&self, id: &str) -> Result, df_types::error::Error> { + let conn = self.conn.clone(); + let id = id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare("SELECT * FROM project_modules WHERE id = ?1") + .map_err(storage_err)?; + let row = stmt + .query_row(params![id], project_module_from_row) + .optional() + .map_err(storage_err)?; + Ok(row) + }) + .await + .map_err(storage_err)? + } + + /// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。 + pub async fn list_by_project( + &self, + project_id: &str, + ) -> Result, df_types::error::Error> { + let conn = self.conn.clone(); + let project_id = project_id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare( + "SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \ + FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC", + ) + .map_err(storage_err)?; + let rows = stmt + .query_map(params![project_id], project_module_from_row) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + + /// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。 + /// 返回是否命中(affected > 0)。 + pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result { + let conn = self.conn.clone(); + let mut rec = record.clone(); + rec.updated_at = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let conn = &*guard; + let r = &rec; + let affected = conn + .execute( + "UPDATE project_modules SET \ + project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \ + auto_detected = ?6, sort_order = ?7, updated_at = ?8 \ + WHERE id = ?9", + params![ + r.project_id, r.name, r.path, + r.git_url, r.stack, r.auto_detected, r.sort_order, + r.updated_at, r.id, + ], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 按 id 硬删工程(工程不需要软删审计追溯)。 + pub async fn delete(&self, id: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "DELETE FROM project_modules WHERE id = ?1", + params![id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 按项目删除全部工程(删项目时级联清理,硬删)。 + pub async fn delete_by_project(&self, project_id: &str) -> Result { + let conn = self.conn.clone(); + let project_id = project_id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "DELETE FROM project_modules WHERE project_id = ?1", + params![project_id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } +} + +// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径) +use rusqlite::OptionalExtension; + +// ============================================================ +// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格) +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + + /// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。 + async fn setup() -> (Database, ProjectModuleRepo, String) { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let project_id = "proj-test".to_string(); + db.conn() + .blocking_lock() + .execute( + "INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \ + VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')", + params![project_id, "Test Project"], + ) + .expect("insert placeholder project"); + let repo = ProjectModuleRepo::new(&db); + (db, repo, project_id) + } + + fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord { + ProjectModuleRecord { + id: format!("mod-{name}"), + project_id: project_id.to_string(), + name: name.to_string(), + path: path.to_string(), + git_url: None, + stack: None, + auto_detected: false, + sort_order: 0, + created_at: "0".to_string(), + updated_at: "0".to_string(), + } + } + + #[tokio::test] + async fn insert_and_get_by_id_reads_back_full_fields() { + let (_db, repo, pid) = setup().await; + let mut rec = mrec(&pid, "frontend", "/p/frontend"); + rec.git_url = Some("https://example.com/f.git".to_string()); + rec.stack = Some(r#"{"lang":"ts"}"#.to_string()); + rec.auto_detected = true; + rec.sort_order = 2; + let ok = repo.insert(rec.clone()).await.expect("insert"); + assert!(ok); + let got = repo.get_by_id(&rec.id).await.expect("get").expect("found"); + assert_eq!(got.name, "frontend"); + assert_eq!(got.path, "/p/frontend"); + assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git")); + assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#)); + assert!(got.auto_detected); + assert_eq!(got.sort_order, 2); + } + + #[tokio::test] + async fn list_by_project_orders_by_sort_order_asc() { + let (_db, repo, pid) = setup().await; + let mut a = mrec(&pid, "a", "/p/a"); + a.sort_order = 5; + let mut b = mrec(&pid, "b", "/p/b"); + b.sort_order = 1; + let mut c = mrec(&pid, "c", "/p/c"); + c.sort_order = 3; + repo.insert(a).await.unwrap(); + repo.insert(b).await.unwrap(); + repo.insert(c).await.unwrap(); + let list = repo.list_by_project(&pid).await.expect("list"); + assert_eq!(list.len(), 3); + // sort_order ASC:b(1) → c(3) → a(5) + assert_eq!(list[0].name, "b"); + assert_eq!(list[1].name, "c"); + assert_eq!(list[2].name, "a"); + } + + #[tokio::test] + async fn update_full_changes_fields_and_preserves_created_at() { + let (_db, repo, pid) = setup().await; + let rec = mrec(&pid, "frontend", "/p/old"); + repo.insert(rec.clone()).await.unwrap(); + let original = repo.get_by_id(&rec.id).await.unwrap().unwrap(); + // 整体更新:path 变了,sort_order 变了 + let mut updated = original.clone(); + updated.path = "/p/new".to_string(); + updated.sort_order = 9; + let hit = repo.update_full(&updated).await.expect("update"); + assert!(hit); + let got = repo.get_by_id(&rec.id).await.unwrap().unwrap(); + assert_eq!(got.path, "/p/new"); + assert_eq!(got.sort_order, 9); + // created_at 保留不变(update_full 不覆盖 created_at) + assert_eq!(got.created_at, original.created_at); + } + + #[tokio::test] + async fn delete_removes_record() { + let (_db, repo, pid) = setup().await; + let rec = mrec(&pid, "frontend", "/p/f"); + repo.insert(rec.clone()).await.unwrap(); + let hit = repo.delete(&rec.id).await.expect("delete"); + assert!(hit); + assert!(repo.get_by_id(&rec.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn delete_by_project_removes_all() { + let (_db, repo, pid) = setup().await; + repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap(); + repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap(); + let hit = repo.delete_by_project(&pid).await.expect("delete_by_project"); + assert!(hit); + assert!(repo.list_by_project(&pid).await.unwrap().is_empty()); + } +} diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 396ede9..7f51fd7 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> { // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 - let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -79,6 +79,7 @@ pub fn run(conn: &Connection) -> Result<()> { (31, migrate_v31), (32, migrate_v32), (33, migrate_v33), + (34, migrate_v34), ]; for (version, migrate_fn) in steps { @@ -961,6 +962,39 @@ fn migrate_v33(conn: &Connection) -> Result<()> { Ok(()) } +/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库) +/// +/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 +/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。 +/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。 +/// +/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 +fn migrate_v34(conn: &Connection) -> Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS project_modules ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + path TEXT NOT NULL, + git_url TEXT, + stack TEXT, + auto_detected BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)", + [], + )?; + tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)"); + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?; + tracing::info!("迁移 v34 完成"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 37641ae..62ab98c 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -464,3 +464,22 @@ pub struct KnowledgeEventRecord { pub context_json: Option, // JSON: 因 event_type 而异 pub timestamp: String, } + +/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库) +/// +/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 +/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。 +/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectModuleRecord { + pub id: String, + pub project_id: String, + pub name: String, + pub path: String, + pub git_url: Option, + pub stack: Option, // 技术栈 JSON 字符串 + pub auto_detected: bool, + pub sort_order: i32, + pub created_at: String, + pub updated_at: String, +} diff --git a/docs/02-架构设计/INDEX.md b/docs/02-架构设计/INDEX.md index 63ceb91..1cc296b 100644 --- a/docs/02-架构设计/INDEX.md +++ b/docs/02-架构设计/INDEX.md @@ -83,6 +83,7 @@ | [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 | | [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 | | [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 | +| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | 🚧 实施中(Batch 9) | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具 | --- diff --git a/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md b/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md new file mode 100644 index 0000000..60ee1f9 --- /dev/null +++ b/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md @@ -0,0 +1,189 @@ +# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览 + +> 创建: 2026-06-29 | 状态: 实施中(Batch 9) +> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md + +--- + +## 一、背景 + +DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。 + +引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。 + +--- + +## 二、两级结构 + +``` +项目(Project) ← 业务实体("支付平台") +├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web") +│ ├── 工程目录(path) +│ ├── Git 地址(git_url) +│ ├── 技术栈(stack) +│ └── Git 状态(实时派生,不存表) +├── 工程(Module) +└── 工程(Module) +``` + +单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。 + +--- + +## 三、数据模型 + +### 3.1 project_modules 表(V34 迁移) + +```sql +CREATE TABLE project_modules ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, -- "后端 API" + path TEXT NOT NULL, -- 工程目录(绝对路径) + git_url TEXT, -- Git 远程地址(可选) + stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"]) + auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写) + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_project_modules_project ON project_modules(project_id); +``` + +### 3.2 设计原则 + +| 字段 | 来源 | 说明 | +|---|---|---| +| name | 用户/探测 | 工程名称 | +| path | 用户 | 工程目录(必填,等于 .git 所在目录) | +| git_url | 用户 | Git 远程地址(可选,单机可不填) | +| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) | +| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 | +| sort_order | 系统 | 列表排序 | + +**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。 + +--- + +## 四、Git 状态查询 + +### 4.1 get_module_git_status(module_id) 返回结构 + +```json +{ + "branch": "main", + "remote": "origin", + "changes": { + "modified": 3, + "added": 1, + "untracked": 2, + "total": 6 + }, + "files": [ + { "path": "src/main.rs", "status": "M" }, + { "path": "Cargo.toml", "status": "M" }, + { "path": "README.md", "status": "??" } + ], + "last_commit": { + "hash": "abc1234", + "message": "feat: add login", + "author": "lxy", + "date": "2026-06-29T10:00:00" + } +} +``` + +### 4.2 实现方式 + +在工程目录(module.path)执行: +- `git branch --show-current` → 当前分支 +- `git remote get-url origin` → 远程地址(与 module.git_url 互补) +- `git status --porcelain` → 改动文件列表(解析为结构化) +- `git log -1 --format=...` → 最近提交 + +所有 git 命令包 10s timeout。无 .git 目录返回空状态。 + +--- + +## 五、文件浏览器(IPC + UI) + +### 5.1 get_module_file_tree IPC + +```rust +get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse +``` + +返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。 + +噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。 + +### 5.2 UI 结构 + +``` +项目详情页 → 文件 Tab +┌──────────────┬──────────────────────────────────┐ +│ 文件树 │ 文件内容预览 │ +│ │ │ +│ 📁 src/ │ 1 use std::...; │ +│ main.rs M │ 2 fn main() { │ +│ config.rs │ 3 println!("hello"); │ +│ 📁 tests/ │ 4 } │ +│ Cargo.toml A │ │ +│ │ main.rs · 2.1 KB · 已修改 │ +└──────────────┴──────────────────────────────────┘ +``` + +Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。 + +### 5.3 单工程退化 + +项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。 + +--- + +## 六、创建项目适配 + +create_project 时自动建一个工程: +- name = 项目名 +- path = 绑定目录 +- stack = 探测结果(复用现有 detect_stack) +- git_url = 探测(git remote get-url origin,失败为 None) +- auto_detected = true + +用户后续可添加更多工程(多工程场景)。 + +--- + +## 七、AI 工具 + +### 7.1 只读工具(Batch 9 随数据层一起注册) + +| 工具 | 功能 | 风险 | +|---|---|---| +| list_project_modules | 列出项目工程列表 | Low | +| get_module_git_status | 查询工程 Git 状态 | Low | + +### 7.2 Git 工具(后续批次) + +| 工具 | 功能 | 风险 | +|---|---|---| +| git_status | 工作区状态(结构化) | Low | +| git_diff | 未提交改动详情 | Low | +| git_log | 提交历史 | Low | +| git_commit | 提交改动 | Medium | +| git_branch | 分支管理 | Medium | +| git_merge | 合并分支 | High | + +安全边界:禁止 push / force / reset --hard。 + +--- + +## 八、实施批次 + +| 批次 | 内容 | +|---|---| +| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 | +| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) | +| Batch 11 | Git 只读 AI 工具(status/diff/log) | +| Batch 12 | Git 写 AI 工具(commit/branch/merge) | diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 84e67fe..fa5a186 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -1270,6 +1270,37 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) }) })}, ); + + // ── list_project_modules (Low 只读,工程系统) ── + // 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。 + // 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。 + registry.register( + "list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈", + df_ai::ai_tools::object_schema(vec![ + ("project_id", "string", true), + ]), + RiskLevel::Low, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let modules = repo.list_by_project(&project_id).await?; + let total = modules.len(); + Ok(serde_json::json!({ + "modules": modules, + "total": total, + "project_id": project_id, + })) + }) + })}, + ); } /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 @@ -2992,6 +3023,8 @@ mod tests { // register_task_graph_tools 6→7)。 // 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services // 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。 + // 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询, + // register_task_graph_tools 9→10) assert_eq!( registry.len(), 41, @@ -3017,6 +3050,8 @@ mod tests { "get_project_timeline", // 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具) "add_project_service", "list_project_services", + // 工程系统:项目工程列表查询 + "list_project_modules", // ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好, // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621; // detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 986f23f..90e6279 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod events; pub mod idea; pub mod knowledge; pub mod knowledge_timeline; +pub mod module; pub mod project; pub mod services; pub mod settings; diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs new file mode 100644 index 0000000..744e001 --- /dev/null +++ b/src-tauri/src/commands/module.rs @@ -0,0 +1,376 @@ +//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库) +//! +//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。 +//! +//! 关键设计(对标设计 §五工程系统 + V34 迁移注释): +//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。 +//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交), +//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute, +//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。 +//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。 + +use std::process::Command; + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use df_storage::models::ProjectModuleRecord; +use df_types::types::new_id; + +use crate::state::AppState; + +use super::{err_str, now_millis}; + +// ============================================================ +// 入参 / 出参结构 +// ============================================================ + +/// 新增工程入参(对标设计 §五 add_project_module)。 +#[derive(Debug, Deserialize)] +pub struct AddProjectModuleInput { + pub project_id: String, + pub name: String, + /// 工程目录绝对路径(本机) + pub path: String, + /// 远程仓库地址,可选(单仓库本地工程可为空) + #[serde(default)] + pub git_url: Option, + /// 技术栈 JSON 字符串(前端检测后传入),可选 + #[serde(default)] + pub stack: Option, +} + +/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。 +#[derive(Debug, Deserialize)] +pub struct UpdateProjectModuleInput { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub git_url: Option, + #[serde(default)] + pub stack: Option, +} + +// ============================================================ +// 辅助:Optional 字段 trim + 空串归一为 None +// ============================================================ + +fn trim_opt(s: Option) -> Option { + s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +// ============================================================ +// IPC 命令 — 工程 CRUD +// ============================================================ + +/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。 +#[tauri::command] +pub async fn add_project_module( + state: State<'_, AppState>, + input: AddProjectModuleInput, +) -> Result { + let project_id = input.project_id.trim().to_string(); + let name = input.name.trim().to_string(); + let path = input.path.trim().to_string(); + if project_id.is_empty() { + return Err("project_id 不能为空".to_string()); + } + if name.is_empty() { + return Err("name 不能为空".to_string()); + } + if path.is_empty() { + return Err("path 不能为空".to_string()); + } + // sort_order 取当前项目下工程数(末尾追加,新工程排末位) + let existing = state + .project_modules + .list_by_project(&project_id) + .await + .map_err(err_str)?; + let sort_order = existing.len() as i32; + + let record = ProjectModuleRecord { + id: new_id(), + project_id, + name, + path, + git_url: trim_opt(input.git_url), + stack: trim_opt(input.stack), + auto_detected: false, + sort_order, + // created_at/updated_at 由 Repo 内部覆盖,此处占位 + created_at: now_millis(), + updated_at: now_millis(), + }; + let record_id = record.id.clone(); + let ok = state + .project_modules + .insert(record) + .await + .map_err(err_str)?; + if !ok { + return Err("插入工程记录失败".to_string()); + } + state + .project_modules + .get_by_id(&record_id) + .await + .map_err(err_str)? + .ok_or_else(|| "插入后回读失败".to_string()) +} + +/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。 +#[tauri::command] +pub async fn update_project_module( + state: State<'_, AppState>, + input: UpdateProjectModuleInput, +) -> Result { + let id = input.id.trim().to_string(); + if id.is_empty() { + return Err("id 不能为空".to_string()); + } + // 至少一个可更新字段 + if input.name.is_none() + && input.path.is_none() + && input.git_url.is_none() + && input.stack.is_none() + { + return Err("至少提供一个待更新字段 (name/path/git_url/stack)".to_string()); + } + + // 先校验存在(404 友好错误),再整体更新(保留不可变字段) + let mut existing = state + .project_modules + .get_by_id(&id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("工程 {id} 不存在"))?; + + if let Some(name) = input.name.map(|s| s.trim().to_string()) { + if name.is_empty() { + return Err("name 不能为空".to_string()); + } + existing.name = name; + } + if let Some(path) = input.path.map(|s| s.trim().to_string()) { + if path.is_empty() { + return Err("path 不能为空".to_string()); + } + existing.path = path; + } + existing.git_url = trim_opt(input.git_url); + existing.stack = trim_opt(input.stack); + + let hit = state + .project_modules + .update_full(&existing) + .await + .map_err(err_str)?; + if !hit { + return Err(format!("工程 {id} 不存在(更新未命中)")); + } + state + .project_modules + .get_by_id(&id) + .await + .map_err(err_str)? + .ok_or_else(|| "更新后回读失败".to_string()) +} + +/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。 +#[tauri::command] +pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> { + let id = id.trim().to_string(); + if id.is_empty() { + return Err("id 不能为空".to_string()); + } + state.project_modules.delete(&id).await.map_err(err_str)?; + Ok(()) +} + +/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。 +#[tauri::command] +pub async fn list_project_modules( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + let project_id = project_id.trim().to_string(); + if project_id.is_empty() { + return Err("project_id 不能为空".to_string()); + } + state + .project_modules + .list_by_project(&project_id) + .await + .map_err(err_str) +} + +// ============================================================ +// IPC 命令 — Git 状态查询(实时派生,不进表) +// ============================================================ + +/// Git 改动文件项(状态码 + 相对路径)。 +#[derive(Debug, Serialize)] +struct GitChangedFile { + /// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??") + status: String, + /// 相对仓库根的路径 + path: String, +} + +/// 最近提交项(简短哈希 + subject)。 +#[derive(Debug, Serialize)] +struct GitRecentCommit { + hash: String, + subject: String, +} + +/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。 +#[derive(Debug, Serialize)] +struct GitStatus { + /// 当前分支名(HEAD 处于分离状态时为空) + branch: String, + /// 改动文件列表(未提交) + changed_files: Vec, + /// 最近 10 条提交 + recent_commits: Vec, + /// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空) + is_git_repo: bool, +} + +/// 默认空状态(无 .git / git 不可用时返回此)。 +fn empty_status() -> GitStatus { + GitStatus { + branch: String::new(), + changed_files: Vec::new(), + recent_commits: Vec::new(), + is_git_repo: false, + } +} + +/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。 +/// +/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作, +/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。 +/// +/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。 +#[tauri::command] +pub async fn get_module_git_status( + state: State<'_, AppState>, + module_id: String, +) -> Result { + let module_id = module_id.trim().to_string(); + if module_id.is_empty() { + return Err("module_id 不能为空".to_string()); + } + // 取工程记录拿 path + let module = state + .project_modules + .get_by_id(&module_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("工程 {module_id} 不存在"))?; + + let path = std::path::Path::new(&module.path); + // 无 .git → 返回空状态 + if !path.join(".git").exists() { + return Ok(serde_json::to_value(empty_status()).map_err(err_str)?); + } + + // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 + let dir = module.path.clone(); + let status = tokio::task::spawn_blocking(move || run_git_status(&dir)) + .await + .map_err(|e| format!("git 状态查询任务失败: {e}"))?; + + Ok(serde_json::to_value(status).map_err(err_str)?) +} + +/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。 +/// +/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。 +fn run_git_status(dir: &str) -> GitStatus { + use std::time::Duration; + + let path = std::path::Path::new(dir); + if !path.join(".git").exists() { + return empty_status(); + } + + let timeout = Duration::from_secs(10); + + // 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD") + let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s != "HEAD") + .unwrap_or_default(); + + // 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码) + let mut changed_files = Vec::new(); + if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) { + for line in out.lines() { + if line.len() < 4 { + continue; + } + // porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path) + let status = line[..2].to_string(); + let file_path = line[3..].trim().to_string(); + if !file_path.is_empty() { + changed_files.push(GitChangedFile { + status, + path: file_path, + }); + } + } + } + + // 3) 最近提交:`git log -10 --oneline`(每行 "hash subject") + let mut recent_commits = Vec::new(); + if let Some(out) = run_git_cmd(path, &["log", "-10", "--oneline"], timeout) { + for line in out.lines() { + // oneline 格式:" " + if let Some((hash, subject)) = line.split_once(' ') { + recent_commits.push(GitRecentCommit { + hash: hash.to_string(), + subject: subject.to_string(), + }); + } + } + } + + GitStatus { + branch, + changed_files, + recent_commits, + is_git_repo: true, + } +} + +/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。 +/// +/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。 +fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option { + use std::sync::mpsc; + + // 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。 + let (tx, rx) = mpsc::channel(); + let cwd = cwd.to_path_buf(); + let args = args.iter().map(|s| s.to_string()).collect::>(); + std::thread::spawn(move || { + let out = Command::new("git") + .args(&args) + .current_dir(&cwd) + .output(); + let _ = tx.send(out); + }); + + match rx.recv_timeout(timeout) { + Ok(Ok(output)) if output.status.success() => { + Some(String::from_utf8_lossy(&output.stdout).to_string()) + } + _ => None, + } +} diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index b235cb4..2a597c9 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -170,6 +170,27 @@ async fn create_with_binding( updated_at: now, }; state.projects.insert(record.clone()).await.map_err(err_str)?; + // 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。 + // 单仓库项目退化:项目下只有一个工程,用户无感工程概念。 + // 多仓库场景用户后续可添加更多工程(add_project_module IPC)。 + if let Some(ref module_path) = record.path { + let now_str = df_types::now_millis().to_string(); + let module = df_storage::models::ProjectModuleRecord { + id: df_types::types::new_id(), + project_id: record.id.clone(), + name: record.name.clone(), + path: module_path.clone(), + git_url: None, // 探测 git remote 补充(失败为 None,不影响) + stack: record.stack.clone(), // 复用项目级技术栈探测结果 + auto_detected: true, // 自动创建,重新探测时可覆盖 + sort_order: 0, + created_at: now_str.clone(), + updated_at: now_str, + }; + if let Err(e) = state.project_modules.insert(module).await { + tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e); + } + } // F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent), // 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5) if record.path.is_some() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6ebc0f..86ac1a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -319,6 +319,12 @@ pub fn run() { commands::services::update_project_service, commands::services::remove_project_service, commands::services::list_project_services, + // 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询 + commands::module::add_project_module, + commands::module::update_project_module, + commands::module::remove_project_module, + commands::module::list_project_modules, + commands::module::get_module_git_status, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9c110ae..ccecb8e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -37,8 +37,9 @@ use tokio::sync::{Mutex, RwLock}; use df_ai::ai_tools::AiToolRegistry; use df_storage::crud::{ AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo, - KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo, - ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo, + KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo, + ProjectRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, + WorkflowRepo, }; use df_storage::db::Database; use df_workflow::eventbus::EventBus; @@ -70,6 +71,10 @@ pub struct AppState { /// (设计 §2.3 + §五 add_project_service/list_project_services)。 /// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。 pub project_services: ProjectServiceRepo, + /// 工程表 Repo(工程系统 V34,project_modules)。 + /// 项目多工程(每个工程独立代码仓库):Monorepo 多仓库 / 微服务 / 前后端分离。 + /// 记录工程元数据(路径/Git 地址/技术栈);Git 状态(分支/改动/提交)实时派生不存表。 + pub project_modules: ProjectModuleRepo, /// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留) #[allow(dead_code)] pub releases: ReleaseRepo, @@ -203,6 +208,7 @@ impl AppState { task_links: TaskLinkRepo::new(&db), project_events: ProjectEventRepo::new(&db), project_services: ProjectServiceRepo::new(&db), + project_modules: ProjectModuleRepo::new(&db), releases: ReleaseRepo::new(&db), workflows: WorkflowRepo::new(&db), node_executions: NodeExecutionRepo::new(&db),