新增: 项目多工程系统(工程表+Git 状态查询+AI 工具)

一个项目可含多个工程(Module),每个工程是独立代码仓库,
有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下
只有一个工程,用户无感工程概念。

数据层:
- project_modules 表(V34 迁移):工程名/目录/Git 地址/技术栈
- ProjectModuleRepo:完整 CRUD + 按项目列表(排序)

后端接口:
- 工程 CRUD:添加/更新/删除/列表
- Git 状态查询:实时跑 git 命令返回当前分支/改动文件/最近提交
  (10 秒超时,无 Git 仓库返回空状态)

创建项目适配:
- 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)

AI 工具:
- list_project_modules:列出项目工程列表(AI 了解项目结构)

同时新增工程系统设计方案文档。
This commit is contained in:
2026-06-29 01:39:42 +08:00
parent 3c18dea45b
commit b72df78462
12 changed files with 1016 additions and 3 deletions

View File

@@ -1270,6 +1270,37 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
})
})},
);
// ── 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 代码智能)

View File

@@ -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;

View File

@@ -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<String>,
/// 技术栈 JSON 字符串(前端检测后传入),可选
#[serde(default)]
pub stack: Option<String>,
}
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
#[derive(Debug, Deserialize)]
pub struct UpdateProjectModuleInput {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub git_url: Option<String>,
#[serde(default)]
pub stack: Option<String>,
}
// ============================================================
// 辅助:Optional 字段 trim + 空串归一为 None
// ============================================================
fn trim_opt(s: Option<String>) -> Option<String> {
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<ProjectModuleRecord, String> {
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<ProjectModuleRecord, String> {
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<Vec<ProjectModuleRecord>, 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<GitChangedFile>,
/// 最近 10 条提交
recent_commits: Vec<GitRecentCommit>,
/// 该目录是否为 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<serde_json::Value, String> {
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 格式:"<short-hash> <subject>"
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<String> {
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::<Vec<_>>();
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,
}
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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),