新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -0,0 +1,589 @@
//! Input Augmentation 层领域类型
//!
//! 解决「@项目 / /技能 → AI 上下文」两个增强通道的结构性问题:
//! - [`MentionRef`]:resolve 前,前端选中 mention 后透传的原始引用(携带 id)
//! - [`Augmentation`]:resolve 后,注入 LLM 上下文的投影数据(已 join / 已脱敏)
//! - [`SanitizedPath`]:path 脱敏 newtype,防止裸 String 误用未脱敏值
//! - [`MentionSpanDto`]:chip 元数据,前端按 span 精确替换 mention token
//! - [`ResolveError`]:Resolver 投影失败原因
//!
//! serde 统一 `tag = "kind"`,新增变体向后兼容(老前端遇未知 kind 忽略)。
//! 设计详见 plan: mighty-wibbling-papert.md 核心设计 1。
use serde::{Deserialize, Serialize};
use crate::types::{IdeaStatus, ProjectId, ProjectStatus, TaskId, TaskStatus};
// ============================================================
// 路径脱敏 newtype
// ============================================================
/// 脱敏后的文件系统路径(已根据 provider locality 过滤)。
///
/// 用 newtype 包裹,强制调用方走 [`crate::augmentation`] 模块的脱敏入口,
/// 防止裸 `String` 误用未脱敏的原始路径(含用户名/公司目录/客户名)直接注入 LLM。
///
/// 脱敏规则由 `src-tauri` 侧 `augmentation::sanitize` 模块实现:
/// - Local provider(localhost/127.0.0.1/0.0.0.0/::1/ollama):保留全路径
/// - Remote provider:仅保留 basename
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SanitizedPath(String);
impl SanitizedPath {
/// 构造一个已脱敏路径。仅在 sanitize 入口调用,业务代码不应直接构造。
pub fn new(sanitized: impl Into<String>) -> Self {
Self(sanitized.into())
}
/// 以字符串切片访问路径内容。
pub fn as_str(&self) -> &str {
&self.0
}
/// 取回内部的 String 所有权。
pub fn into_inner(self) -> String {
self.0
}
}
impl From<SanitizedPath> for String {
fn from(p: SanitizedPath) -> Self {
p.0
}
}
impl std::fmt::Display for SanitizedPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
// ============================================================
// MentionRef — resolve 前(前端透传)
// ============================================================
/// 前端选中 mention 后透传的原始引用(resolve 前)。
///
/// 携带 id(以及展示用 name),供后端 Resolver 投影成 [`Augmentation`]。
/// Skill 变体不带 id —— skill 以 name 为唯一键(无全局 id)。
///
/// serde `tag = "kind"`:序列化形如 `{"kind":"project","id":"...","name":"..."}`。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MentionRef {
/// @项目
Project {
#[serde(rename = "id")]
id: ProjectId,
#[serde(rename = "name")]
name: String,
},
/// @任务
Task {
#[serde(rename = "id")]
id: TaskId,
#[serde(rename = "name")]
name: String,
},
/// @灵感
Idea {
#[serde(rename = "id")]
id: String,
#[serde(rename = "name")]
name: String,
},
/// /技能(name 唯一键)
Skill {
#[serde(rename = "name")]
name: String,
},
}
impl MentionRef {
/// 返回 mention 的 kind 标签(与 serde tag 一致):`project`/`task`/`idea`/`skill`。
pub fn kind_str(&self) -> &'static str {
match self {
MentionRef::Project { .. } => "project",
MentionRef::Task { .. } => "task",
MentionRef::Idea { .. } => "idea",
MentionRef::Skill { .. } => "skill",
}
}
/// 返回展示用 label(即 mention 选中时的可见文本)。
pub fn label(&self) -> &str {
match self {
MentionRef::Project { name, .. }
| MentionRef::Task { name, .. }
| MentionRef::Idea { name, .. }
| MentionRef::Skill { name } => name,
}
}
/// 返回 chip 渲染/查找用的稳定标识:有 id 用 id,Skill 用 name。
pub fn ref_id(&self) -> &str {
match self {
MentionRef::Project { id, .. }
| MentionRef::Task { id, .. }
| MentionRef::Idea { id, .. } => id,
MentionRef::Skill { name } => name,
}
}
}
// ============================================================
// Augmentation — resolve 后(注入 LLM 用)
// ============================================================
/// Resolver 投影后的增强上下文(resolve 后,注入用)。
///
/// 与 [`MentionRef`] 的区别:
/// - 字段更丰富(join 后的 status / description / project_name 等)
/// - `Project` 带 `path: Option<SanitizedPath>`(已脱敏)
/// - `Skill` 带 `body`(已剥 frontmatter 的正文)
///
/// serde `tag = "kind"`,与 `MentionRef` 同构,前端可共用 chip 渲染逻辑。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Augmentation {
/// @项目 注入体
Project {
#[serde(rename = "id")]
id: ProjectId,
#[serde(rename = "name")]
name: String,
#[serde(rename = "status")]
status: ProjectStatus,
#[serde(rename = "description")]
description: String,
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
path: Option<SanitizedPath>,
},
/// @任务 注入体
Task {
#[serde(rename = "id")]
id: TaskId,
#[serde(rename = "title")]
title: String,
#[serde(rename = "status")]
status: TaskStatus,
#[serde(rename = "description")]
description: String,
/// 所属项目名(join 展示用);游离任务为 None。
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
project_name: Option<String>,
},
/// @灵感 注入体
Idea {
#[serde(rename = "id")]
id: String,
#[serde(rename = "title")]
title: String,
#[serde(rename = "status")]
status: IdeaStatus,
#[serde(rename = "description")]
description: String,
},
/// /技能 注入体
Skill {
#[serde(rename = "name")]
name: String,
/// 技能来源(plugin/command/user 等),用于审计/展示。
#[serde(rename = "source")]
source: String,
/// 已剥 frontmatter 的技能正文。
#[serde(rename = "body")]
body: String,
},
}
impl Augmentation {
/// 返回 kind 标签(与 serde tag 一致)。
pub fn kind_str(&self) -> &'static str {
match self {
Augmentation::Project { .. } => "project",
Augmentation::Task { .. } => "task",
Augmentation::Idea { .. } => "idea",
Augmentation::Skill { .. } => "skill",
}
}
/// 返回注入段落的小标题(供 build_augmentation_segment 拼接)。
pub fn section_label(&self) -> &str {
match self {
Augmentation::Project { .. } => "项目",
Augmentation::Task { .. } => "任务",
Augmentation::Idea { .. } => "灵感",
Augmentation::Skill { .. } => "技能",
}
}
}
// ============================================================
// MentionSpanDto — chip 元数据
// ============================================================
/// 用户消息内 mention 区间的元数据,驱动 chip 精确替换。
///
/// 前端按 `{start, length}` 在原文中切出区间并替换为 chip;若区间被用户编辑破坏
/// (长度/内容不再匹配),降级为纯文本展示,避免正则误匹配。
///
/// 注意:序列化字段名 `refId`(camelCase,对齐前端 TS 类型),Rust 侧用 `ref_id`。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MentionSpanDto {
/// 区间起点(字节/字符偏移由前端约定,后端仅透传)。
#[serde(rename = "start")]
pub start: u32,
/// 区间长度。
#[serde(rename = "length")]
pub length: u32,
/// kind 标签:`project`/`task`/`idea`/`skill`。
#[serde(rename = "kind")]
pub kind: String,
/// 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查。
#[serde(rename = "refId")]
pub ref_id: String,
/// chip 展示文本(项目名/任务标题/技能名)。
#[serde(rename = "label")]
pub label: String,
}
impl MentionSpanDto {
/// 构造一个 span。kind 取自 MentionRef::kind_str,ref_id 取自 MentionRef::ref_id。
pub fn new(start: u32, length: u32, kind: impl Into<String>, ref_id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
start,
length,
kind: kind.into(),
ref_id: ref_id.into(),
label: label.into(),
}
}
/// 区间终点(start + length)。
pub fn end(&self) -> u32 {
self.start.saturating_add(self.length)
}
}
// ============================================================
// ResolveError — Resolver 投影失败
// ============================================================
/// Resolver 解析 [`MentionRef`] → [`Augmentation`] 的失败原因。
///
/// `Skip` 表示该 mention 无法 resolve 但不应中断整批注入(如 skill 已删除),
/// 调用方应跳过该项继续处理其他 mention。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolveError {
/// 引用对象不存在(项目/任务/灵感/技能已删除)。
#[error("未找到引用: kind={kind}, ref_id={ref_id}")]
NotFound {
/// kind 标签。
kind: String,
/// 引用标识。
ref_id: String,
},
/// kind 与 Resolver 注册不匹配(如 Project id 传给了 TaskResolver)。
#[error("kind 不匹配: expected={expected}, actual={actual}")]
KindMismatch {
/// 期望的 kind。
expected: String,
/// 实际的 kind。
actual: String,
},
/// 跳过该 mention(如 skill 已被卸载),不视为错误,不中断整批注入。
#[error("跳过引用: kind={kind}, ref_id={ref_id}, reason={reason}")]
Skip {
/// kind 标签。
kind: String,
/// 引用标识。
ref_id: String,
/// 跳过原因(审计/日志用)。
reason: String,
},
}
// ============================================================
// 单测:serde round-trip + 辅助方法
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{IdeaStatus, ProjectStatus, TaskStatus};
// ---------- SanitizedPath ----------
#[test]
fn sanitized_path_transparent_serde() {
// serde(transparent):JSON 直接是裸字符串,无 {inner} 包裹
let p = SanitizedPath::new("E:/wk-lab/devflow");
let json = serde_json::to_string(&p).expect("serialize");
assert_eq!(json, "\"E:/wk-lab/devflow\"");
let back: SanitizedPath = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, p);
}
#[test]
fn sanitized_path_accessors() {
let p = SanitizedPath::new("/tmp/x");
assert_eq!(p.as_str(), "/tmp/x");
assert_eq!(p.to_string(), "/tmp/x");
assert_eq!(p.into_inner(), "/tmp/x");
}
#[test]
fn sanitized_path_from_to_string() {
let p = SanitizedPath::new("a/b");
let s: String = p.into();
assert_eq!(s, "a/b");
}
// ---------- MentionRef round-trip ----------
#[test]
fn mention_ref_project_round_trip() {
let m = MentionRef::Project {
id: "p-1".to_string(),
name: "devflow".to_string(),
};
let json = serde_json::to_string(&m).expect("serialize");
// tag=kind, snake_case, 字段名 id/name
assert_eq!(
json,
"{\"kind\":\"project\",\"id\":\"p-1\",\"name\":\"devflow\"}"
);
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, m);
}
#[test]
fn mention_ref_skill_round_trip() {
let m = MentionRef::Skill {
name: "review".to_string(),
};
let json = serde_json::to_string(&m).expect("serialize");
assert_eq!(json, "{\"kind\":\"skill\",\"name\":\"review\"}");
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, m);
}
#[test]
fn mention_ref_all_variants_round_trip() {
let cases = vec![
serde_json::to_value(MentionRef::Project {
id: "p".into(),
name: "pn".into(),
})
.unwrap(),
serde_json::to_value(MentionRef::Task {
id: "t".into(),
name: "tn".into(),
})
.unwrap(),
serde_json::to_value(MentionRef::Idea {
id: "i".into(),
name: "in".into(),
})
.unwrap(),
serde_json::to_value(MentionRef::Skill { name: "s".into() }).unwrap(),
];
for v in cases {
let back: MentionRef = serde_json::from_value(v.clone()).expect("deserialize");
let re = serde_json::to_value(&back).expect("serialize");
assert_eq!(re, v, "round-trip not stable");
}
}
#[test]
fn mention_ref_helpers() {
let p = MentionRef::Project {
id: "p1".into(),
name: "Devflow".into(),
};
assert_eq!(p.kind_str(), "project");
assert_eq!(p.label(), "Devflow");
assert_eq!(p.ref_id(), "p1");
let s = MentionRef::Skill { name: "review".into() };
assert_eq!(s.kind_str(), "skill");
assert_eq!(s.ref_id(), "review"); // skill ref_id = name
assert_eq!(s.label(), "review");
}
// ---------- Augmentation round-trip ----------
#[test]
fn augmentation_project_with_path_round_trip() {
let a = Augmentation::Project {
id: "p-1".to_string(),
name: "devflow".to_string(),
status: ProjectStatus::InProgress,
description: "AI-native dev tool".to_string(),
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"project\""));
assert!(json.contains("\"status\":\"in_progress\""));
assert!(json.contains("\"path\":\"E:/wk-lab/devflow\""));
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, a);
}
#[test]
fn augmentation_project_path_none_omitted() {
let a = Augmentation::Project {
id: "p-2".to_string(),
name: "nopath".to_string(),
status: ProjectStatus::Planning,
description: String::new(),
path: None,
};
let json = serde_json::to_string(&a).expect("serialize");
// skip_serializing_if 生效:path 字段不出现在 JSON 中
// 检查 "path" key(带引号),避免误匹配 name 值里的 path 子串
assert!(!json.contains("\"path\""), "path should be omitted, got: {}", json);
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, a);
}
#[test]
fn augmentation_task_with_project_name_round_trip() {
let a = Augmentation::Task {
id: "t-1".to_string(),
title: "Implement X".to_string(),
status: TaskStatus::Todo,
description: "do X".to_string(),
project_name: Some("devflow".to_string()),
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"task\""));
assert!(json.contains("\"status\":\"todo\""));
assert!(json.contains("\"project_name\":\"devflow\""));
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, a);
}
#[test]
fn augmentation_idea_round_trip() {
let a = Augmentation::Idea {
id: "i-1".to_string(),
title: "Multi-round parallel".to_string(),
status: IdeaStatus::Approved,
description: "desc".to_string(),
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"idea\""));
assert!(json.contains("\"status\":\"approved\""));
// idea 无 project_name / path 字段
assert!(!json.contains("project_name"));
assert!(!json.contains("\"path\""));
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, a);
}
#[test]
fn augmentation_skill_round_trip() {
let a = Augmentation::Skill {
name: "review".to_string(),
source: "command".to_string(),
body: "## Review\nDo X".to_string(),
};
let json = serde_json::to_string(&a).expect("serialize");
assert_eq!(
json,
"{\"kind\":\"skill\",\"name\":\"review\",\"source\":\"command\",\"body\":\"## Review\\nDo X\"}"
);
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, a);
}
#[test]
fn augmentation_helpers() {
let p = Augmentation::Project {
id: "p".into(),
name: "n".into(),
status: ProjectStatus::Planning,
description: String::new(),
path: None,
};
assert_eq!(p.kind_str(), "project");
assert_eq!(p.section_label(), "项目");
let s = Augmentation::Skill {
name: "x".into(),
source: "y".into(),
body: String::new(),
};
assert_eq!(s.kind_str(), "skill");
assert_eq!(s.section_label(), "技能");
}
// ---------- MentionSpanDto round-trip ----------
#[test]
fn mention_span_dto_round_trip() {
let span = MentionSpanDto::new(10, 8, "project", "p-1", "devflow");
let json = serde_json::to_string(&span).expect("serialize");
// refId camelCase
assert!(json.contains("\"refId\":\"p-1\""));
assert!(json.contains("\"kind\":\"project\""));
assert!(json.contains("\"start\":10"));
assert!(json.contains("\"length\":8"));
assert!(json.contains("\"label\":\"devflow\""));
let back: MentionSpanDto = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, span);
assert_eq!(span.end(), 18);
}
#[test]
fn mention_span_dto_end_saturates() {
let span = MentionSpanDto::new(u32::MAX, 5, "task", "t", "lbl");
// 防溢出:saturating_add 在 u32::MAX + 5 时停在上界
assert_eq!(span.end(), u32::MAX);
}
// ---------- ResolveError ----------
#[test]
fn resolve_error_display() {
let e = ResolveError::NotFound {
kind: "project".into(),
ref_id: "p-x".into(),
};
assert!(format!("{}", e).contains("p-x"));
assert!(format!("{}", e).contains("project"));
let e2 = ResolveError::KindMismatch {
expected: "project".into(),
actual: "task".into(),
};
let s = format!("{}", e2);
assert!(s.contains("project") && s.contains("task"));
let e3 = ResolveError::Skip {
kind: "skill".into(),
ref_id: "removed".into(),
reason: "skill uninstalled".into(),
};
assert!(format!("{}", e3).contains("skill uninstalled"));
}
#[test]
fn resolve_error_eq() {
let a = ResolveError::NotFound {
kind: "task".into(),
ref_id: "t".into(),
};
let b = ResolveError::NotFound {
kind: "task".into(),
ref_id: "t".into(),
};
assert_eq!(a, b);
}
}

