Files
DevFlow/src-tauri/src/state.rs
绝尘 8da6bdcec2 新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C
会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测

文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09  PASS
2026-06-19 18:58:48 +08:00

904 lines
44 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.
//! 应用全局状态 — 数据库、Repo、事件总线、节点注册表、AI 会话
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock, Semaphore};
use df_ai::ai_tools::AiToolRegistry;
use df_storage::crud::{
AiConversationRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo, KnowledgeEventsRepo,
KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, SettingsRepo, TaskRepo,
WorkflowRepo,
};
use df_storage::db::Database;
use df_workflow::eventbus::EventBus;
use df_workflow::registry::NodeRegistry;
use df_workflow::state::StateMachine;
use crate::commands::ai::AiSession;
// ============================================================
// 知识库配置(提取 + 注入)
// ============================================================
/// AI 提炼触发方式
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ExtractTrigger {
/// 对话正常完成时(默认)
OnComplete,
/// 对话闲置 N 秒后
OnIdle,
/// 仅手动按钮触发
ManualOnly,
}
/// 知识库行为配置(存 AppState 内存,前后端通过 IPC 读写)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeConfig {
/// 提炼总开关,默认 true
pub auto_extract: bool,
/// 提炼触发方式,默认 OnComplete
pub trigger_mode: ExtractTrigger,
/// 最少消息数守卫(防闲聊噪音),默认 4
pub min_messages: u32,
/// 闲置触发超时(ms),默认 30000
pub idle_timeout_ms: u64,
/// 聊天时自动注入相关知识开关,默认 true
pub auto_inject: bool,
/// 语义检索(向量)总开关,默认 false——关闭时纯 LIKE 零外部依赖
#[serde(default)]
pub vector_enabled: bool,
/// embedding 用的 provider id(仅 openai_compat 类型,Anthropic 无 embed API)
#[serde(default)]
pub embedding_provider_id: Option<String>,
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
#[serde(default)]
pub embedding_model: Option<String>,
}
impl Default for KnowledgeConfig {
fn default() -> Self {
Self {
auto_extract: true,
trigger_mode: ExtractTrigger::OnComplete,
min_messages: 4,
idle_timeout_ms: 30_000,
auto_inject: true,
vector_enabled: false,
embedding_provider_id: None,
embedding_model: None,
}
}
}
// ============================================================
// LLM 调用并发控制(双层 Semaphore + 可选 per-provider 层)
// ============================================================
/// LLM 调用并发控制 — 全局 LLM 调用限流 global + 单对话内 per_conv 双层 Semaphore + 可选 per-provider 层
///
/// 限流对象:所有真实 LLM 调用(主循环 stream_llm / 标题生成 / 知识提炼)。
/// 不限流本地工具执行tools.execute——本地操作无外部成本、不受 RPM 约束。
///
/// ## globalpermits 默认 3「LLM 调用并发限流」原义)
/// F-09 batch5 曾把 global 改「并发会话数上限」loop 入口持整 loop用户决策修正「不设并发会话上限」后
/// 已删除 loop 入口 acquire。global 回归原义:由各单次 LLM 调用点stream_llm 重试循环 / 标题 / 压缩 /
/// 提炼 / 项目分析扫描)各自 `acquire_global()` 拿 permit、调用结束 Drop 释放,防 provider 429。
/// 多对话 loop 并发不限数(用户接受 token 暴增)。
///
/// ## per_conv 改 HashMap<conv_id, Semaphore>
/// 原应用级单信号量AiSession 单例 + generating 互斥下退化)改为
/// `HashMap<String, Arc<Semaphore>>`每对话一份permits=2主循环 + 标题 + 提炼各自限流)。
/// `run_agentic_loop` 入口 `acquire_per_conv(conv_id)` 拿 1 permit 持整个 loop 生命周期(含工具执行/审批
/// 等待/重试),防单对话内并发 LLM 调用失控。这是单对话内限流,非会话数限制,符合用户「不设上限」。
/// `acquire_per_conv(conv_id)`lock HashMap → 无则建permits=2→ clone Arc → 释放 lock → acquire_owned。
/// conv 退出清理(`release_conv(conv_id)`conv 删除时 remove 条目(防 HashMap 无限增长)。
///
/// 运行时调整tokio Semaphore 的 permits 数构造时固定、不可增减,
/// 故 `global` 用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装——替换内层 Arc 即重建 Semaphore。
/// 已持有旧 permit 的任务不受影响permit 绑定旧 Semaphore软收敛
/// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。
/// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits对话内并发上限 2 固定),故无重建需求。
///
/// ## F-260614-04: per-provider 层(可选)
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经
/// `acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。
/// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空,
/// `acquire_for_provider` 返回 None(无限流,行为同 F-01 前)。零变化保证。
/// 全局容量 = min(sum(各 provider 上限), global_cap):由调用方在配置时约束
/// (set_provider_caps 传 min(sum, global_cap)),非运行时强约束。
#[derive(Clone)]
pub struct LlmConcurrency {
/// 全局 LLM 调用并发上限(permits 默认 3)F-09 batch5 修正后回归原义,由各单次 LLM 调用点
/// (stream_llm/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429,不再由 loop 入口持整 loop。
global: Arc<Mutex<Arc<Semaphore>>>,
/// 单对话内并发上限(F-09 B 批5)HashMap<conv_id, Semaphore>,每对话 permits=2。
/// acquire 时按 conv_id 取/建release_conv 在 loop 结束 + 无 pending 时 remove。
per_conv: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
/// 每对话内并发上限(permits 默认 2构造时传入 new(3, 2) 的第二参)。
/// F-09 B 批5:AtomicUsize 支持运行时热改(ai_set_concurrency_config 的 per_conv_limit)。
/// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定)**新建对话**用新值;
/// 为使热改立即全量生效set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。
per_conv_permits: Arc<AtomicUsize>,
/// F-260614-04: per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。
/// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。
per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
}
impl LlmConcurrency {
pub fn new(global: usize, per_conv: usize) -> Self {
Self {
global: Arc::new(Mutex::new(Arc::new(Semaphore::new(global)))),
per_conv: Arc::new(Mutex::new(HashMap::new())),
per_conv_permits: Arc::new(AtomicUsize::new(per_conv)),
per_provider: Arc::new(Mutex::new(HashMap::new())),
}
}
/// 取全局 LLM 调用并发 permit由各单次 LLM 调用点(stream_llm/标题/压缩/提炼/项目分析)
/// 各自调用、调用结束 Drop 释放。F-09 batch5 修正后不再由 run_agentic_loop 入口持整 loop。
/// 重建后(set_global)新请求自动走最新 Semaphore。
pub async fn acquire_global(&self) -> tokio::sync::OwnedSemaphorePermit {
let sema = self.global.lock().await.clone();
sema.acquire_owned().await.expect("llm global semaphore closed")
}
/// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphorepermits=当前 per_conv_permits(默认 2)。
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
let sema = {
let mut map = self.per_conv.lock().await;
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
map.entry(conv_id.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
.clone()
};
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
}
/// F-09 B 批5conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
/// remove 后已持 permit 不受影响(permit 绑旧 Arc随 Drop 释放),仅阻止新条目累积。
pub async fn release_conv(&self, conv_id: &str) {
self.per_conv.lock().await.remove(conv_id);
}
/// 重建全局 Semaphore软收敛旧 permit 不回收,待其释放后新限制完全生效)
pub async fn set_global(&self, permits: usize) {
*self.global.lock().await = Arc::new(Semaphore::new(permits));
}
/// 重建单对话 Semaphore(F-09 B 批5 后 per_conv 为 HashMap)。
/// 行为:更新 per_conv_permits(AtomicUsize) + 清空 HashMap(软收敛:旧 permit 随 Drop 释放,
/// 新对话 acquire 用新 permits 值重建 Semaphore)。已建对话若仍在跑,旧 Semaphore 不变;
/// 下次该 conv 新 acquire 时因 HashMap 已清空会重建为新 permits。
pub async fn set_per_conv(&self, permits: usize) {
self.per_conv_permits
.store(permits, std::sync::atomic::Ordering::SeqCst);
self.per_conv.lock().await.clear();
}
/// F-260614-04: 取 per-provider 并发 permit(可选)。
///
/// - provider 在 `per_provider` 表中有配置 → 取其 Semaphore permit,返回 Some。
/// - provider 无配置(单 provider 场景或未 set_provider_caps)→ 返回 None,无限流。
///
/// 调用方(agentic loop)用法:
/// ```ignore
/// let _global_permit = llm_concurrency.acquire_global().await;
/// let _per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await;
/// let _provider_permit = llm_concurrency.acquire_for_provider(&provider_id).await;
/// ```
/// 三 permit 均绑 guard Drop 自动释放。None 时无 permit 需释放(行为同 F-01 前)。
pub async fn acquire_for_provider(
&self,
provider_id: &str,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
let sema = {
let map = self.per_provider.lock().await;
map.get(provider_id).cloned()
}?;
// Semaphore 存在 → acquire。expect 同 acquire_global/per_conv:Semaphore 不会 close
// (无 close() 调用,仅在 set_provider_caps 时替换表内 Arc,旧 Arc permit 仍有效)。
Some(
sema.acquire_owned()
.await
.expect("llm per_provider semaphore closed"),
)
}
/// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。
///
/// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map,
/// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap)
/// 由调用方在构造 map 时应用(本函数不做强约束,只落表)。
///
/// 传空 map → 清空 per_provider 表(所有 provider 回退到无 per-provider 限流)。
pub async fn set_provider_caps(&self, caps: HashMap<String, usize>) {
let mut map = self.per_provider.lock().await;
map.clear();
for (pid, permits) in caps {
// permits=0 等同无配置(Semaphore::new(0) 永远 acquire 不到 → 死锁),
// 故 permits=0 跳过(不落表 → acquire_for_provider 返 None → 无限流)。
if permits > 0 {
map.insert(pid, Arc::new(Semaphore::new(permits)));
}
}
}
}
/// 应用全局状态 — 通过 `app.manage()` 注入command 中以 `State<'_, AppState>` 取用
pub struct AppState {
/// 数据库句柄Arc 包装,便于在异步任务中重建 Repo
pub db: Arc<Database>,
/// 灵感表 Repo
pub ideas: IdeaRepo,
/// 项目表 Repo
pub projects: ProjectRepo,
/// 任务表 Repo
pub tasks: TaskRepo,
/// 发布表 Repo预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub releases: ReleaseRepo,
/// 工作流执行表 Repo
pub workflows: WorkflowRepo,
/// 节点执行表 Repo预留:NodeExecutionRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub node_executions: NodeExecutionRepo,
/// 工作流事件总线tokio broadcast
pub event_bus: EventBus,
/// 节点注册表(已注册内置节点)
pub registry: Arc<NodeRegistry>,
// ── AI ──
/// AI 提供商配置 Repo
pub ai_providers: AiProviderRepo,
/// AI 对话历史 Repo
pub ai_conversations: AiConversationRepo,
/// AI 工具执行审计 Repo
pub ai_tool_executions: AiToolExecutionRepo,
/// AI 工具注册表
pub ai_tools: Arc<AiToolRegistry>,
/// AI 会话状态
pub ai_session: Arc<Mutex<AiSession>>,
// ── 知识库 ──
/// 知识库 Repo
pub knowledge: KnowledgeRepo,
/// 知识生命线事件 Repo(产生/审核/引用/归档审计)
pub knowledge_events: KnowledgeEventsRepo,
/// 知识库行为配置(提取 + 注入)
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
pub settings: SettingsRepo,
// ── LLM 并发控制 ──
/// LLM 调用并发上限(全局 + 单对话双层 Semaphore运行时可调
pub llm_concurrency: LlmConcurrency,
// ── Agentic 循环轮次上限 ──
/// Agentic 循环最大轮次(前端 Settings 数字配置 → AppState 字段 → 热改 command →
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
/// 默认 10与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
pub agent_max_iterations: Arc<AtomicUsize>,
// ── 流式对话失败自动重试 ──
/// 流式对话失败自动重试次数F-260616-07 / 决策 a1只重试流前失败 Init Err——未输出
/// 任何 token流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3
pub agent_max_retries: Arc<AtomicUsize>,
// ── 工作流执行状态 ──
/// 工作流执行 → 节点状态机注册表
///
/// run_workflow 创建执行器后注册其 state_machineStateMachine 内部 Arc<Mutex>
/// clone 共享底层 HashMap与下沉到 NodeContext.node_status 的是同一份);
/// cancel_workflow_node IPC 经 execution_id 取出后 set_cancelled
/// 直达运行中 HumanNode 的 is_cancelled 轮询。执行完成(成功/失败)后移除条目。
pub workflow_state_registry: Arc<Mutex<HashMap<String, StateMachine>>>,
// ── F-260619-03 Phase A: AI 工具文件访问授权目录白名单 ──
/// AI 文件工具(read/write/list/patch/...)可访问的授权目录池。
/// Phase A 仅持久化白名单(Settings KV `allowed_dirs` 加载),
/// workspace_root 始终在白名单(向后兼容)。
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
}
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
///
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
/// - workspace_root 始终视为已授权(向后兼容,默认根)
/// - 任一 persistent 目录 starts_with 命中即放行
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
/// process_tool_calls 预校验均读此字段,两端一致。
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
#[derive(Debug, Clone, Default)]
pub struct AllowedDirs {
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
pub persistent: HashSet<PathBuf>,
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
pub session: HashSet<PathBuf>,
}
impl AllowedDirs {
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
/// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。
/// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。
pub fn default_with_root() -> Self {
let mut set = HashSet::new();
set.insert(workspace_root_path());
Self { persistent: set, session: HashSet::new() }
}
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
///
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
///
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
pub fn is_authorized(&self, candidate: &Path) -> bool {
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
if is_in_system_blacklist(candidate) {
return false;
}
let root = workspace_root_path();
if candidate.starts_with(&root) {
return true;
}
self.persistent.iter().any(|d| candidate.starts_with(d))
|| self.session.iter().any(|d| candidate.starts_with(d))
}
}
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
///
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathAuthDecision {
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
Authorized,
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
NeedsAuthorization { dir: PathBuf },
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
Denied { reason: String },
}
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
///
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
/// 此处补系统核心目录。
///
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
let s = path.to_string_lossy().to_lowercase();
let seps = ['/', '\\'];
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
for (i, seg) in segs.iter().enumerate() {
// Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System
if cfg!(windows) {
if *seg == "windows" {
if let Some(next) = segs.get(i + 1) {
if matches!(*next, "system32" | "syswow64" | "system") {
return true;
}
}
}
// C:\Program Files / C:\Program Files (x86)
if *seg == "program files" || *seg == "program files (x86)" {
return true;
}
}
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
return true;
}
}
false
}
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
///
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
///
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
pub fn check_path_authorization(
raw_path: &str,
allowed: &AllowedDirs,
) -> PathAuthDecision {
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
let resolved = if Path::new(raw_path).is_absolute() {
PathBuf::from(raw_path)
} else {
workspace_root_path().join(raw_path)
};
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
if is_in_system_blacklist(&resolved) {
return PathAuthDecision::Denied {
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
};
}
if allowed.is_authorized(&resolved) {
return PathAuthDecision::Authorized;
}
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
let dir = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| workspace_root_path());
PathAuthDecision::NeedsAuthorization { dir }
}
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?);
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs));
// build_registry 需注入 Arc<Database>(TaskAdvanceNode 持 db)。
// 在 struct 字段 `db` move 前 clone,避免 E0382。
let registry = Arc::new(build_registry(db.clone()));
let state = Self {
ideas: IdeaRepo::new(&db),
projects: ProjectRepo::new(&db),
tasks: TaskRepo::new(&db),
releases: ReleaseRepo::new(&db),
workflows: WorkflowRepo::new(&db),
node_executions: NodeExecutionRepo::new(&db),
ai_providers: AiProviderRepo::new(&db),
ai_conversations: AiConversationRepo::new(&db),
ai_tool_executions: AiToolExecutionRepo::new(&db),
ai_session: Arc::new(Mutex::new(AiSession::new())),
knowledge: KnowledgeRepo::new(&db),
knowledge_events: KnowledgeEventsRepo::new(&db),
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2),
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
)),
agent_max_retries: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(),
db,
event_bus: EventBus::new(),
registry,
ai_tools,
};
// 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建,
// 使重启后待审批不丢。前端经 ai_pending_tool_calls + switchConversation 恢复 toolCard 态。
crate::commands::ai::restore_pending_approvals(&state).await;
// F-260614-04c: 启动一次性初始化 per-provider caps 表。
// 根据 DB enabled providers 建 HashMap<provider_id, global_cap>,让 agentic loop 的
// acquire_for_provider 从 None(无限流)切换到 Some(按配置限流)。disabled / weight=0
// 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。
// 单 provider 场景:该 provider cap=global_cap → acquire_global+acquire_for_provider
// 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。
state.reload_provider_caps().await;
// F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。
state.reload_allowed_dirs().await;
Ok(state)
}
/// F-260614-04c: 从 DB enabled providers 重建 per_provider caps 并 set_provider_caps。
///
/// 启动 + provider 变更(ai_update_provider_pool / 删除 / 新增)后调用。caps 策略见
/// AppState::init 注释(本轮每 provider cap = global_cap,F-04d 配差异化上限时仅改此)。
pub async fn reload_provider_caps(&self) {
let providers = match self.ai_providers.list_all().await {
Ok(ps) => ps,
Err(e) => {
tracing::warn!(
"[F-04c] 读取 providers 重建 caps 失败,保持当前 caps 表不变: {}", e
);
return;
}
};
let global_cap = 3; // 与 LlmConcurrency::new(3, 2) 的 global 上限对齐。
let caps: HashMap<String, usize> = providers
.into_iter()
.filter(|p| p.enabled && p.weight > 0)
.map(|p| (p.id, global_cap))
.collect();
self.llm_concurrency.set_provider_caps(caps).await;
}
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
///
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
/// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) {
let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => v,
Ok(None) => {
// 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root)
return;
}
Err(e) => {
tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e);
return;
}
};
let dirs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e);
return;
}
};
let mut set = HashSet::new();
// workspace_root 始终在白名单(向后兼容);不重复插入
set.insert(workspace_root_path());
for d in dirs {
let d = d.trim();
if d.is_empty() {
continue;
}
let p = PathBuf::from(d);
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
// 规范化分隔符 trim 尾部,保持与 starts_with 比对一致
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
set.insert(normalized);
}
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
let mut guard = self.allowed_dirs.write().await;
let preserved_session = std::mem::take(&mut guard.session);
*guard = AllowedDirs { persistent: set, session: preserved_session };
}
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
///
/// - 持久化:JSON 字符串数组写 `app_settings` key=`allowed_dirs`
/// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份)
/// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。
pub async fn set_allowed_dirs(&self, dirs: Vec<String>) -> Result<Vec<String>> {
// 去空 + 去重(保留顺序,前端展示友好)
let mut seen = HashSet::new();
let cleaned: Vec<String> = dirs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| seen.insert(s.clone()))
.collect();
let json = serde_json::to_string(&cleaned)?;
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
self.reload_allowed_dirs().await;
// 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径)
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
Ok(out)
}
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> {
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
out
}
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
///
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
/// 返回持久化后规范化列表(含 workspace_root)。
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
let d = dir.trim().to_string();
if d.is_empty() {
return Ok(self.get_allowed_dirs().await);
}
let mut current = self.get_allowed_dirs().await;
if !current.iter().any(|c| paths_eq(c, &d)) {
current.push(d);
}
self.set_allowed_dirs(current).await
}
/// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。
///
/// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时
/// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize
/// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。
pub async fn add_session_allowed_dir(&self, dir: String) {
let d = dir.trim();
if d.is_empty() {
return;
}
let p = PathBuf::from(d);
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
self.allowed_dirs.write().await.session.insert(normalized);
}
/// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。
///
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为
/// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。
pub async fn clear_session_allowed_dirs(&self) {
self.allowed_dirs.write().await.session.clear();
}
}
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
fn paths_eq(a: &str, b: &str) -> bool {
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
pa.eq_ignore_ascii_case(&pb)
}
/// 构建节点注册表 — 注册内置节点
///
/// 注意:不使用 `NodeRegistry::default()`,其 script 工厂为占位实现(会 panic
/// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。
///
/// "script" 节点不注册ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串,
/// 前端可构造任意 DagDef 触达无审批 shellR-PD-2。DevFlow 工作流当前为纯演示
/// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo无真实构建/部署脚本
/// 需求。需要脚本执行能力时新建独立 BuildNode白名单 + 项目目录锚定 + 复用 AI 工具
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
/// 详见 docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md。
fn build_registry(db: Arc<Database>) -> NodeRegistry {
let mut registry = NodeRegistry::new();
registry.register("human", |_config| {
Box::new(df_nodes::human_node::HumanNode)
});
// AiNode(df_nodes::ai_node, impl Node trait):
// 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id
// 则把产出落 task.output_json。工厂闭包 move 捕获 db 句柄(Arc clone 廉价)。
let ai_db = db.clone();
registry.register("ai", move |_config| {
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
});
// AiSelfReviewNode(df_nodes::ai_node, impl Node trait):
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
// review 摘要塞 NodeOutput.data 供下游 human_review 经 DAG inputs 透传。
// testing 模板 ai_self_review 节点类型对齐此注册 key。
let review_db = db.clone();
registry.register("ai_self_review", move |_config| {
Box::new(df_nodes::ai_node::AiSelfReviewNode::new(review_db.clone()))
});
// TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
// 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db,
// Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status。
// D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task。
registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
});
registry
}
#[cfg(test)]
mod tests {
use super::*;
// ============================================================
// F-260619-03 Phase A: AllowedDirs 授权语义测试
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
// ============================================================
/// workspace_root 路径在 default_with_root 白名单内授权通过
#[test]
fn test_allowed_dirs_workspace_root_authorized() {
let allowed = AllowedDirs::default_with_root();
let root = workspace_root_path();
assert!(allowed.is_authorized(&root), "workspace_root 应被授权");
// workspace 内子路径也应授权(starts_with workspace_root)
let child = root.join("src").join("main.rs");
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
}
/// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir)
#[test]
fn test_allowed_dirs_custom_authorized() {
let mut set = HashSet::new();
let custom = PathBuf::from("E:/wk-test-external-dir");
set.insert(custom.clone());
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
// 精确命中 + 子路径 starts_with 命中
assert!(allowed.is_authorized(&custom));
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
}
/// 未授权目录被拒绝
#[test]
fn test_allowed_dirs_unauthorized_rejected() {
let mut set = HashSet::new();
set.insert(PathBuf::from("E:/wk-test-authorized"));
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
}
/// 默认(空 persistent)只授权 workspace_root
#[test]
fn test_allowed_dirs_default_empty_persistent() {
let allowed = AllowedDirs::default();
// 空 persistent,无 workspace_root → 任何路径都不授权
// (default_with_root 才含 workspace_root;Default 不含,用于边界测试)
assert!(!allowed.is_authorized(&PathBuf::from("E:/anything")));
}
/// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失)
#[test]
fn test_allowed_dirs_settings_key_stable() {
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
}
// ============================================================
// F-260619-03 Phase B: session 临时授权语义测试
// ============================================================
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
#[test]
fn test_allowed_dirs_session_authorized() {
let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-temp-session"));
let allowed = AllowedDirs { persistent: HashSet::new(), session };
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
}
/// Phase B: persistent + session 任一命中即放行
#[test]
fn test_allowed_dirs_persistent_or_session() {
let mut persistent = HashSet::new();
persistent.insert(PathBuf::from("E:/wk-persist"));
let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-session"));
let allowed = AllowedDirs { persistent, session };
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
}
// ============================================================
// F-260619-03 Phase C: 系统目录黑名单测试
// ============================================================
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
#[test]
fn test_blacklist_windows_system32() {
let mut persistent = HashSet::new();
// 用户误把整个 C:\ 加入白名单
persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() };
// System32 应被黑名单拒(尽管 C:\ 在白名单)
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
}
/// Phase C: Windows Program Files 黑名单命中
#[test]
fn test_blacklist_windows_program_files() {
let mut persistent = HashSet::new();
persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() };
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
}
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
#[test]
fn test_blacklist_no_false_positive() {
// "my program files backup" 不应被拒(分段匹配,非精确段)
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
// 普通工作目录不拒
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
}
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
#[test]
fn test_blacklist_unix_system_dirs() {
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
// 普通用户目录不拒
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
}
// ============================================================
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
// ============================================================
/// Phase B/C: workspace_root 路径 → Authorized
#[test]
fn test_check_path_authorized_workspace() {
let allowed = AllowedDirs::default_with_root();
let rel = "src/main.rs";
match check_path_authorization(rel, &allowed) {
PathAuthDecision::Authorized => {}
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
}
}
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
#[test]
fn test_check_path_needs_auth() {
let allowed = AllowedDirs::default(); // 空,无 workspace_root
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
PathAuthDecision::NeedsAuthorization { dir } => {
assert_eq!(dir, PathBuf::from("E:/wk-external"));
}
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
}
}
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
#[test]
fn test_check_path_session_hit() {
let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-session"));
let allowed = AllowedDirs { persistent: HashSet::new(), session };
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
PathAuthDecision::Authorized => {}
other => panic!("session 命中应 Authorized, got {:?}", other),
}
}
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
#[test]
fn test_check_path_denied_blacklist() {
let allowed = AllowedDirs::default_with_root();
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
PathAuthDecision::Denied { .. } => {}
other => panic!("System32 应 Denied, got {:?}", other),
}
}
}