修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导

- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
This commit is contained in:
2026-06-27 21:32:12 +08:00
parent a6d692270f
commit 9c3f27f4ca
26 changed files with 743 additions and 104 deletions

View File

@@ -463,9 +463,9 @@ pub async fn ai_chat_send(
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取首条 user 目标 pinned_goal(仅 None 时写,连续追问视为同目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
@@ -751,12 +751,12 @@ pub async fn ai_approve(
Ok("completed".to_string())
}
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
fn authz_debug(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true)
.open("E:/wk-lab/devflow/authz-debug.log")
{
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = writeln!(f, "{}", msg);
}
}
@@ -774,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
None
}
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。
///
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
/// 失败回退 None(对比退化,不报跨盘提示)。
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
fn workspace_drive() -> Option<String> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("."));
drive_letter(&root.to_string_lossy())
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
}
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
@@ -1366,6 +1359,13 @@ pub async fn ai_chat_edit(
conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
conv.model_override = model_override.clone();
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&new_message);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
}
}
}
let _tool_defs = state.ai_tools.tool_definitions();
@@ -1531,10 +1531,10 @@ pub async fn ai_chat_force_send(
}
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取首条 user 目标 pinned_goal(仅 None 时写,与 send 路径一致)。
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
// pinned_goal 已更新,续跑用新目标)。
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);

View File

@@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
"run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d),
None => workspace_root_str(),
None => String::new(),
};
Some(TrustKey::Execute { dir })
}
@@ -423,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
.unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy())
}
Err(_) => workspace_root_str(),
Err(_) => String::new(),
}
}
@@ -783,7 +783,7 @@ pub struct PerConvState {
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
///
/// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
pub pinned_goal: Option<String>,

View File

@@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String {
format!("{}_{}", modified.unwrap_or(0), meta.len())
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
///
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
@@ -339,19 +330,15 @@ async fn rename_or_cross_volume_copy(
}
}
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
/// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录
///
/// 双层校验:
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
///
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
fn resolve_workspace_path_with_allowed(
path: &str,
allowed: &AllowedDirs,
@@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed(
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
///
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
resolve_workspace_path_impl(path, &AllowedDirs::default())
}
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
// 无授权目录时给出明确引导,避免指向编译期常量。
let root = allowed.first_persistent_dir()
.ok_or_else(|| anyhow::anyhow!(
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
))?;
root.join(path)
};
// 词法层:防明显越界(不存在路径的兜底)。
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
if !allowed.is_authorized(&resolved) {
anyhow::bail!("路径不在授权目录内: {}", path);
}
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
if resolved.exists() {
let canon_resolved = resolved.canonicalize()?;
if !allowed.is_authorized(&canon_resolved) {
@@ -449,10 +436,11 @@ async fn bind_dir_to_project(
pub fn build_ai_tool_registry(
db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) -> AiToolRegistry {
let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db);
register_file_tools(&mut registry, allowed_dirs);
register_file_tools(&mut registry, allowed_dirs, data_dir);
register_http_tools(&mut registry);
registry
}
@@ -1412,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
fn register_file_tools(
registry: &mut AiToolRegistry,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) {
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
@@ -1967,8 +1956,9 @@ fn register_file_tools(
("permanent", "boolean", false),
]),
RiskLevel::High,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
{ let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
let data_dir = data_dir.clone();
Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
let snap = allowed_dirs.read().await.clone();
@@ -2004,15 +1994,15 @@ fn register_file_tools(
}))
} else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace
let trash_dir = workspace_root().join(".trash");
// .trash 放 app_data_dir 下,不依赖编译期 workspace_root
let trash_dir = data_dir.join(".trash");
tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
@@ -2325,16 +2315,14 @@ fn register_file_tools(
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
None => String::new(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)

View File

@@ -140,6 +140,21 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool
state.ideas.restore(&id).await.map_err(err_str)
}
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
#[tauri::command]
pub async fn relate_ideas(
state: State<'_, AppState>,
subject_id: String,
target_ids: Vec<String>,
) -> Result<(), String> {
state
.ideas
.sync_related_ids(&subject_id, &target_ids)
.await
.map_err(err_str)
}
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
#[tauri::command]
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
@@ -199,10 +214,10 @@ pub async fn promote_idea(
..record
};
if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
// 回写失败:补偿删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await {
tracing::error!("补偿删项目 {project_id} 也失败(需人工清理): {del_err}");
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿删项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
}

View File

@@ -47,3 +47,9 @@ pub async fn settings_delete(
) -> Result<bool, String> {
state.settings.delete(&key).await.map_err(err_str)
}
/// 获取 DevFlow 数据目录路径(运行期确定,跨平台)
#[tauri::command]
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}