View File

@@ -1,8 +1,13 @@
//! df-types: 核心类型定义,所有 crate 的基础依赖
pub mod augmentation;
pub mod error;
pub mod events;
pub mod types;
// 顶层 re-export:常用时间工具(跨 crate 调用方用 df_types::now_millis,避免 types:: 前缀)
pub use types::now_millis;
// Input Augmentation 层领域类型透传(跨 crate 调用方用 df_types::Augmentation 等,
// 避免 augmentation:: 前缀)。详见 augmentation 模块文档。
pub use augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError, SanitizedPath};

View File

@@ -76,6 +76,22 @@ impl IdeaStatus {
IdeaStatus::Archived => "archived",
}
}
/// 从数据库存储的小写字符串解析为 IdeaStatus(与 `as_str` 对偶)。
///
/// Input Augmentation 层 ProjectResolver/IdeaResolver 把记录的 String status 投影成
/// 类型化枚举用。未知字符串返 None(调用方按 Draft 兜底或视作数据异常跳过)。
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"draft" => IdeaStatus::Draft,
"pending_review" => IdeaStatus::PendingReview,
"approved" => IdeaStatus::Approved,
"rejected" => IdeaStatus::Rejected,
"promoted" => IdeaStatus::Promoted,
"archived" => IdeaStatus::Archived,
_ => return None,
})
}
}
impl Default for IdeaStatus {
@@ -117,6 +133,23 @@ impl ProjectStatus {
ProjectStatus::Cancelled => "cancelled",
}
}
/// 从数据库存储的小写字符串解析为 ProjectStatus(与 `as_str` 对偶)。
///
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
/// 类型化枚举用。未知字符串返 None。
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"planning" => ProjectStatus::Planning,
"in_progress" => ProjectStatus::InProgress,
"testing" => ProjectStatus::Testing,
"releasing" => ProjectStatus::Releasing,
"completed" => ProjectStatus::Completed,
"paused" => ProjectStatus::Paused,
"cancelled" => ProjectStatus::Cancelled,
_ => return None,
})
}
}
impl Default for ProjectStatus {
@@ -181,6 +214,23 @@ impl TaskStatus {
"cancelled",
]
}
/// 从数据库存储的小写字符串解析为 TaskStatus(与 `as_str` 对偶)。
///
/// Input Augmentation 层 TaskResolver 把记录的 String status 投影成类型化枚举用。
/// 未知字符串返 None。
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"todo" => TaskStatus::Todo,
"in_progress" => TaskStatus::InProgress,
"in_review" => TaskStatus::InReview,
"testing" => TaskStatus::Testing,
"done" => TaskStatus::Done,
"blocked" => TaskStatus::Blocked,
"cancelled" => TaskStatus::Cancelled,
_ => return None,
})
}
}
impl Default for TaskStatus {