Compare commits

115 Commits

Author SHA1 Message Date
e9e3578d26 修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
2026-07-20 00:19:50 +08:00
42efb31bbf 优化: Zed 编译卡死治理(musl 隔离 + rust-analyzer 配置)
- musl 交叉编译: 文档化 CARGO_TARGET_DIR=target-musl 隔离方案, 删除现有 349M 产物
- gitignore: 新增 target-musl/ 规则
- .zed/settings.json: 新建项目级 rust-analyzer 配置
  - 关闭 check.onSave (Zed 卡死主因: 每次保存触发全 workspace check)
  - check.workspace=false (即便手动也只查当前 crate)
  - cargo.allTargets=false (跳过 musl/交叉 target 解析)
  - files.excludeDirs 排除 target/node_modules 等
  - 保留 procMacro/tauri-codegen enable (否则宏会报红)
2026-07-04 00:53:58 +08:00
0dfaeb0f5b 优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)
- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分
- 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls)
  - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转
  - 多条/轮提取,system_prompt 仅注入 active 目标
  - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见)
- 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护
- 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见
- 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite
- 分离窗口默认置顶+置顶状态持久化(刷新后保持)
- header 菜单聚合:清空/压缩上下文收入 ... popout
- LLM 文字重叠防御:delta 重复检测+丢弃
- 置顶图标:星→大头针 pushpin
- 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
2026-07-03 13:07:03 +08:00
aa0701a2ea 优化: 响应式布局走查修复(断点统一+窄屏适配)
- 统一断点: Dashboard/Ideas/Projects/ProjectDetail 的响应式断点
  从 900px 改为 768px,与 AppLayout/AppSidebar 对齐(消除中间区间挤压)
- filter-bar 全局加 flex-wrap:wrap(窄屏自动换行,Ideas/Tasks/AuditLog 受益)
- IdeaDetail debate-container 窄屏双栏堆叠(@media 760px 切单列)
- modal-box min-width 改用 min(360px,90vw)(超小窗口不撑破)
- Tasks search-input 改 flex 布局(窄屏自适应宽度)
2026-07-03 02:35:14 +08:00
89cfbc7499 新增: 左侧导航支持收缩展开(手动+小窗口自动)
- 手动收缩:brand 区右侧收缩按钮,收缩后底部展开按钮
- 小窗口自动收缩:窗口宽度≤768px 自动切换为图标模式
- 状态持久化:用户偏好存 appSettings(df-sidebar-collapsed)
- 收缩态:仅显示图标,hover 显示 tooltip 文字
- 过渡动画:width/min-width 0.2s ease 平滑收缩
2026-07-03 02:17:26 +08:00
3d8b755229 重构: 前端DRY收口+后端测试类型对齐
- 新增 useToast composable: 消除 AiChat/Settings/Projects 4处 toast 重复
  统一默认3000ms(Projects原4000ms为操作类提示保留参数覆盖)
- 新增 utils/json.ts parseJsonArray: 消除 parseStack/parseTags/ModuleNode
  3处JSON字符串数组解析重复
- 新增 utils/html.ts escapeHtml: 消除 useMarkdown/FilePreview 2处重复
- ProjectDetail score-bar 内联三元改用 scoreTier(消除最后一处阈值硬编码)
- ConversationSidebar 删除 formatTime 透传包装(直接用 formatRelative)
- 清理死代码: parseTs/stringifyError/ErrorSink/_Unused 改私有或删除
  wrapNakedDiff 改私有(无外部 import)
- ModuleNode shortPath 改名 truncatedPath(与 useToolCard.shortPath 语义不同)
2026-07-03 00:18:21 +08:00
249b3b9ea8 重构: 跨模块样式DRY收口+审批列表增强+灵感模块优化
- 全局样式收口: page-header/btn/modal/filter-bar/back-link/empty-state
  等通用类从12个视图的scoped重复定义提取到global.css/components.css
- 审批列表增强: 筛选栏(状态/风险/工具搜索)+行展开查看完整参数结果
  +审批耗时显示+刷新spinner+硬编码颜色全部改用CSS变量
- 灵感模块: scoreTier统一评分阈值+score-bar/assessment-badge提取全局
  +statusLabelKey消除重复+i18n状态文案对齐+eval-report--muted统一空态
- Dashboard: df-panel/df-link提取全局(修scoped不渗透)+统计卡可点击跳转
  +活跃项目面板只显示active状态+灵感列表按分数排序+刷新spinner
- 修复: intent.rs tool_type类型对齐(newtype)+ToolCardList补ai-btn定义
  +SkillMention/ImageInput样式不渗透修复(提取全局)
2026-07-02 23:24:11 +08:00
eee0f06e1a 新增: 合并产出落回主对话(Coordinator 接线)
- agentic/mod.rs: plan_execution_enabled 时 decompose 后调用 dispatch/merge,
  将合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
- pinned_goals_snapshot 提前初始化,coordinator 出口复用
- Batch.md: 新增 Batch 38 记录,更新 Batch 37 提交 hash
2026-07-02 20:19:39 +08:00
46246880b3 重构: String→newtype 强类型 + 工程清理
- df-types: ExecutionId/ToolCallType 从 type 别名改为 newtype 结构体
- df-ai-core: 新增 ToolType newtype / MessageStatus 枚举,
  ChatMessage.status 从 Option<String> 改为 Option<MessageStatus>
- provider: is_active() 改用 MessageStatus::Active 模式匹配
- context/chat/conversation: 构造站点更新为枚举变体
- .gitignore: 添加分析脚本排除项,清理根目录临时文件
- Batch.md: 更新最新提交与新增 Batch 37 记录
2026-07-02 20:11:52 +08:00
b18740405c 修复: run_command 空路径报错 + 隧道日志洪水 + 启动残留清理
- tool_registry: run_command working_dir 空字符串 "" → None,修 Windows os error 123
- lib: tunnel subscriber 从频率压制(suppress_until)改为连接状态感知(is_connected),
  断开时静默丢弃事件,重连后恢复透传,记 INFO 状态变迁(治本)
- conversation_repo: 新增 cleanup_stale_pending(),超 24h 残留 pending 标记 interrupted
- restore: 启动时先清理过期 pending 再恢复审批
- state: 实现 cleanup_orphan_pending_messages(),启动时清理对应已决/超时 tool 的
  __PENDING__ 占位消息
2026-07-02 15:47:47 +08:00
d99a65f034 优化: 任务分页watcher+设置页确认弹窗复用+进展文档更新
- Tasks.vue: 补全 page/pageSize watcher,翻页触发后端查询(原翻页只改前端切片)

- Settings.vue: 自建确认弹窗替换为 ConfirmDialog 组件,删除重复 CSS

- PROGRESS.md: 更新项目速览(多Agent架构+40+工具+人设系统)
2026-07-02 13:27:49 +08:00
8fa3a93eec 修复: 用户消息气泡头像位置(MessageItem提取后scoped样式缺失)
- MessageItem.vue 提取时未带 scoped CSS,MessageList 的 scoped 样式不穿透子组件

- 补齐用户消息 flex-direction:row-reverse(头像靠右)+ 完整 scoped 样式
2026-07-02 13:22:32 +08:00
3f22fd3673 优化: i18n硬编码清扫(8文件20+处中文字符串替为i18n)
- ToolCard: 已等待 → aiTool.waitTime

- ErrorBoundary: 堆栈详情 → error.stackDetails

- TopBar: 10处硬编码(tooltip+section标签+单位) → aiChat.* keys

- FileExplorer: 4处工程操作title → fileExplorer.*

- Tasks: 快捷菜单4处 → tasks.*

- EnrichmentPanel: 取消关联 → aiChat.enrichmentDismiss

- PlanProgress: 复用已有 aiChat.conflictsPending

- WorkflowDagDisplay: 删除中文fallback(已有i18n key)

- vue-tsc + vite build 通过
2026-07-02 12:19:57 +08:00
34944df764 修复: 预存测试编译错误+快捷键绑定+批次记录同步
- df-types: augmentation 测试 5 处缺 extra 字段补齐(17 测试全绿)

- AiChat: Ctrl+N(新建对话)+Ctrl+R(重新生成)快捷键绑定(Tauri 下 preventDefault 可靠)

- Batch.md: 标记已完成项,仅剩合并产出落回主对话待接线
2026-07-02 12:16:55 +08:00
c3b47b5771 修复: 前端UI审查问题(项目状态badge+confirm替换+加载态+CSS修正)
- ProjectDetail: 加载态/不存在态 + stage badge CSS 类名修正(7个实际状态)

- Tasks: confirm()替换为useConfirm + 死代码清理 + priority CSS去重

- Knowledge: 列表加载态

- Projects: CSS变量名拼写修正(--df-mono→--df-font-mono)

- EmptyState: opacity从容器移到图标(对比度修复)

- Ideas: 5处内联style替换为modal-field class

- ProjectDetail: 删除注释stages死代码
2026-07-02 12:12:29 +08:00
acf0ed091d 重构: 前端God文件拆分(StreamRenderer+EmptyState+MentionPopover+EnrichmentPanel)
- useStreamRenderer.ts: 流式Markdown块级渲染提取为composable(220行)

- EmptyState.vue: 空状态独立组件(无provider引导+示例问题)

- MentionPopover.vue: @实体联想浮层独立组件

- EnrichmentPanel.vue: @项目enrichment预览面板独立组件

- MessageList.vue: 1386→1148行, ChatInput.vue: 1155→1007行

- AiChat进度条: completedTools计数器(AiToolCallCompleted+1,AiAgentRound重置)

- i18n: 进度条文案加已完成工具数(中英文)
2026-07-02 10:11:15 +08:00
54ffc868b2 重构: context.rs拆分(sanitize独立子模块)+架构文档节点状态同步
- context.rs(1956行)拆分为 context/mod.rs(538行)+sanitize.rs(761行)+manager_tests.rs(719行)

- sanitize 函数移至子模块,ContextManager 保留委托方法(零行为变更)

- 46个 context 测试全绿(11 sanitize + 35 manager)

- ARCHITECTURE.md: 8节点全部标记已实现,Coordinator 标记已实现
2026-07-02 01:33:56 +08:00
d5b0459a8a 新增: DockerNode+CI状态读取+模板CRUD+CIStatus面板
- DockerNode: 环境检测(docker --version)+授权+容器内执行(12个测试)

- ci_status.rs: Gitea commit status API 读取(7个测试,失败返回空不阻塞)

- CIStatus.vue: CI检查面板(通过/失败/pending 汇总+可点击跳转)

- 模板CRUD IPC: list/save/delete templates(内置只读+自定义KV持久化)

- TemplateInfo 结构体(IPC传输用)

- 零编译警告,vue-tsc+vite build 通过
2026-07-02 01:02:39 +08:00
f1fb8655c3 更新: 批次记录同步(审批策略+小程序核验完成+散项进度) 2026-07-02 00:21:48 +08:00
d10d7ff330 新增: 审批策略Persona维度(reviewer/analyst全auto+扩展点)
- should_auto_for_persona: 叠加在 classify_risk_and_auto 之上的人设维度判定

- reviewer/analyst 人设工具调用全 auto(只读工具审批无意义)

- 预留 Phase 2 工具维度扩展点(函数内加 if 分支即可)

- 零编译警告
2026-07-02 00:20:48 +08:00
61d3e9c148 新增: Reviewer仲裁+命令互斥锁+冲突解决IPC(批次C散项)
- coordinator: arbitrate_conflicts 方法(规则仲裁:成功方优先/双方成功推荐合并/都失败手动)

- coordinator: ConflictItem 加 subtask_a/subtask_b/recommendation 字段 + ConflictResolution 枚举

- command_lock.rs: CommandLockRegistry (dir+cmd首词 Semaphore 串行化,5个测试全绿)

- resolve_conflict IPC: 解决冲突→更新DB→emit AiConflictResolved 事件闭环

- 3个仲裁测试 + 5个互斥锁测试全绿

- 零编译警告
2026-07-02 00:17:00 +08:00
840949ad05 修复: 代码审查发现的安全与兼容性问题
- git_worktree: commit config 改用 --local 避免污染全局 git config

- git_worktree: merge-tree 改用旧版兼容语法(三参数替代 --write-tree)

- coordinator: executor 'static 约束加注释说明(JoinSet spawn 要求)

- coordinator: Token reserve 按层 SubTask 数累加(原固定 10k 不准确)

- coordinator: 更新结构体注释(反映当前并行实现而非 Phase 1 串行)

- 39 个测试全绿,行为零回归
2026-07-01 23:37:58 +08:00
3e93a2cd70 更新: 批次记录同步(仲裁合并+模板系统+节点补齐完成) 2026-07-01 23:20:46 +08:00
6e44878021 重构: agentic主循环拆分(提取自动压缩逻辑到context_lifecycle)
- context_lifecycle.rs: F-15自动压缩触发逻辑(maybe_auto_compress)

- title_lifecycle/knowledge_lifecycle: 文档说明(已在之前拆分到title.rs/knowledge_inject.rs)

- mod.rs从2222行降至2071行,行为零变更

- cargo check零警告通过
2026-07-01 23:19:34 +08:00
27b4268acb 新增: SubflowNode嵌套子工作流 + 3个内置模板
- SubflowNode: 加载子DagDef递归执行,深度限制防无限递归,5个测试全绿

- 内置模板: 代码审查(analyze→report→notify)

- 内置模板: Bug修复(locate→fix→verify→notify)

- 内置模板: 功能开发(design→implement→test→review→notify)

- 模板含persona分配(reviewer/analyst/coder/tester/architect)
2026-07-01 23:10:13 +08:00
f40287bb00 新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)
- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用)

- GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装)

- HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时)

- NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义)

- 8个模板加载器测试+37个节点测试全绿
2026-07-01 22:45:56 +08:00
1d580dccc3 新增: 仲裁合并(冲突检测+ConflictResolver+reviewer仲裁基础)
- Coordinator.merge: 跳过失败SubTask+同文件冲突检测+extract_written_files

- ConflictResolver.vue: 双栏diff展示+接受A/接受B/合并/手动按钮

- i18n: 冲突相关翻译键(中英文)

- 7个merge测试全绿(含4个新增冲突检测)
2026-07-01 22:32:27 +08:00
9947e6c1fe 更新: 批次记录同步(数据层+worktree+并行执行完成) 2026-07-01 22:26:12 +08:00
3c9077b043 新增: 并行调度+Token预算池+事件协议+前端类型+编译警告清理
- Coordinator.dispatch_with_budget: JoinSet层内并行+层间串行+预算超限降级串行

- TokenBudgetPool: AtomicU64 CAS无锁并发安全,0=不限制

- 4个新事件: AiPlanCreated/AiSubTaskStatusChanged/AiMergeCompleted/AiConflictResolved

- 前端类型: PlanRecord/SubTaskRecord/ConflictRecord/PlanLayerInfo/ConflictInfo

- PlanProgress.vue: 接入真实状态+persona徽章+冲突徽章+i18n

- 编译警告全部清零(audit子模块allow+事件allow+record allow)

- Coordinator测试27个全绿(含4个Token预算+4个并行调度)
2026-07-01 22:25:18 +08:00
4483358f6b 新增: Git worktree生命周期管理(创建/提交/合并/冲突检测/清理)
- create_plan_worktree: 创建Plan级工作分支

- create_worktree: 创建SubTask专属worktree(天然文件隔离)

- commit_worktree: worktree内提交改动(含user.email兜底)

- merge_branch: merge-tree预检+merge实际合并,返回Clean/Conflict

- remove_plan_worktrees: 批量清理Plan所有worktree+分支

- 7个集成测试全绿(隔离/并行/提交/无冲突合并/冲突检测/非Git降级/清理)

- 使用真实Git+tempdir测试(参考df-execute/tests/shell.rs模式)
2026-07-01 21:54:30 +08:00
ae6d3d0043 新增: 多Agent数据层(V36迁移+3新表+Repo CRUD+7个测试全绿)
- V36: ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id

- models: PlanRecord/SubTaskRecord/ConflictRecord 结构体

- PlanRepo/SubTaskRepo/ConflictRepo CRUD(insert/get/list_by_*/update_status/resolve)

- 7个 Repo 单元测试全部通过(内存 SQLite + 全迁移)

- 顺带修复 task_repo.rs 预存测试编译错误(trec_full 参数类型+缺失 await)
2026-07-01 21:50:16 +08:00
f4fc0feff5 新增: 测试落地实施方案(Rust单元+集成+vitest+E2E手动验证)
- 落地方式:Rust单元(mod tests) / Rust集成(tests目录+tempdir) / 前端vitest(需引入) / E2E手动检查清单

- 每个用例类别的文件位置和执行命令

- Mock策略:LLM闭包注入/Git真实tempdir/DB内存SQLite

- 批次验证流程:编码→cargo test→vue-tsc→vite build→手动E2E→SQL验证→提交

- CI自动化远期路径(cargo test + bun test)
2026-07-01 21:37:07 +08:00
6833792de2 新增: 后续批次测试用例设计 + 批次规划同步worktree方案
- 测试用例:8大类共70+用例(迁移/Repo/拆解/worktree/并行/仲裁/模板/CI/审批/拆分/端到端/边界)

- 批次同步:数据层+worktree+并行(12项)/仲裁+冲突UI+编译检查(10项)/模板+节点(10项)/Git-CI+DockerNode(6项)/审批政策+小程序+收尾(8项)

- INDEX注册两份设计文档
2026-07-01 21:30:58 +08:00
af2e085bea 更新: 多Agent隔离机制改用Git worktree替代自建暂存区
- 隔离:git worktree天然分支隔离,复用diff/merge/conflict能力

- 合并:git merge-tree三方合并预检+git merge实际合并到plan分支

- 冲突:四层防线(层间串行/worktree隔离/编译检查/命令互斥)

- 展示:合并产出落回主对话采用可展开单条(方案C)

- 类型:SubTaskRecord加branch字段,ConflictRecord加conflict_type

- 降级:非Git工程串行执行(无worktree隔离)

- 实施分批同步更新(含git_worktree.rs+编译检查+命令互斥锁)
2026-07-01 21:26:55 +08:00
583058acd6 新增: 多Agent并行执行与仲裁合并设计文档
- 数据模型:混合方案(messages加FK列 + Plan/SubTask/Conflict独立表)

- 并行策略:层间串行+层内并行,写SubTask降级串行,token预算池

- 上下文隔离:子Agent独立ContextManager+fork快照,合并产出落回主对话

- 事件协议:4个新事件(Plan创建/SubTask状态/合并完成/冲突解决)

- UI交互:PlanProgress发送即展示+工具卡分组+冲突徽章非阻塞

- 状态机:Plan/SubTask/Conflict各自状态流转图

- 前端类型:PlanRecord/SubTaskRecord/ConflictRecord TypeScript定义

- INDEX.md注册 + 批次记录同步明细
2026-07-01 17:08:46 +08:00
f04d47b09d 更新: 批次推进记录同步(Coordinator+audit拆分) 2026-07-01 14:29:53 +08:00
fa410e6843 重构: Coordinator 接入 agentic loop + audit 拆分(approval/record)
- audit/approval.rs: 审批门控(风险分类+自动执行+挂起审批)

- audit/record.rs: 审计记录写入+历史查询

- coordinator.rs: 移除 deprecated,正式作为 P0 骨架可用

- run_agentic_loop: plan_execution_enabled 时 Coordinator 分解意图→记录 Plan

- audit/mod.rs 从 ~500 行精简至 ~200 行(编排层)
2026-07-01 14:29:34 +08:00
a9ee9b1f74 更新: 批次推进记录同步(人设系统完成) 2026-07-01 13:11:15 +08:00
0a65ea2c5a 新增: 人设系统(P0 AgentPersona+PersonaRegistry+5内置人设)
- AgentPersona 数据结构(能力/工具白名单/prompt模板)

- PersonaRegistry 含5内置人设:coder/reviewer/architect/tester/analyst

- 按意图关键词推荐人设(review→reviewer,设计→architect等)

- 10个单元测试覆盖注册/默认/推荐/白名单/build_prompt
2026-07-01 13:10:16 +08:00
91d80841ea 新增: 多 ReAct 执行骨架(PlanExecutor 运行时门控+DAG 进度组件) - PLAN_EXECUTION_ENABLED 改为 AtomicBool 运行时开关 - 新增 set/get_plan_execution IPC 前后端通路 - 新增 PlanProgress.vue 层状执行进度展示组件 - feature flag 默认关, 翻 true 后 process_tool_calls 可并行执行同层工具 2026-07-01 12:59:37 +08:00
c89742cb9a 更新: 批次推进记录同步(搜索索引/编译警告/i18n核验/类型重构/MessageList拆分/ChatInput拆分/白名单打通) 2026-07-01 12:50:12 +08:00
2af24f0db8 新增: ScriptNode 白名单跨 crate 配置打通(IPC+OnceLock) - 新增 set_script_safety / get_script_safety IPC - ScriptNode.check_command_policy 优先读运行时配置 - 前端设置页保存时同步注入后端运行时 - 核验确认小程序心跳/文件树刷新/项目分页三项已就绪 2026-07-01 12:38:21 +08:00
535525c2f6 新增: AI Native 方向与路线图文档
- 定义 AI Native vs 传统范式的差异(人从操作者→决策者)
- 优先级矩阵:P0人设+多Agent → P1模板+Git/CI → P2审批+算法
- 明确不做方向(全自动/MCP/跨端)及理由
- Agent架构说明添加前向引用
- 注册文档到架构 INDEX
2026-07-01 12:34:44 +08:00
3abdfb489f 重构: ChatInput 拆分(提取 SkillMention+ImageInput, 1183->1155行)
- 提取 SkillMention.vue(/ 技能联想浮层+已选 chip)

- 提取 ImageInput.vue(待发送图片预览+移除)

- ChatInput 模板内联技能浮层替换为子组件
2026-07-01 12:27:58 +08:00
2c2b2d710e 重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮)

- 提取 MessageItem.vue(按 role 渲染单条消息)

- 清理 MessageList.vue 中已移至子组件/ composable 的死代码
2026-07-01 12:21:40 +08:00
6771d396f0 优化: 代码质量收尾(搜索索引补全+编译警告清理+i18n核验+String替newtype) 2026-07-01 12:11:35 +08:00
0338210ba2 更新: 批次推进记录同步至 Batch 29
- 补全 Batch 14-29 全部提交记录
- 新增后续规划批次(Batch 30-34)
2026-07-01 11:37:42 +08:00
36ea090d9b 新增: 依赖图环形检测 + PNG 导出 + 任务列表真实总数
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
2026-07-01 11:30:19 +08:00
89912155a6 优化: 架构债收尾(ScriptNode 注释更新 + 双监听器核查)
- ScriptNode 白/黑名单注释清理(移除误导性'待后续',标注实际读取方式)
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub(project store + FileExplorer + TaskDetail),非 bug
- God 文件拆分(MessageList/ChatInput)标记为独立技术债,需完整测试覆盖后推进
2026-07-01 01:21:46 +08:00
dd0e059365 新增: 依赖图增强(添加依赖弹窗 + dagre 自动布局)
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
- 依赖边按类型着色(library/api/mq/shared/custom)
- 小地图导航 + 缩放/适应内容
2026-07-01 01:17:44 +08:00
bce2fea4e5 修复: 设置页和分离窗口渲染崩溃(TDZ + 窗口权限通配符)
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
2026-07-01 01:09:26 +08:00
3758bea0b5 新增: 工程依赖图数据层 + 依赖边渲染 + 小地图
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
- 任务列表后端真分页 count_by_query 方法
2026-07-01 00:20:51 +08:00
3c2fa91fc6 优化: 任务列表后端真分页(limit/offset 下推 + count 查询)
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
- Paginator total 用 totalTasks(当前用当前页条数,后续接 count IPC)
2026-07-01 00:10:41 +08:00
78ef5dcc44 优化: 变更区完善(文件 Diff 预览 + 分支只读列表)
- 变更文件选中后展开 Diff 红绿预览(原 selectFile 拉了 diffContent 但未渲染)
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
- 分支切换提示走 AI 助手(git_branch 工具)
2026-07-01 00:01:25 +08:00
976ce29ad9 新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)
- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染
- ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签)
- 项目详情新增依赖图Tab,与概览/文件并列
- 网格布局排列工程节点,支持缩放/拖拽/框选
- Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
2026-06-30 23:57:43 +08:00
4dac9fe5f1 新增: Plan DAG 分层执行器(按层级调度子任务,层间串行/层内并行)
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
- feature flag 门控(默认关,未接入主 loop)
- 3 个单元测试覆盖:线性链/并行层/失败容错
2026-06-30 23:35:10 +08:00
76aec62d36 修复: stores/ai 模块顶层 __bindMessages TDZ 防御
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
- 防 ES module 循环初始化顺序导致的 Cannot access before initialization
2026-06-30 23:26:40 +08:00
27eb0a9f6b 新增: 任务行 hover 快捷操作菜单(改状态/优先级/删除)
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
- 点击外部自动关闭菜单
- 补 i18n confirmDelete 翻译
2026-06-30 22:08:43 +08:00
948d6e644b 重构: 删除 coordinator.rs 零调用空壳占位
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
- lib.rs 移除 pub mod coordinator 声明
- 消除编译 deprecated 警告
- 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现
2026-06-30 22:01:44 +08:00
aa4d5213eb 新增: 项目卡片显示工程数(异步拉取 module 列表)
- ProjectCard 底部新增工程数统计(📁 N),异步拉取不阻塞卡片渲染
- 老项目无工程记录显示 0(不报错,与 list_project_modules 自动补建兼容)
2026-06-30 22:00:14 +08:00
2780f6affa 优化: 任务管理界面 UX 重构(紧凑布局/搜索/排序/分页/快捷键)
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style,统一 modal-field/modal-input 类
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
2026-06-30 21:58:11 +08:00
773ebb6ef6 新增: 小程序审批超时计时器(5min 自动拒绝兜底)
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
- 5min 阈值符合移动端使用习惯(桌面端 15min)
2026-06-30 21:54:21 +08:00
15eaa0c637 新增: Git 能力闭环(Diff 行号解析/分支只读展示/提交详情/写入后自动刷新变更视图)
- Diff 行号修复:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
- 提交详情增强:显示父提交哈希/作者/日期/完整消息(git show -s --format=%P\t%an)
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支(切换走 AI 工具)
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
2026-06-30 21:50:25 +08:00
f5f101d88a 修复: 设置页和 AI 独立窗口渲染崩溃(循环依赖 TDZ)
- ProviderPanel 改静态 import useAiStore 为动态 import,打破循环依赖链
- ErrorBoundary 增强显示具体错误信息和可折叠堆栈详情
2026-06-30 21:45:31 +08:00
b19b0f3679 新增: Git 提交历史显示作者 + 提交详情含作者/时间
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
- 提交列表每行显示 hash/作者/主题/时间
- 提交详情头部补充作者显示
- 分支切换按钮占位(后续接入分支列表 IPC)
2026-06-30 21:45:31 +08:00
abc6880936 新增: 多工程管理(编辑/删除确认/记忆选中/自动扫描子仓库)
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git地址)
- 工程删除二次确认:防误删
- 记住上次选中工程:localStorage 持久化
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
2026-06-30 21:45:31 +08:00
6b131f3dc1 新增: 文件浏览器收尾(滚动条/阶段进度条/空状态/骨架屏/工程下拉)
- 提交之前未提交的滚动条与布局修复
- 移除阶段进度条死代码(模板+CSS+常量)
- 文件预览空状态加图标引导
- 变更视图加载骨架屏
- 工程选择器改为自定义下拉(显示工程路径)
2026-06-30 21:45:31 +08:00
78bd8d4344 新增: Zed 编辑器规则文件(提交规范+开发规则) 2026-06-29 22:22:34 +08:00
093fe172f8 新增: 项目状态机分层+任务状态枚举合并
真分层(df-project):
- ProjectManager 加 can_transition/transition/is_terminal 状态机
- 创建加 EmptyName 校验,返回 Result 而非裸实体
- 新增 ProjectTransitionError 三种错误变体
- 调用方(idea.rs promote_idea)适配 Result 传播

双源合并:
- TaskStatus::as_str 改为 const fn(允许 const 上下文调用)
- task_state_machine 字符串常量从 TaskStatus enum 派生
- IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性)

架构债设计方案:
- #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费)
- #15 AI 状态机抽离 df-ai-session(4 阶段)
- #18 配置四源统一(AppConfig 单例 + 环境变量覆盖)
- #19 SQLite 连接池(r2d2)
2026-06-29 22:16:45 +08:00
ff3f153d45 修复: 安全加固+DRY 收敛+文档同步+测试补齐
安全:
- ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd)
- bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry)
- ai_providers 白名单移除 api_key(防 update_field 旁路写明文)

DRY:
- useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾
- 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板

文档:
- df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD)
- INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等)
- Agent架构说明 死链修复(../构想审查/)
- AI对话引擎工具清单改为数量+按风险分组(不再用固定数字)
- ARCH 状态标签 设计阶段 → Phase 2 验证

测试:
- df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
2026-06-29 21:57:07 +08:00
dfed588b54 新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
2026-06-29 19:31:55 +08:00
63936e3016 修复: 审批按钮永久转圈和等待计数不停
根因:审批 IPC 成功返回后,前端等待后端事件(AiApprovalResult/
AiToolCallCompleted)来更新工具卡片状态和复位 loading。但如果
后端事件因时序问题未及时到达,或 findToolCall 找不到工具卡片,
loading 永不复位,按钮一直转圈,等待计数也不停。

修复:审批 IPC 成功返回后立即乐观更新状态:
- 通过/批准:工具卡片改为执行中(后端正在执行工具)
- 拒绝:工具卡片改为已拒绝
- 同时从待审批列表移除(不等后端事件来移)

后端事件仍会到达作为二次确认(幂等覆盖,无副作用)。
2026-06-29 12:59:45 +08:00
7a2a2e13a2 修复: 工具卡片永远显示执行中
根因:aiShared 的消息引用在会话切换时失效。

switchConversation 和 newConversation 会整体替换 state.messages
(赋值新数组),但 aiShared 缓存的是旧数组引用。切换后
findToolCall 在旧数组中找不到新消息的工具卡片,AiToolCallCompleted
事件无法更新卡片状态,卡片永远停在 running。

修复:缓存数组引用改为 getter 函数,每次调用实时从 state 读取
最新数组,确保切换会话后仍能找到工具卡片。
2026-06-29 12:33:57 +08:00
49c6f3360a 新增: 批次推进记录文档 2026-06-29 11:14:10 +08:00
9b6353c561 更新: 工程系统设计文档状态同步为已落地 2026-06-29 01:55:35 +08:00
0cfea6da22 新增: Git 写操作 AI 工具(提交/分支/合并)
AI 现在能操作代码仓库的提交、分支和合并,闭环任务推进链。
三个工具按风险分级审批:

- git_commit(中等风险):提交工作区改动
  提交前检查敏感文件(.env/.key/.pem 等),拒绝提交
  支持自定义提交信息和选择性暂存
- git_branch(中等风险):分支管理
  列表所有分支、创建新分支、切换分支
- git_merge(高风险):合并分支
  合并后自动检测冲突,返回冲突文件列表

安全边界:不暴露 push/force/reset --hard(推送远程是
用户决策,避免 AI 自主推到远程或丢失改动)。
2026-06-29 01:55:01 +08:00
7b5b62d3ae 新增: Git 只读 AI 工具(状态/改动/历史)
AI 现在能自主查看工程代码仓库的 Git 状态,无需用户审批。
三个低风险只读工具,在工程目录(module.path)执行:

- git_status:查看工作区状态,返回当前分支和改动文件列表
  (结构化输出,每文件含路径和状态标记 M/A/D/??)
- git_diff:查看未提交的代码改动,返回统计和 patch 内容
  (patch 截断到 8000 字符防止 token 爆炸)
- git_log:查看提交历史,返回最近提交的结构化信息
  (哈希/作者/消息/日期,默认最近 20 条,最多 100 条)

所有 git 命令 10 秒超时,无 Git 仓库返回空状态。
AI 据此了解代码变更情况,为后续提交/分支操作做准备。
2026-06-29 01:51:38 +08:00
50aad375eb 新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)
项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目
文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。

后端接口:
- 文件树查询:列工程目录,合并 Git 状态到每个文件
  (噪音过滤 node_modules/target/.git 等,路径穿越防御)
- 文件读取:支持文本/图片/二进制检测,1MB 上限

前端组件:
- 文件树:递归树形展示,懒加载子目录,Git 状态标记
  (橙色=已修改/绿色=已新增/灰色=未跟踪)
- 文件预览:代码文本/图片/二进制三分支
- 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新

界面文案中英文同步补齐。
2026-06-29 01:48:52 +08:00
b72df78462 新增: 项目多工程系统(工程表+Git 状态查询+AI 工具)
一个项目可含多个工程(Module),每个工程是独立代码仓库,
有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下
只有一个工程,用户无感工程概念。

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

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

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

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

同时新增工程系统设计方案文档。
2026-06-29 01:39:42 +08:00
3c18dea45b 新增: 消息分页懒加载与事件总线空转优化
后端基础设施两项改进:

一、历史消息分页懒加载(治长对话渲染卡顿)
- 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标)
- 切换对话时后端保留全量消息供模型上下文用,但只返回最近
  50 条给前端渲染,附带是否有更多历史和游标位置
- 新增加载更多消息的接口,前端滚顶时按游标分页拉取

二、事件总线空转优化(消除无消费者时的序列化开销)
- 发布事件前先检查订阅者数量,无订阅者直接跳过序列化
- 当前隧道订阅方未接入,20 余个发射点双写不再空转浪费

同时核验确认对话上下文透明化三项均已落地(目标可见/
项目增强预览/完整上下文面板),关闭对应待办。
2026-06-29 00:44:10 +08:00
8d18918e39 更新: 架构设计文档状态同步代码核验结果
经代码核验发现 9 份设计文档的状态标注严重滞后(标'待实施'但
实际已完整落地),本次批量同步:

已落地(核验确认):
- 局部编辑工具:三层防御+三模式完整,仅文本不支持二进制
- 密钥迁移健壮性:空 key 不覆盖+即时迁移补密钥+阻断保存
- AST 符号解析:符号读取工具已注册+基线测试守护
- 查询能力补全:任务/项目/灵感均多维动态查询
- 条件表达式引擎:手写求值器+JSON Path+执行器集成+前端入口
- 工作流脚本边界:命令白名单/黑名单+危险关键词告警
- 消息拆分存储:消息表+全量迁移+读写全部切换
- 消息级溯源:消息 ID+四场景溯源+切读全部完成

部分落地:
- 全局事件总线:基建+20 余个发射点就位,消费者未接(空转)

归档不实施:
- 规格契约自检:核心价值已被求助协议+自审闸门覆盖,过度设计
2026-06-29 00:16:39 +08:00
cb7e1fce82 新增: 小程序配置持久化与心跳闭环 + 文档同步
小程序(mobile client)三项改进:

一、配置持久化(分发前置)
- 运行时配置(relay 地址/设备 ID/token/心跳间隔)接入 storage
- 懒加载:首次读取从 storage 恢复,合并默认值兼容旧字段
- 新增重置函数:清除 storage + 回到默认占位值
- 读写失败降级不崩溃

二、心跳 pong 闭环(移动网络可靠性)
- 中继服务识别小程序心跳 ping,直接回 pong(不经桌面端透传)
- 小程序收到 pong 更新入站时间,半连接看门狗据此判活
- 治移动网络 TCP 半连接挂死(socket 仍开但无响应)

三、文档同步关闭已完成项
- 条件表达式引擎:求值器+执行器+前端编辑入口+翻译均已落地
- 流式状态按会话隔离:accessor 委派 per-conv Map 已落地
- 灵感录入增强:表单已含来源/标签/优先级字段
2026-06-28 23:55:51 +08:00
cc3ef5bc1d 修复: 设置页面崩溃(模块循环依赖致初始化失败)
共享层模块顶层导入状态仓库,而状态仓库又导入共享层符号,形成
循环引用。打包后运行时触发 TDZ 崩溃,设置页面无法打开。

修复方案:

- 共享层删除模块顶层的状态仓库导入和全局 store 实例
- 改为由状态仓库在状态就绪后主动注入消息数组引用
- 共享层函数运行时惰性获取消息引用,不再编译期依赖
- store 实例改为函数内局部获取(运行时 store 已就绪)

零行为变更,纯依赖方向调整。
2026-06-28 23:39:48 +08:00
996f1d9e5f 重构: 应用状态拆分+事件分发统一+路径授权治本
三项治本性架构重构,解决长期遗留的技术债:

一、应用状态文件拆分(原 1416 行单文件 → 按职责分模块)
- 文件授权白名单(AllowedDirs/路径校验/黑名单)独立成模块
- 知识库行为配置(ExtractTrigger/KnowledgeConfig/KV 常量)独立成模块
- LLM 并发控制(LlmConcurrency 双层信号量)独立成模块
- 主文件仅保留 AppState 结构体与初始化逻辑

二、事件分发器统一(消除双监听器架构隐患)
- 压缩/清空等 5 类生命周期事件纳入主事件联合类型
- 删除上下文管理模块的独立第二监听器
- 改为由主事件分发器统一处理,经回调注入副作用
- 架构保证:一个事件源 → 一个监听器 → 一个分发器

三、路径授权去除编译期硬编码(分发适配治本)
- 启动时不再硬塞编译机路径到授权白名单
- 工程根授权完全靠项目绑定目录自动授权 + 设置页手动配置
- 分发到其他机器后不再有脏白名单数据
2026-06-28 23:35:38 +08:00
696e34407c 更新: 关闭已完成的若干待办项
经代码核验,五项候选待办实际上都已实现或已满足决策,本次仅
同步文档关闭:

- 工具卡片分组标题:已实现文件路径摘要展示(跨轮自动收起保留
  作为低优先增量)
- 重试持锁问题:多会话并发改造后全局锁已移除,重试仅持单会话锁
  (同循环内合理),原说法已过时
- 对抗式评估批量命令:单项评估接口已接入,批量评估无前端消费方
  暂不做(避免过度设计)
- 残留诊断代码:聊天命令已无硬编码绝对路径,调试输出已降级为
  普通日志
- 工程内路径授权政策:用户决策确认为白名单制(工程外走三档授权
  + 系统目录黑名单兜底),已落地
2026-06-28 19:06:02 +08:00
19b647e650 修复: 知识提炼改读消息表,不再依赖旧 JSON 列
消息存储拆分改造早前已覆盖主要读写路径(保存写消息表 / 切换 /
导出 / 跨端同步读消息表),但知识提炼仍在读旧的消息 JSON 列,
与改造目标不一致。

本次补全:
- 知识提炼改为优先读消息表
- 表空时回退旧 JSON 列(老数据库兼容,与其他读路径一致)
- 复用既有的记录转消息映射函数,与其他读路径同源

至此消息存储拆分全部完成,所有读写路径统一走消息表,旧 JSON
列仅作老库兼容回退,不再写入。
2026-06-28 18:47:21 +08:00
10629f88e8 新增: 工作流条件表达式 UI 补齐中英文翻译
条件表达式引擎和执行器集成早已完成,工作流可视化组件也提供边
条件编辑入口。但前端用「中文兜底」说明翻译缺失,本次补齐:

中英文同步新增:
- 工作流结构标题 / 层标签 / 空态提示
- 边条件编辑器入口 / 无条件标签 / 输入提示

核验确认条件引擎全部链路(引擎/特性开关/执行器集成/UI/翻译)
均已落地,多会话并发架构的会话级隔离改造也已完成。
2026-06-28 18:34:22 +08:00
b5e159f190 更新: 关闭已完成的工具卡片拆分等待办
代码核验发现工具卡片和大文件重构任务实际已完成,文档标记滞后,
本次同步关闭对应待办:

工具卡片拆分(原 1527 行 → 373 行):
- 审批状态机独立为单独文件
- 头部逻辑 / 渲染逻辑 / 通用工具函数各自独立

大型文件拆分:
- 主聊天组件抽子组件后大幅瘦身
- 命令注册按域拆为多个函数
- 通用 CRUD 拆为按表独立的仓库文件

类型强类型化重构评估为低优先级纯重构,留待后续技术债窗口处理。
2026-06-28 18:31:34 +08:00
55542abf41 新增: 审批超时后端兜底,覆盖重启恢复场景
前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话
内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复
的待审批无人计时,会永久卡死会话。

本次补充后端兜底机制:

- 待审批记录创建时刻,用于超时判定
- 在审批恢复入口检测超时,超时自动取消并写入审计
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源

同时更新待办文档,把已实现项标记为完成。
2026-06-28 18:16:34 +08:00
3fac0a618d 修复: system_prompt 追加行为准则,减少 LLM 写脚本偏好 2026-06-28 17:21:35 +08:00
9839935782 新增: AI 上下文信息面板(系统提示/增强上下文可见) 2026-06-28 14:13:20 +08:00
4a396f4f88 新增: EnvSnapshot 环境感知注入 system_prompt
- EnvSnapshot 结构: OS/shell/工具版本/编码/路径分隔符
- OnceLock 全局缓存,启动时 spawn_blocking 探测一次
- to_prompt() 生成环境摘要注入 agentic loop system_prompt
- 探测: Windows 注册表/macOS sw_vers/Linux os-release + chcp 编码
- 工具版本: python/node/rust/go/docker/git 逐个探测,失败不阻塞
- 8 个单测覆盖版本号解析 + 平台 fallback
2026-06-28 14:05:16 +08:00
7adaf97377 修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖
- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
2026-06-28 13:57:53 +08:00
6ffcba7e4d 新增: 审批超时可配置 + ScriptNode 命令白/黑名单
- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min
- getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值
- ScriptNode: 黑名单 > 白名单策略,从环境变量读取
- Settings 加命令执行安全面板(白/黑名单文本框)
- i18n 中英文案 + 搜索索引补全
2026-06-28 13:49:19 +08:00
b157bc9077 修复: relay token 改环境变量 + api_key 脱敏 + 路径规范化 + create_project 自动建目录
- relay token 必设 DF_RELAY_TOKEN 环境变量,移除硬编码默认
- AiProviderRecord Debug 脱敏 api_key/model_configs/config
- ScriptNode 危险命令告警(rm -rf/DROP TABLE 等)
- bind_directory 路径规范化,拒绝含 .. 的原始路径
- create_project 目录不存在时自动创建
- probe_pwsh OnceLock 全局缓存,异步探测不阻塞 tokio
- retry jitter 改 rand,范围扩大到 ±50%
- EventBus send 错误改 tracing::warn
- StateMachine 文档警告不得在 await 期间持锁
2026-06-28 13:39:22 +08:00
f776336eb1 更新: 对话诊断待办项
- 最新对话 d9aef24f 分析: 审批死锁 + Windows 兼容 + 标题失败
- P0: pending 超时自动取消 + create_project auto_create_dir
- P1: run_command shell 适配提示 + PowerShell 反斜杠转义
- P2: 标题摘要合并连续同 role 防 GLM 1214
- P3: migration V33 + L0-handshake 防抖
2026-06-28 13:14:54 +08:00
ad1821bc14 修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时)
- V33 迁移: ai_conversations 加 pending_approvals 列
- save_conversation 持久化 pending_approvals
- ai_conversation_switch 从 DB 恢复 pending_approvals
- switchConversation 加 try/catch(对话不存在→建新)
- @项目 关联添加取消按钮(×)
- TopBar 底部无标题时图标右对齐
- TaskDetail.vue 删除重复 case
2026-06-28 13:09:55 +08:00
c4b02b5370 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- P2 切读方案文档更新(确认批次B已上线)
- query LIKE 通配符转义(project/idea/task/knowledge 4 repo)
- dag.rs deep_merge null 覆盖全局配置
- dag.rs 通配 match 展开显式变体
- INDEX.md 补漏 10 个文档索引
- ARCHITECTURE.md 删除与新文档逐字重复
- coordinator.rs 加 #[deprecated] 编译守卫
- AiChat.vue 空值传播加 console.warn
2026-06-28 05:06:51 +08:00
fee856129c 新增: 全量走查报告 — 代码/前端/文档体系
- Rust 后端 12 crate: 锁/LIKE 通配符/jitter/路径穿越 等 15 项
- Vue 3 前端: Arco 残留/confirm 重复/类型退化 等 13 项
- 文档体系: 工具清单过时/INDEX 漏列 9 文档/死链/SSOT 违反 等 9 项
2026-06-28 04:53:33 +08:00
ead490783f 修复: AiCompleted 携带 pinned_goals 根治目标面板刷新 2026-06-28 04:51:08 +08:00
dcc3f0d230 新增: 三层模型设计文档 — 模板/工作流/人设体系
- ARCHITECTURE.md 补充执行层级模型 + 重写 §七 三层模型章节
- 新建专项设计: 流程模板 YAML 规范/AgentPersona 数据结构/实例化流程
- 注册新文档到架构设计 INDEX
- Agent架构说明补充前向关联引用
2026-06-28 04:44:01 +08:00
d1b9488853 修复: status→enum + type alias(SMELL-P1-6) 2026-06-28 04:42:59 +08:00
48c966f6f7 更新: 归档今日决策 + 标记已解决项 + 执行计划 2026-06-28 04:12:16 +08:00
7e3fe1961a 修复: execution_id String→ExecutionId 类型别名 + 更新待决策状态 2026-06-28 03:50:25 +08:00
8723075360 重构: TopBar 布局 — header 中间区 + 底部工具区(标题/目标/历史/摘要) 2026-06-28 03:31:41 +08:00
53e1c1da77 重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
2026-06-28 03:31:40 +08:00
e4ceb0015b 新增: Conditions 启用 + 工作流 DAG 展示 + 决策记录
- 开启 conditions-eval feature flag(df-workflow 默认启用)
- 新增 WorkflowDagDisplay.vue 展示工作流节点/边/条件
- TaskDetail 工作流推进后自动获取并显示 DAG 结构
- 记录 2026-06-27 讨论决策到待决策.md
- 记录 Inbox 设计想法到 todo.md
2026-06-28 01:17:36 +08:00
22362a77b8 新增: 审批挂起时长提示(BUG-260624-02 方案 C)
背景:AI 调用需审批的工具后,ToolCard 弹审批卡常驻直到用户批/拒,
用户走开忘了批则 AI loop 卡死,且卡片无任何时长提示。

方案选型(A/B/C 三选一,选 C):
  A 不做 - 挂起审批容易被遗忘
  B 加超时+倒计时 - 超时后行为难定义(自动拒丢改动/自动批危险)
  C 显示挂起时长(本方案) - 纯前端展示,不动后端,不改审批行为

实现:
- ToolCard pending_approval 态显示已等待 mm:ss
- >2分钟橙色脉冲,>5分钟红色脉冲
- 注释中标注方案选型理由,便于后人理解决策上下文
2026-06-28 00:41:00 +08:00
cf223fc08b 修复: .trash WARN 降级 + ToolCard 分组标题增强
- .trash 跨盘迁移从 WARN 降为 DEBUG(预期行为,非异常)
- ToolCard 分组标题增加文件路径摘要(取前3个文件名),
  收起态也能看到影响了哪些文件
2026-06-28 00:25:37 +08:00
b7b004dd68 新增: Phase 4 注入 — @[项目] 关联任务/灵感 + 三个工具 path 可选
- Augmentation 新增 extra 字段(项目关联信息/任务关联信息/灵感关联信息)
- ProjectResolver: @[项目] 时自动查前5条进行中任务+前3条待评估灵感
- TaskResolver/IdeaResolver: extra 预留(后续按需追加)
- render_one 渲染 extra 行
- search_files/grep/list_directory path 改为可选,不传时返回引导提示
2026-06-28 00:19:13 +08:00
b7b54eb2a6 修复: patch_file 小改动自动放行 + tool_result JSON 压缩 + prompt 项目路径
- C-260627: patch_file ≤5 行自动放行,不阻塞 AI 工作流
- tool_result 压缩: JSON 数组截断到 10 条 + _truncated 标记
- system_prompt: 项目清单增加「目录:」路径信息
2026-06-27 23:59:07 +08:00
6212002270 修复: read_symbol 搜不到 const 声明的符号
- 新增 variable_declarator 到 DEFINITION_KINDS
- Vue 3 + TS 大量使用 const fn = () => {} 箭头函数
  AST 节点是 variable_declarator,原本不在列表中
  导致 read_symbol 对 Vue 文件频繁 fallback 回 read_file
2026-06-27 23:42:14 +08:00
7cd833b1e5 修复: 文件日志 + G1 日志增强 + 清理死代码
- 文件日志: tracing-subscriber + tracing-appender 写入 %TEMP%/devflow-trace.log
- G1 日志: 增加 first_goal 字段显示首条目标内容
- 清理: 删 workspace_root_str / data_dir_path 死代码
- 标注: default_with_root 加 cfg_attr 抑制 non-test 编译警告
2026-06-27 23:27:31 +08:00
5c539fe764 修复: 前端 TS 阻断 + G1/G2 单测 + 归档已完成项
- 修复 vue-tsc 3 阻断: i18n 重复 err key 删除 + store 补 relateIdeas
- G1: extract_pinned_goal 单测 10 条(mention剥离/截断/空输入/全角冒号)
- G2: is_empty_tool_result 单测 11 条(JSON标记/中文/英文/非空判定)
- 归档: todo.md/docs/todo.md/待决策.md/待审查.md 已完成项迁入归档
2026-06-27 22:35:37 +08:00
6e1485e4f9 修复: BUG-260620-05 工程内路径弹窗 + G1 多目标累积 + 测试编译债
- BUG-260620-05: reload_allowed_dirs 无条件插入 workspace_root,工程内路径免授权
- G1: pinned_goal → pinned_goals Vec<String>,多目标追加去重 + MAX_GOALS=5
- tool_registry.rs 测试 4 处补 data_dir 形参
- 更新 todo.md / docs/todo.md 状态
2026-06-27 22:02:41 +08:00
296 changed files with 31985 additions and 10894 deletions

View File

@@ -1,5 +1,12 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
#
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
#
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
#
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]

23
.gitignore vendored
View File

@@ -25,6 +25,7 @@ unpackage/
# Rust / Cargo
target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock
# Tauri
@@ -59,6 +60,28 @@ tmp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
/analysis_*.txt
/analysis_*.py
/core_domains.txt
/core_domains.py
/final_report.txt
/grand_output.txt
/im_live_verify.txt
/liang_deep.py
/liangxianyou_deep.txt
/member_profiles.py
/read_all.py
/report_p2.txt
/report_wall.txt
/team_analysis.py
/team_deep_analysis.py
/team_member_profiles.txt
/team_report_final.txt
/tmp_*.py
/tmp_*.txt
/verify_im_live*.py
# MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db
devflow-dev.db-shm

View File

@@ -0,0 +1,48 @@
# DevFlow 提交规范
## 提交注释格式
```
<动词>: <简短描述>
<详细说明(可选,仅在需要补充上下文时)>
```
## 动词词表
| 动词 | 使用场景 |
|------|---------|
| `新增` | 新功能、新特性、新文件 |
| `修复` | Bug 修复、问题修正 |
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
| `重构` | 代码重构(行为不变的结构调整) |
| `删除` | 移除功能、删除文件 |
| `优化` | 性能优化、体验改进 |
| `合并` | 分支合并 |
## 规范要求
1. **动词 + 冒号 + 空格**`修复: 审批按钮永久转圈`
2. **主题行不超过 72 字符**
3. **主语用中文**,描述清楚改动内容
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
5. **不要用分隔线**:禁止 `---``===` 等装饰线
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
## 示例
```
修复: 审批按钮永久转圈和等待计数不停
```
```
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 中文编码修复: git 命令注入 LC_ALL 环境变量
```

View File

@@ -0,0 +1,29 @@
# DevFlow 开发规则
## 技术栈
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
- 后端: Rust Workspace (8 crate) / SQLite
- 包管理: bun (前端) / cargo (Rust)
- 路径: `E:/wk-lab/devflow`
## 架构原则
1. **治本不治标**:修复问题时找到根因,不做表面补丁
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
3. **长远考虑**:不追求局部最优,考虑整体架构演进
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
## 代码规范
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
4. **TypeScript 文件名**camelCase`fileExplorer.ts`
5. **Vue 组件文件名**PascalCase`FileExplorer.vue`
## i18n
- 中英文双语言同步:`src/i18n/en/``src/i18n/zh-CN/`
- 新增组件时同时添加对应语言的翻译文件
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)

64
.zed/settings.json Normal file
View File

@@ -0,0 +1,64 @@
{
// 项目级 Zed 配置:优化 rust-analyzer 性能
//
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
"lsp": {
"rust-analyzer": {
"initialization_options": {
"cargo": {
"features": [], // 不编 default features 之外的重头
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
"targetDir": null, // 留空用 cargo 默认,避免双写
"buildScripts": {
"enable": true,
"rerun": "on-save"
}
},
"check": {
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
"workspace": false, // 即便手动 check 也只查当前 crate
"command": "check", // 用 cargo check(非 clippy),首次开销低
"features": [],
"allTargets": false
},
"diagnostics": {
"enable": true, // 保留 rust-analyzer 自身诊断
"experimental": {
"enable": false
}
},
"procMacro": {
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
"attributes": {
"enable": true
}
},
"workspace": {
"symbol": {
"search": {
"kind": "only_types",
"limit": 64
}
}
},
"completion": {
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
}
},
"interpret": {
"tests": false
},
"files": {
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位
@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型
### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,40 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
```
### 层级模型(执行层级)
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
```
┌──────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
│ │
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
│ 生命周期: 长期存在,跨项目复用 │
│ 存储: YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — DAG 实例、状态机、运行时 │
│ │
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
│ 生命周期: 随项目启动/结束,单次执行后归档 │
│ 载体: df-workflow (DAG + Executor + StateMachine) │
├──────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色、能力边界、行为风格 │
│ │
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
│ 生命周期: 长期存在,跨节点复用 │
│ 注入点: AINode 执行时载入对应人设 │
└──────────────────────────────────────────────────┘
```
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
- 人设 = Agent 的角色卡system prompt + 工具集 + 行为规则)
### AI Working 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的主要操作者,人是监督者与决策者**。
@@ -119,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的
devflow/
├── Cargo.toml # Workspace 根
├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
@@ -284,8 +320,9 @@ pub struct Decision {
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删R-PD-7模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作Planner/Coder/Reviewer/Fixer— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
- **ContextManager**Token 预算管理(`context.rs`,完整实现)✅
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
- **ContextManager**Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes)
@@ -295,13 +332,13 @@ pub struct Decision {
| AINode | 调用 LLM支持流式输出、工具调用 | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| DockerNode | Docker 容器操作 | ❌ 未实现 |
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
| HTTPNode | HTTP 请求 | ❌ 未实现 |
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
> `crates/df-nodes/src/` ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
## 六、数据模型
@@ -497,20 +534,26 @@ CREATE TABLE app_settings (
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
```
## 七、阶段模板
## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板YAML 定义),用户可自定义。
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
- 📋 **需求**AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**AI 编码 → 代码审查 → 自动修复
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
### 7.1 三层定义
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|----|-----------|------|---------|---------|
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor)
- df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表)
- df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志
@@ -528,9 +571,10 @@ CREATE TABLE app_settings (
- 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP)
- ~~df-stages (5 阶段模板)~~ — 已移除2026-06-14 零引用清理
- 流程模板系统YAML 定义 + 模板库 + 实例化引擎
- 人设系统AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板
@@ -566,3 +610,6 @@ CREATE TABLE app_settings (
8. **本地优先**SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
10. **流式优先**AI 输出、Shell 输出全部流式推送到前端
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设

553
Batch.md Normal file
View File

@@ -0,0 +1,553 @@
# DevFlow 批次推进记录
> 记录每个批次的提交 hash、改动内容和交付价值。
> 最后更新: 2026-07-02 | 最新提交: `4624688`
---
## Batch 1 — 审批超时后端兜底
- **提交**: `b923d70`(后重写为 `9dd5a5a`)
- **内容**:
- 新增 `agentic/approval_timeout.rs`,在审批恢复入口检测超时,超时自动取消并写入审计
- 待审批记录新增创建时刻字段,用于超时判定
- 应用启动时从设置恢复超时配置(默认 15 分钟0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源
- **验证**: cargo check 通过
---
## Batch 2 — 工具卡片拆分核验
- **提交**: `e95a5cd`(后重写为 `45fc493`)
- **内容**:
- 核验发现工具卡片拆分任务实际已完成,文档标记滞后
- ToolCard.vue 从 1527 行降至 373 行
- 审批状态机独立为 useToolApproval.ts220 行)
- 头部逻辑、渲染逻辑、通用工具函数各自独立
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 CRUD
- **验证**: 文档核验,无代码改动
---
## Batch 3 — 条件表达式 UI 翻译补齐
- **提交**: `e460e8d`(后重写为 `d79c816`)
- **内容**:
- 工作流条件表达式引擎和执行器集成早已完成
- 前端边条件编辑入口已存在,但翻译缺失(用中文兜底)
- 补齐中英文翻译(工作流结构标题/层标签/空态/边条件编辑器)
- 核验确认条件引擎全部链路均已落地
- **验证**: vue-tsc 通过
---
## Batch 4 — 知识提炼切读消息表
- **提交**: `6aac7ef`(后重写为 `f57c37e`)
- **内容**:
- 知识提炼是最后一个仍读旧消息 JSON 列的路径
- 改为优先读消息表,表空时回退旧 JSON老库兼容
- 复用既有的记录转消息映射函数,与其他读路径同源
- 至此消息存储拆分全部完成
- **验证**: cargo check 通过
---
## Batch 5 — 关闭已完成待办项
- **提交**: `59b7188`(后重写为 `694f24f`)
- **内容**:
- 工具卡片分组标题增强(文件路径摘要展示)已落地
- 重试持锁问题核验无影响(多会话改造后全局锁已移除)
- 对抗式评估批量命令暂不做(单项已接入,批量无消费方)
- 残留诊断代码已清理(无硬编码绝对路径)
- 工程内路径授权政策确认为白名单制
- **验证**: 文档核验
---
## Batch 6 — 应用状态拆分 + 事件分发统一 + 路径授权治本
- **提交**: `996f1d9`
- **内容**:
- **状态文件拆分**: 1416 行单文件 → 按职责分模块(授权白名单/知识库配置/并发控制)
- **事件分发器统一**: 5 类生命周期事件纳入主事件联合类型,删除第二监听器,一个事件源一个监听器一个分发器
- **路径授权去硬编码**: 不再硬塞编译期路径到白名单,工程根授权靠项目绑定目录 + 设置页配置
- **补丁**: `cc3ef5b` — 修复设置页面崩溃(模块循环依赖导致初始化失败)
- **验证**: cargo check + vue-tsc 通过
---
## Batch 7 — 小程序配置持久化与心跳闭环
- **提交**: `cb7e1fc`
- **内容**:
- **配置持久化**: 运行时配置接入 storage懒加载恢复新增重置函数
- **心跳 pong 闭环**: 中继服务识别小程序心跳 ping 直接回 pong不经桌面端透传小程序看门狗据此判活
- **文档同步**: 关闭条件引擎/streaming per-conv/workspace_root 三项已完成的待决策
- **验证**: cargo checkdevflow + df-relay通过
---
## Batch 8 — 消息分页懒加载与事件总线空转优化
- **提交**: `3c18dea`
- **内容**:
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
- **验证**: cargo check 通过
---
## Batch 9 — 项目多工程系统(数据层)
- **提交**: `b72df78`
- **内容**:
- **数据层**: project_modules 表V34 迁移)+ ProjectModuleRepo 完整 CRUD
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交10s 超时)
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
- **AI 工具**: list_project_modulesAI 了解项目工程结构)
- **设计文档**: 新增工程系统设计方案
- **验证**: cargo check 通过
---
## Batch 10 — 项目文件浏览器(前端 UI
- **提交**: `50aad37`
- **内容**:
- **后端接口**: 文件树查询(列目录 + Git 状态合并 + 噪音过滤 + 路径穿越防御)+ 文件读取(文本/图片/二进制1MB 上限)
- **文件树组件**: 递归树形展示懒加载子目录Git 状态标记(橙=修改/绿=新增/灰=未跟踪)
- **文件预览组件**: 代码文本/图片/二进制三分支,加载状态
- **主容器**: 工程选择(单工程隐藏)+ 面包屑导航 + 刷新
- **集成**: 项目详情页新增"文件"Tab
- **国际化**: 中英文同步
- **验证**: cargo check + vue-tsc 通过
---
## Batch 11 — Git 只读 AI 工具
- **提交**: `7b5b62d`
- **内容**:
- **git_status**Low 自动执行): 工作区状态结构化(分支 + 改动文件列表 + 状态标记)
- **git_diff**Low: 改动详情(统计 + patch 截断到 8000 字符防 token 爆)
- **git_log**Low: 提交历史结构化(哈希/作者/消息/日期)
- 所有 git 命令在工程目录执行10s 超时,无 Git 仓库返回空状态
- **验证**: cargo check 通过
---
## Batch 12 — Git 写操作 AI 工具
- **提交**: `0cfea6d`
- **内容**:
- **git_commit**Medium 审批): 提交工作区改动,提交前检查敏感文件(.env/.key/.pem
- **git_branch**Medium 审批): 分支列表/创建/切换
- **git_merge**High 审批): 合并分支,自动检测冲突返回冲突文件列表
- 安全边界: 禁止 push / force / reset --hard
- **验证**: cargo check 通过
---
## 架构设计文档状态同步
- **提交**: `8d18918`
- **内容**: 9 份设计文档状态批量更新(从"待实施"改为"已落地"1 份归档(规格契约自检被其他机制覆盖)
---
## 提交注释规范重写
- **内容**: Batch 1-5 的提交注释经 filter-branch 重写消除内部代号Batch/P1/P2/L1/F-09 等),统一为"两字动词 + 冒号"格式(新增/修复/更新)
---
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
- **提交**: `c6df455`
- **内容**:
- **行号显示**: 文件预览左侧行号列
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
- **刷新保留选中文件**: 只清树缓存不清预览
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
- **验证**: cargo check + vue-tsc + vite build 通过
---
## Batch 14 — 文件浏览器收尾
- **提交**: `53e6442`
- **内容**:
- 提交之前未提交的滚动条与布局修复
- 移除阶段进度条死代码(模板+CSS+常量)
- 文件预览空状态加图标引导
- 变更视图加载骨架屏
- 工程选择器改为自定义下拉(显示工程路径)
---
## Batch 15 — 多工程管理
- **提交**: `d8d6226`
- **内容**:
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git 地址)
- 工程删除二次确认:防误删
- 记住上次选中工程:localStorage 持久化
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
---
## Batch 16 — Git 提交历史作者
- **提交**: `b19b0f3`
- **内容**:
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
- 提交列表每行显示 hash/作者/主题/时间
- 提交详情头部补充作者显示
- 分支切换按钮占位(后续接入分支列表 IPC)
---
## 渲染崩溃修复
- **提交**: `bce2fea`
- **内容**:
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
---
## Batch 17 — Git 能力闭环
- **提交**: `15eaa0c`
- **内容**:
- Diff 行号解析:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
- 提交详情增强:显示父提交哈希/作者/日期/完整消息
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
---
## Batch 18 — 小程序审批超时计时器
- **提交**: `773ebb6`
- **内容**:
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
---
## Batch 19 — 任务管理 UX 重构 + 项目卡片工程数
- **提交**: `2780f6a` + `aa4d521`
- **内容**:
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
- 项目卡片显示工程数(异步拉取 module 列表)
---
## Batch 20 — coordinator.rs 空壳删除
- **提交**: `948d6e6`
- **内容**:
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
- lib.rs 移除 pub mod coordinator 声明
- 消除编译 deprecated 警告
---
## 任务行快捷操作菜单
- **提交**: `27eb0a9`
- **内容**:
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
- 点击外部自动关闭菜单
---
## stores/ai TDZ 防御
- **提交**: `76aec62`
- **内容**:
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
---
## Plan DAG 分层执行器
- **提交**: `4dac9fe`
- **内容**:
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
- feature flag 门控(默认关,未接入主 loop)
- 3 个单元测试覆盖:线性链/并行层/失败容错
---
## 变更区完善
- **提交**: `78ef5dc`
- **内容**:
- 变更文件选中后展开 Diff 红绿预览
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
---
## 任务列表后端真分页
- **提交**: `3c2fa91`
- **内容**:
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
---
## Batch 25 — 工程依赖图数据层
- **提交**: `3758bea`
- **内容**:
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
---
## Batch 26 — 依赖图增强
- **提交**: `dd0e059`
- **内容**:
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
- 依赖边按类型着色
---
## Batch 28 — 架构债收尾
- **提交**: `8991215`
- **内容**:
- ScriptNode 白/黑名单注释清理(标注实际读取方式)
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub,非 bug
---
## Batch 29 — 环形检测 + PNG 导出 + 真实总数
- **提交**: `36ea090`
- **内容**:
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
---
## Batch 30 — 代码质量收尾
- **提交**: `6771d39`
- **内容**:
- 设置页搜索索引补全(新增 dataDir 项)
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
- **验证**: cargo check + vue-tsc + vite build 通过
## Batch 31 — God 文件拆分第一步(MessageList)
- **提交**: `2c2b2d7`
- **内容**:
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
- **验证**: vue-tsc + vite build 通过
## Batch 32 — God 文件拆分第二步(ChatInput)
- **提交**: `3abdfb4`
- **内容**:
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
- ChatInput.vue 从 1183 行降至 1155 行
- **验证**: vue-tsc + vite build 通过
## Batch 34 — 产品完善
- **提交**: `2af24f0`
- **内容**:
- 小程序 device 在线心跳加固(早前已完成)
- ScriptNode 白名单跨 crate 接口打通:OnceLock 全局态 + IPC set_script_safety
- AI 写入后文件树自动刷新(早前已完成)
- Projects 列表分页(后端 list_projects 已支持 limit/offset)
- **验证**: cargo check + vue-tsc 通过
## Batch 35 — 人设系统(P0·第一步)
- **提交**: `0a65ea2`
- **内容**:
- AgentPersona 数据结构(能力标签/工具白名单/prompt 模板)
- PersonaRegistry 注册表(5 内置人设: coder/reviewer/architect/tester/analyst)
- 按意图推荐人设(启发式关键词匹配合适角色)
- build_prompt 注入上下文和目标钉扎
- **验证**: 10 个单元测试通过
## Batch 36 — Coordinator 接入 + audit 拆分
- **提交**: `fa410e6`
- **内容**:
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
- coordinator.rs 移除 deprecated 标记,正式可用
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
- **验证**: cargo check 通过
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
- **提交**: `4624688`
- **内容**:
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
- ChatMessage.status 从 `Option<String>``Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
- .gitignore 添加分析脚本,tmp 文件清理
- Batch.md/文档状态同步
- **验证**: cargo check 通过
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
- **提交**: 待后续
- **内容**:
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
- **测试**: cargo check + vue-tsc 通过
- **验证**: plan_execution_enabled 默认关,主线行为零变化
## 后续规划批次(待推进)
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
### 数据层 + Git worktree 隔离 + 并行执行 ✅
- **提交**: `ae6d3d0``4483358``3c9077b`
- **测试**: 48 个全绿(7 Repo + 7 worktree + 27 Coordinator + 4 Token + 3 merge)
- **验证**: cargo check(0 警告) + vue-tsc + vite build
- **已完成任务**:
| # | 任务 | 文件 |
|---|------|------|
| 1 | V36 迁移(ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id + subtasks.branch + conflicts.conflict_type) | migrations.rs |
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD + models 结构体 | 新 repo 文件 |
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
### 仲裁合并 + 冲突 UI + 编译检查 ✅
- **提交**: `1d580dc``6e44878`
- **测试**: 7 个 merge 冲突检测测试全绿
- **已完成任务**:
| # | 任务 | 状态 |
|---|------|------|
| 1 | Coordinator.merge 冲突检测(同文件路径 + 跳过失败 SubTask) | ✅ |
| 2 | extract_written_files 辅助函数(从 output 提取写入路径) | ✅ |
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
| 4 | i18n 冲突翻译键(中英文) | ✅ |
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
| 8 | context.rs 拆分(1956行) | 待下一批 |
### 模板系统 + 节点补齐 ✅
- **提交**: `f40287b``27b4268`
- **测试**: 8 个模板加载 + 37 个节点 + 5 个 SubflowNode 测试全绿
- **已完成任务**:
| # | 任务 | 状态 |
|---|------|------|
| 1 | YAML 模板格式定义 + 加载器(YAML→DagDef + 校验:空节点/未知类型/环/边引用) | ✅ |
| 2 | GitNode(分支/checkout/commit/merge/push/status/log) | ✅ |
| 3 | HTTPNode(GET/POST/PUT/DELETE + headers/body/timeout) | ✅ |
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
| 8 | MessageList.vue 继续拆分 | 待下一批 |
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | DockerNode(环境检测+授权) | ✅ |
| 2 | GitNode push(plan分支推送) | ✅ GitNode 含 push action |
| 3 | commit status 读取(Gitea API) | ✅ ci_status.rs |
| 4 | CIStatus.vue 前端面板 | ✅ |
| 5 | ChatInput.vue 拆分(1155→1007行) | ✅ |
| 6 | context.rs 拆分(1956→538行) | ✅ |
### 审批政策 + 小程序完善 + 体验收尾 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | 审批策略 Persona 维度 | ✅ |
| 2 | 小程序 MentionInput 接真实数据 | ✅ 核验已完成 |
| 3 | 小程序长会话虚拟化 | ✅ 截断200条合理兜底 |
| 4 | 快捷键完善(Ctrl+N/Ctrl+R) | ✅ |
| 5 | ARCHITECTURE.md 状态同步 | ✅ |
### 批次 C 前序散项 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | Reviewer Agent 仲裁 | ✅ |
| 2 | 命令互斥锁 | ✅ |
| 3 | resolve_conflict IPC | ✅ |
| 4 | 模板 CRUD IPC | ✅ |
| 5 | MessageList.vue 拆分(1386→1148行) | ✅ |
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
| 7 | df-types augmentation 测试修复 | ✅ |
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
| 9 | 合并产出落回主对话 | 待接线 |
---
## 未纳入批次的其他会话改动
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复Batch 6 补丁 `cc3ef5b` 纳入提交)

223
Cargo.lock generated
View File

@@ -779,6 +779,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -835,6 +849,7 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"dashmap",
"df-ai",
"df-execute",
"df-ideas",
@@ -859,6 +874,8 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter",
"tree-sitter-go",
"tree-sitter-java",
@@ -875,13 +892,15 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"bytes",
"df-ai-core",
"df-types",
"eventsource-stream",
"futures",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
@@ -951,6 +970,7 @@ dependencies = [
"df-storage",
"df-types",
"df-workflow",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
@@ -966,6 +986,7 @@ dependencies = [
"df-types",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
@@ -1037,6 +1058,7 @@ dependencies = [
"futures",
"serde",
"serde_json",
"serde_yaml",
"tokio",
"tracing",
]
@@ -1288,17 +1310,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1638,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1649,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1987,6 +2002,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2488,6 +2504,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2535,12 +2557,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2643,16 +2659,6 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3342,6 +3348,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3370,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -3381,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -3393,6 +3474,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3493,6 +3583,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3500,6 +3592,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3509,6 +3602,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3632,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3644,6 +3739,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -3755,7 +3851,7 @@ dependencies = [
"generic-array",
"num",
"once_cell",
"rand",
"rand 0.8.6",
"serde",
"zbus 4.4.0",
]
@@ -3976,6 +4072,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -4201,6 +4310,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "1.0.109"
@@ -5039,6 +5154,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -5210,7 +5338,7 @@ dependencies = [
"httparse",
"log",
"native-tls",
"rand",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -5228,7 +5356,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -5316,6 +5444,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5583,6 +5717,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@@ -5639,6 +5783,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -6376,7 +6529,7 @@ dependencies = [
"hex",
"nix",
"ordered-stream",
"rand",
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",

View File

@@ -22,3 +22,7 @@ regex = "1"
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
dashmap = "6"

View File

@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量)2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md)
---
@@ -11,9 +11,9 @@
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
| Git 状态 | 已纳入版本控制(main 分支) |
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
---
@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------|
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeReposearch/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) |
@@ -59,7 +59,7 @@
```
真实可用路径(有端到端逻辑):
df-core (类型基础)
df-types (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 |
|------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行
**工作内容**(三个子代理并行,目录互不重叠):
- **Migrations V2**df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列
- **Migrations V2**df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列
- **Executor 并行化**df-workflow同层节点改 futures::join_all 并发三阶段模式串行准备→并发执行→串行收尾规避借用冲突StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法set_xxx 改返回 Result4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层
- **Tauri IPC**src-tauriAppStatedb + 6 Repo + EventBus + NodeRegistry+ 17 个 commandproject/task/idea CRUD + run_workflow/list/get executionsrun_workflow 先落库 status=running 立即返回 execution_id事件经 app.emit("workflow-event") 转发前端完成后回写状态。注意NodeRegistry 不能用 default()script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
@@ -665,7 +665,7 @@
**遗留 / 下一步(修复批次建议)**:
- **批1 数据安全**: ①AI delete 改 soft_delete+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-core+ ⑤tool_registry 复用 allowed_columns_for
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-types+ ⑤tool_registry 复用 allowed_columns_for
- **批3 前端**: ⑥(补 i18n key+ ⑦(抽 src/utils/project.ts+ ⑧(全局 8 处 alert/confirm 换 Arco Modal
- ⚪ 可选: ⑨⑩⑪⑫ 按需
@@ -1031,6 +1031,47 @@
---
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
> 本轮Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
> 代码变更 50 文件,+2454/-1251 行,未提交。
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
| Phase | 内容 |
|---|---|
| Phase0-1 | DashMap 骨架 + AppState 接入Claude Code 完成)|
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
| Phase4 | conversation_delete 同步 conv_states.remove |
**前端修复**
- P0-1: watchdog `convStates.clear()``delete(activeConversationId)`
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
- 每轮 token 独立显示(🔣 XXX in · YYY out
- STREAM_TIMEOUT_MS 45s → 90s
**后端修复**
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
- `env_snapshot.rs` 5s 超时兜底Windows Store alias 卡死)
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
- `openai_compat.rs` send 阶段 60s timeout
- 原生 SSE 解析器替代 eventsource-streamWindows 兼容)
- run_agentic_loop 20s AiHeartbeat 保活
- grep 正则自动修复(未分组 `|``(?:...)` 包裹)
- read_file 路径不存在提示 list_directory
**标题策略改进**
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
**代码审查清理**
- 修复 lib.rs 重复 handler 注册(🔴 严重)
- 清理 unused import / [STUCK] 调试日志 / 过时注释
- 340 tests passed, vue-tsc clean
**待提交**50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
## 七、开发约定
### 构建命令

View File

@@ -341,7 +341,7 @@ class WsClient {
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(relay pong,用心跳期间入站消息替代判活)
// 活性:任意入站帧更新(relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)

View File

@@ -215,6 +215,44 @@ function resetWatchdog(): void {
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
}
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
function startApprovalTimer(tcId: string, toolName: string): void {
if (_approvalTimers.has(tcId)) return // 幂等
const timer = setTimeout(() => {
_approvalTimers.delete(tcId)
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
if (idx >= 0) {
pendingApprovals.splice(idx, 1)
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
approve(tcId, false)
}
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(tcId, timer)
}
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
function clearApprovalTimer(tcId: string): void {
const t = _approvalTimers.get(tcId)
if (t) {
clearTimeout(t)
_approvalTimers.delete(tcId)
}
}
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
function clearAllApprovalTimers(): void {
for (const t of _approvalTimers.values()) clearTimeout(t)
_approvalTimers.clear()
}
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer)
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
// 审批计时器:工具完成 → 清(审批已生效)
clearApprovalTimer(event.id)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
pendingApprovals.push(info)
}
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
}
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = {
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
generating.value = false
agentRound.value = 0
clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) {
@@ -674,7 +717,14 @@ function subscribeWs(): void {
}
},
onControl: (msg: ControlMessage) => {
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI)
// 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
if (msg.control_kind === 'pong') {
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
return
}
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
console.log('[useAiChat] control', msg)
},
onStatus: (status: WsStatus, detail?: string) => {
@@ -928,7 +978,7 @@ function switchConversation(convId: string): void {
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
@@ -1015,7 +1065,7 @@ function newConversation(): void {
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
}
/**
@@ -1037,7 +1087,7 @@ function syncOnConnect(): void {
// req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
}

View File

@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
reconnectMaxDelay: 30000,
}
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
let _config: MiniappConfig = { ...defaultConfig }
/** storage key(持久化完整 MiniappConfig JSON) */
const STORAGE_KEY = 'df-miniapp-config'
/** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null
/**
* 从 storage 读取并合并默认配置
*
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
*/
function loadFromStorage(): MiniappConfig {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
// 合并默认值,避免旧版本字段缺失导致 undefined
return { ...defaultConfig, ...parsed }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
}
}
/**
* 同步写入 storage(JSON 序列化完整配置)
*
* 容错:写入失败仅 warn,不影响内存配置。
*/
function saveToStorage(config: MiniappConfig): void {
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.warn('[df-miniapp] save config to storage failed:', e)
}
}
export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config
}
export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch }
saveToStorage(_config)
}
/**
* 重置为默认配置并清除 storage 持久化数据
*
* 用于用户重新配对或恢复出厂占位值的场景。
*/
export function resetConfig(): void {
_config = { ...defaultConfig }
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (e) {
console.warn('[df-miniapp] remove config from storage failed:', e)
}
}

View File

@@ -0,0 +1,47 @@
# Bug 修复模板
name: Bug 修复
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
nodes:
locate:
type: ai
label: 定位 Bug
config:
prompt: |
根据错误描述和堆栈信息,定位 bug 的根因。
使用 search_files 和 read_file 工具查找相关代码。
persona_id: analyst
fix:
type: ai
label: 修复 Bug
config:
prompt: |
基于定位结果,修复 bug。
使用 patch_file 工具进行最小化修改。
persona_id: coder
verify:
type: ai
label: 验证修复
config:
prompt: |
验证修复是否有效。
使用 run_command 工具运行相关测试。
persona_id: tester
notify:
type: notify
label: 通知结果
config:
type: desktop
title: Bug 修复完成
message: 修复已验证
edges:
- from: locate
to: fix
- from: fix
to: verify
- from: verify
to: notify

View File

@@ -0,0 +1,36 @@
# 代码审查模板
name: 代码审查
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
nodes:
analyze:
type: ai
label: 分析代码变更
config:
prompt: |
分析以下代码变更,识别:
1. 潜在 bug 和逻辑错误
2. 安全漏洞
3. 性能问题
4. 代码规范
persona_id: reviewer
report:
type: ai
label: 生成审查报告
config:
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 代码审查完成
message: 审查报告已生成
edges:
- from: analyze
to: report
- from: report
to: notify

View File

@@ -0,0 +1,62 @@
# 功能开发模板
name: 功能开发
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
nodes:
design:
type: ai
label: 方案设计
config:
prompt: |
分析需求,设计实现方案:
1. 模块划分和接口定义
2. 数据结构和核心算法
3. 影响范围评估
persona_id: architect
implement:
type: ai
label: 编码实现
config:
prompt: |
按照设计方案实现功能。
使用 write_file 和 patch_file 工具编写代码。
persona_id: coder
test:
type: ai
label: 编写测试
config:
prompt: |
为新功能编写单元测试和集成测试。
使用 run_command 工具运行测试验证。
persona_id: tester
review:
type: ai
label: 代码审查
config:
prompt: |
审查实现代码,关注:
1. 代码质量和可维护性
2. 潜在 bug
3. 安全性
persona_id: reviewer
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 功能开发完成
message: 设计→编码→测试→审查 全流程完成
edges:
- from: design
to: implement
- from: implement
to: test
- from: test
to: review
- from: review
to: notify

View File

@@ -5,10 +5,18 @@
"": {
"name": "devflow",
"dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2",
"dompurify": "^3.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
"vue-router": "4",
@@ -24,6 +32,20 @@
},
},
"packages": {
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -172,6 +194,10 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -228,15 +254,35 @@
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -244,20 +290,44 @@
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -284,18 +354,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
}
}

View File

@@ -95,14 +95,14 @@ impl ChatMessage {
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价None=true / "active"=true / "truncated"=false
pub fn is_active(&self) -> bool {
matches!(self.status.as_deref(), None | Some("active"))
matches!(self.status, None | Some(MessageStatus::Active))
}
}
impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".into(),
tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters },
}
}
@@ -166,22 +166,22 @@ mod tests {
// "active"
let mut m = ChatMessage::user("hi");
m.status = Some("active".to_string());
m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值与旧实现等价false
let mut m = ChatMessage::user("hi");
m.status = Some("truncated".to_string());
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("archived_segment".to_string());
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active白名单隔离");
// "compressed" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("compressed".to_string());
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active白名单隔离");
}

View File

@@ -103,11 +103,11 @@ pub struct ChatMessage {
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见;
/// 消息状态UX-09 编辑重生成None 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub status: Option<MessageStatus>,
/// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
@@ -148,11 +148,91 @@ pub enum MessageRole {
Tool,
}
/// 消息状态枚举IPC 边界序列化为小写 snake_case 字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MessageStatus {
/// 正常可见
Active,
/// 软删(编辑某条 user 消息后其后续消息标记)
Truncated,
/// 已压缩
Compressed,
/// 已归档段
ArchivedSegment,
}
impl MessageStatus {
/// DB 存储用的小写 snake_case 字符串
pub fn as_db_str(&self) -> &'static str {
match self {
MessageStatus::Active => "active",
MessageStatus::Truncated => "truncated",
MessageStatus::Compressed => "compressed",
MessageStatus::ArchivedSegment => "archived_segment",
}
}
/// 从 DB 字符串解析
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"active" => MessageStatus::Active,
"truncated" => MessageStatus::Truncated,
"compressed" => MessageStatus::Compressed,
"archived_segment" => MessageStatus::ArchivedSegment,
_ => return None,
})
}
}
/// ChatMessage 结构体
/// 工具类型IPC 边界透明序列化为字符串,如 "function"
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct ToolType(String);
impl ToolType {
/// 构造新工具类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ToolType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl PartialEq<&str> for ToolType {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolType {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
/// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
pub tool_type: ToolType,
pub function: ToolFunction,
}
@@ -169,7 +249,7 @@ pub struct ToolFunction {
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub call_type: ToolType,
pub function: ToolCallFunction,
}

View File

@@ -14,6 +14,10 @@ anyhow = { workspace = true }
tracing = { workspace = true }
# HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
bytes = "1"
futures = "0.3"
eventsource-stream = "0.2"
rand = "0.8"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,76 @@
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
//!
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
//!
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
use df_ai::build_provider;
use df_ai_core::CompletionRequest;
use futures::StreamExt;
#[tokio::main]
async fn main() {
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
eprintln!("[glm-test] token len={}", token.len());
let provider = build_provider(
"anthropic",
"https://open.bigmodel.cn/api/anthropic",
&token,
"glm-5.2",
);
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
let req: CompletionRequest = serde_json::from_str(
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
)
.expect("parse CompletionRequest");
eprintln!("[glm-test] 调用 provider.stream() ...");
let t0 = std::time::Instant::now();
let mut s = match provider.stream(req).await {
Ok(s) => {
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
s
}
Err(e) => {
eprintln!("[glm-test] stream() Err: {:#}", e);
return;
}
};
let mut n = 0;
let mut got_text = false;
while let Some(chunk_result) = s.next().await {
n += 1;
match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
got_text = true;
}
eprintln!(
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
n, chunk.delta,
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
chunk.finished, chunk.usage, chunk.error
);
}
Err(e) => {
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
}
}
if n > 60 {
eprintln!("[glm-test] 超 60 chunk 截断");
break;
}
}
eprintln!(
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
n,
got_text,
t0.elapsed()
);
}

View File

@@ -10,7 +10,6 @@
//! 本模块仅保留 Provider struct + implHTTP 调用Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use reqwest::Client;
use std::time::Duration;
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
let resp = match self
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
tracing::error!(
url = %self.messages_url(),
"Anthropic 流式 send 超时(60s 未返回响应头)"
);
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
if !resp.status().is_success() {
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)
let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
let messages_summary = Self::summarize_messages(&body.messages);
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(ev) => {
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
match result {
Ok(events) => {
for data in events {
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
}
Ok(chunk)
}
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("Anthropic SSE 错误: {}", e);
error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::Error::from(e).context(ctx))
error!("{}", ctx);
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
});
}
futures::stream::iter(chunks)
});
Ok(Box::pin(stream))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
//!
//! 这些测试覆盖 `ContextManager` 各公开/私有方法push / build_for_request / compress /
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
//! `mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
#![cfg(test)]
use super::*;
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
// 纯文本基线
let text_msg = ChatMessage::user("短文本");
let text_tokens = est.estimate_message(&text_msg);
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
let multimodal = ChatMessage::user_parts(
"短文本",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
);
let mm_tokens = est.estimate_message(&multimodal);
assert!(
mm_tokens > text_tokens,
"含图消息 token({}) 应高于纯文本({})",
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token实际 {}",
mm_tokens
);
// url 模式(无字节)也按 URL 长度估算,不爆
let url_msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let url_tokens = est.estimate_message(&url_msg);
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("你好"));
mgr.push(ChatMessage::assistant("你好啊"));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert_eq!(msgs.len(), 2);
}
#[test]
fn over_budget_trims_old() {
// 小预算强制裁剪20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 {
if i % 2 == 0 {
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
} else {
mgr.push(ChatMessage::assistant(&format!("{} 条较长的回复用于撑爆预算", i)));
}
}
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
// 保护区:最新一条必保留(末条 i=19 是 assistant)
assert_eq!(
msgs.last().unwrap().content,
"第 19 条较长的回复用于撑爆预算",
"保护区最新消息被误裁"
);
// 裁剪是视图:内存全量不变
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
}
#[test]
fn tool_triplet_kept_atomic() {
// 三元组不可分离Head 与 Tail 同进同出,永不从中间切断
// 布局6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
let mut mgr = ContextManager::new(cfg(95));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
}
mgr.push(ChatMessage::assistant_with_tools(
"调工具",
vec![ToolCall::new("tc1", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
mgr.push(ChatMessage::assistant("完成"));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
}
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
assert!(trimmed1, "分支一应触发裁剪");
assert_eq!(
has_head(&msgs_keep),
has_tail(&msgs_keep),
"分支一三元组被切断: head={} tail={}",
has_head(&msgs_keep),
has_tail(&msgs_keep)
);
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
assert!(trimmed2, "分支二应触发裁剪");
assert_eq!(
has_head(&msgs_drop),
has_tail(&msgs_drop),
"分支二三元组被切断: head={} tail={}",
has_head(&msgs_drop),
has_tail(&msgs_drop)
);
// 裁剪是视图:两次 build 都不应改变内存全量
assert_eq!(
mgr.all_messages_clone().len(),
15,
"裁剪污染了内存全量"
);
}
fn has_head(msgs: &[ChatMessage]) -> bool {
msgs.iter()
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
}
fn has_tail(msgs: &[ChatMessage]) -> bool {
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
}
#[test]
fn replace_tool_result_updates_tokens() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::tool_result("tc1", ""));
let before = mgr.history_tokens();
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
let after = mgr.history_tokens();
assert!(after > before);
}
#[test]
fn restore_rebuilds_token_cache() {
let mut mgr = ContextManager::new(cfg(100_000));
let src = vec![
ChatMessage::user("测试消息一"),
ChatMessage::assistant("回复一"),
ChatMessage::user("测试消息二"),
];
mgr.restore_from_messages(src);
assert!(mgr.history_tokens() > 0);
assert_eq!(mgr.len(), 3);
}
#[test]
fn push_token_only_active() {
// F-15 §3.3!active 消息truncated / archived_segment / compressed仍 push
// 到 self.messages全量保留持久化/前端视图自管),但不计入 history_tokens
// 避免 build_for_request 误判超预算触发不必要裁剪。
// 1) 直接 push 路径
let mut mgr = ContextManager::new(cfg(100_000));
let active_msg = ChatMessage::user("这条是 active 的");
let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
inactive_msg.status = Some(MessageStatus::Truncated);
let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);
mgr.push(active_msg);
mgr.push(inactive_msg);
// 全量保留(两条都在内存)
assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
// token 预算只含 active
assert_eq!(
mgr.history_tokens(),
active_tokens,
"history_tokens 应只含 active多算了 {}inactive 应被忽略)",
mgr.history_tokens().saturating_sub(active_tokens)
);
assert!(
inactive_tokens > 0,
"前提inactive 消息本身确有 token否则无法证明它被排除"
);
// 2) restore_from_messages 路径(调 pushtoken 同步仅 active
let mut mgr2 = ContextManager::new(cfg(100_000));
let mut a = ChatMessage::user("active 一");
a.status = Some(MessageStatus::Active);
let mut b = ChatMessage::user("archived 一");
b.status = Some(MessageStatus::ArchivedSegment);
let mut c = ChatMessage::user("compressed 一");
c.status = Some(MessageStatus::Compressed);
mgr2.restore_from_messages(vec![a, b, c]);
assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
// 只 active 一条计 tokenb/c 是白名单外状态is_active 返回 false
let only_active_tokens = TokenEstimator::default()
.estimate_message(&ChatMessage::user("active 一"));
assert_eq!(
mgr2.history_tokens(),
only_active_tokens,
"restore 后 history_tokens 应只含 active 一条archived/compressed 不计"
);
}
#[test]
fn empty_history_returns_empty() {
let mgr = ContextManager::new(cfg(100_000));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert!(msgs.is_empty(), "空历史应返回空列表");
}
#[test]
fn protect_zone_returns_full_when_untrimmable() {
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
let mut mgr = ContextManager::new(cfg(10));
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
}
#[test]
fn system_over_budget_trims_to_protect_zone() {
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
let mut mgr = ContextManager::new(cfg(200));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
}
let (msgs, _trimmed) = mgr.build_for_request(195);
assert!(
msgs.len() <= PROTECT_COUNT,
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
PROTECT_COUNT,
msgs.len()
);
}
// ── F-15 阶段1 辅助方法单测 ──
#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
// F-15 §4.2/§4.3compress_old_messages 把 [0, end) 内 active 消息标 compressed,
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧消息1"));
mgr.push(ChatMessage::assistant("旧回复1"));
mgr.push(ChatMessage::user("新消息2"));
let tokens_before = mgr.history_tokens();
assert!(tokens_before > 0);
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
assert_eq!(compressed[0].content, "旧消息1");
assert_eq!(compressed[1].content, "旧回复1");
// status 已改 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
// 保护区外(本例 index 2)仍 active
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
// 持久化全量不变
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
// token 已扣(剩第 3 条的)
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
}
#[test]
fn compress_old_messages_is_idempotent() {
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.push(ChatMessage::user("b"));
let first = mgr.compress_old_messages(2);
assert_eq!(first.len(), 2);
let tokens_after_first = mgr.history_tokens();
let second = mgr.compress_old_messages(2);
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
assert_eq!(
mgr.history_tokens(),
tokens_after_first,
"二次压缩 history_tokens 不应再变(幂等)"
);
}
#[test]
fn compress_old_messages_clamps_oversized_end() {
// compress_end 越界自动 clamp 到 len,不 panic。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("唯一"));
let compressed = mgr.compress_old_messages(999);
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
}
#[test]
fn compress_old_messages_skips_already_inactive() {
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
let mut mgr = ContextManager::new(cfg(100_000));
let mut truncated = ChatMessage::user("被截断");
truncated.status = Some(MessageStatus::Truncated);
mgr.push(truncated);
mgr.push(ChatMessage::user("active 一条"));
let tokens_before = mgr.history_tokens();
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
assert_eq!(mgr.history_tokens(), 0);
assert_eq!(
mgr.history_tokens(),
tokens_before.saturating_sub(tokens_before),
"幂等扣除:truncated 本就没计 token,active 扣光"
);
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
assert_eq!(
mgr.messages_mut()[0].message.status.as_ref(),
Some(&MessageStatus::Truncated),
"已 truncated 不应被改写为 compressed"
);
}
#[test]
fn has_compressible_messages_respects_protect_zone() {
let mut mgr = ContextManager::new(cfg(100_000));
for i in 0..8 {
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
}
// protect_start=6 → [0,6) 内有 active → true
assert!(mgr.has_compressible_messages(6));
// protect_start=0 → 空范围 → false
assert!(!mgr.has_compressible_messages(0));
// 全部压缩后 → false
mgr.compress_old_messages(6);
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
}
#[test]
fn is_compressing_flag_round_trip() {
// 标志位读写 round-trip;clear() 复位。
let mut mgr = ContextManager::new(cfg(100_000));
assert!(!mgr.is_compressing(), "默认 false");
mgr.set_compressing(true);
assert!(mgr.is_compressing(), "set true 后应读到 true");
mgr.set_compressing(false);
assert!(!mgr.is_compressing(), "set false 后复位");
// clear 复位
mgr.set_compressing(true);
mgr.clear();
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
}
#[test]
fn insert_at_adds_to_budget_when_active() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
let tokens_before = mgr.history_tokens();
// 插入 active system 消息 → 计入 token
mgr.insert_at(0, ChatMessage::system("## 摘要"));
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
assert_eq!(mgr.len(), 2);
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
// 插入 !active 消息 → 不计入 token
let tokens_before_inactive = mgr.history_tokens();
let mut inactive = ChatMessage::user("x");
inactive.status = Some(MessageStatus::Truncated);
mgr.insert_at(0, inactive);
assert_eq!(
mgr.history_tokens(),
tokens_before_inactive,
"!active 消息插入不应计 token"
);
}
#[test]
fn build_eviction_units_keeps_triplet_atomic_public() {
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("前置"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("c1", "结果"));
mgr.push(ChatMessage::assistant("完成"));
mgr.push(ChatMessage::user("后置"));
// protect_start=5(全部纳入)
let units = mgr.build_eviction_units(5);
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
assert!(
unit2.end >= 4,
"三元组三件套应在同一淘汰单元, end={}",
unit2.end
);
}
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
#[test]
fn last_assistant_message_id_returns_latest() {
// 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问1"));
let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
mgr.push(ChatMessage::user("问2"));
let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
// 末条 assistant id 应是 last(非 first)
assert_eq!(
mgr.last_assistant_message_id().as_deref(),
Some(last.as_str()),
"应取末条 assistant id, 而非首条"
);
assert_ne!(
mgr.last_assistant_message_id().as_deref(),
Some(first.as_str())
);
}
#[test]
fn last_user_message_id_returns_latest() {
// 多条 user,反向扫描取末条 id(触发本轮检索的 user)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问1"));
mgr.push(ChatMessage::assistant("答1"));
let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
assert_eq!(
mgr.last_user_message_id().as_deref(),
Some(last_user.as_str())
);
}
#[test]
fn last_message_id_none_when_no_such_role() {
// 无 assistant → None;无 user → None
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("只有 user"));
assert!(
mgr.last_assistant_message_id().is_none(),
"无 assistant 消息应返 None"
);
let mut mgr2 = ContextManager::new(cfg(100_000));
mgr2.push(ChatMessage::assistant("只有 assistant"));
assert!(
mgr2.last_user_message_id().is_none(),
"无 user 消息应返 None"
);
}
#[test]
fn last_message_id_none_for_legacy_no_id() {
// 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
let mut mgr = ContextManager::new(cfg(100_000));
let mut legacy = ChatMessage::assistant("老消息无 id");
legacy.id = None;
mgr.push(legacy);
assert!(
mgr.last_assistant_message_id().is_none(),
"老消息无 id 应返 None(向前兼容)"
);
}
/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
let id = message.id.clone();
mgr.push(message);
id.expect("新构造消息必有 id")
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
#[test]
fn topic_marker_triggers_on_two_different_high_conf_topics() {
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("帮我重构这段代码"));
// 此时只有一条 user,无 marker
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let marker = mgr.take_topic_marker()
.expect("双不同 topic 应置位 marker");
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
// take 后清空(幂等)
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
}
#[test]
fn topic_marker_not_triggered_on_same_topic() {
// 同 topic(code/code)→ 不置位(非切换)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("再重构另一段代码"));
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
}
#[test]
fn topic_marker_not_triggered_when_either_topic_none() {
// 保守:任一 topic 为 None(低置信未标)不置位。
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("今天的天气不错啊"));
mgr.push(ChatMessage::user("帮我重构这段代码"));
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
// 反向:前一条高置信 + 后一条低置信
let mut mgr2 = ContextManager::new(cfg(100_000));
mgr2.push(ChatMessage::user("帮我重构这段代码"));
mgr2.push(ChatMessage::user("嗯嗯好的"));
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
}
#[test]
fn topic_marker_not_polluting_compression_or_trim() {
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
let mut mgr = ContextManager::new(cfg(80));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
}
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed);
assert!(!msgs.is_empty());
}
#[test]
fn topic_field_default_none_in_constructors() {
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
// insert_at 始终 None——insert_at 不做主题推断,保守)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.insert_at(0, ChatMessage::system("摘要"));
// insert_at 的 system 消息 topic=None(role 非 User)
assert_eq!(mgr.messages_mut()[0].topic, None);
}
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
#[test]
fn topic_marker_chained_three_switches_each_triggers() {
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
let mut mgr = ContextManager::new(cfg(100_000));
// 1. Code
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
// 2. Code → Project(切换)
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
// 3. Project → Task(切换)
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
// 4. Task → Http(切换,证链式不因中间穿插断)
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
}
#[test]
fn topic_marker_low_confidence_chain_never_triggers() {
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(
mgr.take_topic_marker().is_none(),
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
);
}
#[test]
fn topic_marker_high_then_low_confidence_never_triggers() {
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(
mgr.take_topic_marker().is_none(),
"后一条 topic None,即使前一条高置信也不触发"
);
}
#[test]
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
let _ = mgr.take_topic_marker();
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
}
#[test]
fn topic_field_survives_compress_old_messages() {
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
mgr.push(ChatMessage::assistant("ok")); // idx 2
// 压缩 [0,2):idx 0/1 标 compressed
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
// topic 字段保留(compress 不触碰)
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
// status 改为 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
}
#[test]
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
let marker = mgr.take_topic_marker().expect("应触发 marker");
assert!(marker.contains("code") && marker.contains("project"));
// 调用方据 marker insert system 标记(agentic loop 实际行为)
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "充足预算不应裁剪");
assert!(
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
"marker system 应进发送视图: {:?}",
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
);
}
#[test]
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
assert!(mgr.take_topic_marker().is_none());
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
mgr.push(ChatMessage::assistant("编译 error 出现了"));
assert!(
mgr.take_topic_marker().is_none(),
"assistant 消息不参与主题推断,不触发 marker"
);
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
mgr.push(ChatMessage::user("继续重构"));
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
// 此时末条 user 是 code,切到 project 应触发
mgr.push(ChatMessage::user("创建项目并绑定目录"));
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
}
#[test]
fn topic_marker_take_is_idempotent_and_clears() {
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let _ = mgr.take_topic_marker().expect("应有 marker");
// 第二次 take 必 None
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}

View File

@@ -0,0 +1,538 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX已抽至 [`crate::context_helpers`],本模块 `use` 复用,
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
//!
//! # 子模块
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
use crate::context_helpers::{
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见agentic.rs / commands/ai/mod.rs 等调用方零变更)。
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager {
messages: Vec<TrackedMessage>,
/// 当前历史总 token 数(不含 system prompt
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
/// 压缩重入标志F-15 §4.3true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
}
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
messages: Vec::new(),
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
}
}
/// 追加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
// 仅 active 消息计入 token 预算F-15 §3.3truncated / archived_segment /
// compressed 不进 LLM 上下文token 虚高会致 build_for_request 误判超预算
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
if message.is_active() {
self.history_tokens += tokens;
}
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
Some(intent.as_str().to_string())
} else {
None
}
} else {
None
};
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
if matches!(message.role, MessageRole::User) {
if let Some(prev_topic) = self.last_user_topic() {
if let Some(this_topic) = &topic {
if prev_topic != *this_topic {
self.pending_topic_marker =
Some(format!("{}|{}", prev_topic, this_topic));
}
}
}
}
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
topic,
});
}
/// 清空所有消息
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
}
/// 消息数量
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// 当前历史占用的 token 数(不含 system prompt
pub fn history_tokens(&self) -> u32 {
self.history_tokens
}
/// 预算上限
pub fn budget_limit(&self) -> u32 {
self.config.budget_limit()
}
// ── 核心:构建请求消息(受控裁剪版本)──
/// 构建发送给 LLM 的消息列表
///
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
/// 返回 (消息列表, 是否发生了裁剪)。
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
let budget = self.budget_limit();
let available = budget.saturating_sub(sys_tokens);
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
if sys_tokens > budget {
tracing::warn!(
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
sys_tokens, budget
);
}
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
// 超预算 → 视图裁剪(不修改 self.messages保证 all_messages_clone 仍返回全量)
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in &units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
if trim_end == 0 {
tracing::warn!(
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone());
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
.iter()
.map(|t| t.message.clone())
.collect();
tracing::info!(
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
///
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
/// `ContextManager::sanitize_messages`),实现见子模块文档。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitize::sanitize_messages(messages)
}
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
sanitize::assert_placeholder_pairing(messages, enabled)
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
self.messages.iter().map(|t| t.message.clone()).collect()
}
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
self.clear();
for msg in messages {
self.push(msg);
}
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result
/// 返回 true 如果找到并替换了
///
/// 反向遍历tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::Tool)
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
});
let Some(i) = pos else { return false };
// 先更新 content再重估 token 并校正总量
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
true
}
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result
///
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
pub fn pop_last_assistant_round(&mut self) -> bool {
if self.messages.is_empty() {
return false;
}
let mut popped_any = false;
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
while let Some(last) = self.messages.last() {
if matches!(last.message.role, MessageRole::User) {
break;
}
let removed = self.messages.pop().expect("just checked non-empty");
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
if matches!(removed.message.role, MessageRole::Assistant) {
popped_any = true;
}
}
popped_any
}
/// 编辑某条 user 消息后,将其后所有消息标记为 truncatedUX-09 编辑重生成)。
///
/// 软删语义:保留在内存真相源 + DB可追溯但 sanitize_messages 过滤后不进 LLM 上下文,
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数0 表示该 user 消息已是末尾,无后续)。
///
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
/// 找不到返回 Err(()),调用方据此报错。
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
// 反向找末条 active user 消息且 content 匹配
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User)
&& t.message.content == target_content
&& t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
let mut count = 0usize;
for t in self.messages[i + 1..].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::Truncated);
count += 1;
}
}
Ok(count)
}
/// 替换末条 active user 消息的 contentUX-09 编辑重生成)。
///
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User) && t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
Ok(())
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
///
/// 用途:
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| {
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
})
.and_then(|t| t.message.id.clone())
}
/// 末条 assistant 消息的 id(消息级溯源用)。
pub fn last_assistant_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::Assistant)
}
/// 末条 user 消息的 id(消息级溯源用)。
pub fn last_user_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::User)
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
///
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
fn last_user_topic(&self) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| matches!(t.message.role, MessageRole::User))
.and_then(|t| t.topic.clone())
}
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
///
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
pub fn take_topic_marker(&mut self) -> Option<String> {
self.pending_topic_marker.take()
}
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API──
//
// 已全量接入压缩链路:
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
// insert_at(摘要/续接锚点 system 消息插入首位)
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`
pub fn config(&self) -> &ContextConfig {
&self.config
}
/// 可变消息切片供阶段2 标记 status="compressed"/"archived_segment" + 调整 token
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
if message.is_active() {
self.history_tokens += tokens;
}
self.messages.insert(index, TrackedMessage {
message,
token_count: tokens,
group,
topic: None,
});
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
///
/// 返回每个单元的右开区间 end + token 总和,保证:
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
while i < protect_start {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
{
token_sum += self.messages[i].token_count;
i += 1;
}
} else {
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
///
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
self.messages[..end]
.iter()
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
let end = compress_end.min(self.messages.len());
let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() {
if t.message.is_active() {
newly_compressed.push(t.message.clone());
t.message.status = Some(MessageStatus::Compressed);
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
newly_compressed
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
}
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
pub fn set_compressing(&mut self, v: bool) {
self.is_compressing = v;
}
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
}
#[cfg(test)]
mod manager_tests;

View File

@@ -0,0 +1,761 @@
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
//!
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
//! 等历史调用路径不变)。
//!
//! # 函数概览
//! - [`sanitize_messages`]主入口step0 active 过滤 → step1/2/3 正向 orphan →
//! step3.5 反向 orphan → step4 序列合法性)。
//! - [`drop_reverse_orphans`]step3.5 反向 orphan 检测。
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
//! - [`ensure_sequence_legal`]step4 序列合法性修复(首条 user + 连续同 role 合并)。
use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
use crate::provider::{ChatMessage, MessageRole, ToolCall};
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
///
/// 根因Anthropic 流式 `tool_use` 块缺 id 时anthropic_compat.rs 170-176
/// 生成 `tool_missing_{idx}` 占位 id对应的 `tool_result` 一旦推入历史,
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
/// 不改持久化self.messages 全量保留),让卡死的会话能自愈继续。
///
/// provider 协议铁律assistant 头里每个 tool_call.id 都必须在后续有对应的
/// tool_result否则服务端 400/500。本函数按此自愈分三档处理每个头
///
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
/// - 部分闭合:保留头但只留已闭合的 tool_call丢弃未闭合的 tool_call 及其
/// 无主 tool_result「不能整条删」——保住已发生的合法工具交互历史
///
/// 单次遍历、保序过滤、不重排user/assistant/tool 角色交替不被打乱)。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// step 0UX-09过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| m.is_active())
.collect();
// step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 2判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids未闭合的 tool_call.id含占位 id其 tool_result 要丢
// partial_heads部分闭合的头——保留但需重写 tool_calls只留已闭合的
// full_drop_heads全未闭合的头——整条丢弃
let mut orphaned_ids: HashSet<String> = HashSet::new();
let mut full_drop_heads = 0u32;
let mut partial_heads = 0u32;
// 记录部分闭合头中「应保留的 id」用于 step3 精确重写
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
std::collections::HashMap::new();
for (i, m) in messages.iter().enumerate() {
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else { continue };
if calls.is_empty() {
continue;
}
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str())
});
match (resolved.is_empty(), unresolved.is_empty()) {
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
(true, false) => {
full_drop_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
}
// 全闭合 → 原样保留
(false, true) => {}
// 部分闭合 → 保留头,只留 resolved 的 tool_callunresolved 入 orphaned_ids
(false, false) => {
partial_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
partial_keep.insert(i, resolved.into_iter().cloned().collect());
}
// resolved/unresolved 都空不可能calls 非空已保证)
(true, true) => {}
}
}
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
messages
} else {
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
//
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
// deepseek-v4-pro 等端点对此严格校验 → 400。
//
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
//
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
drop_reverse_orphans(after_triplet)
} else {
after_triplet
};
// step 4序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
ensure_sequence_legal(after_reverse_orphan)
}
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
///
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
use std::collections::HashSet;
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
let mut dropped = 0u32;
let mut placeholder_kept = 0u32;
let filtered: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| {
if !matches!(m.role, MessageRole::Tool) {
return true;
}
let Some(id) = m.tool_call_id.as_deref() else {
return true; // 无 id 的 tool_result(异常数据),不在此处处理
};
if head_ids.contains(id) {
return true; // 有对应头 → 保留
}
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
if is_pending_placeholder(&m.content) {
placeholder_kept += 1;
true
} else {
dropped += 1;
false
}
})
.collect();
if dropped > 0 || placeholder_kept > 0 {
tracing::warn!(
dropped_reverse_orphan_tool_results = dropped,
placeholder_kept_without_head = placeholder_kept,
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
);
}
filtered
}
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
///
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
///
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
///
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
///
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
///
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
if !enabled {
return messages;
}
// 收集现有头的 tool_call.id(已配对的)
use std::collections::HashSet;
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() {
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools(
String::new(),
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
);
out.push(head);
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
let mut result = m;
result.tool_call_id = Some(head_id);
out.push(result);
healed += 1;
continue;
}
}
}
out.push(m);
}
if healed > 0 {
tracing::warn!(
healed_orphan_tool_results = healed,
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
);
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
//
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
return ensure_sequence_legal(out);
}
out
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32;
let mut merged = 0u32;
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages {
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1;
continue;
}
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
if let Some(last) = fixed.last_mut() {
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
if !m.content.is_empty() {
if !last.content.is_empty() {
last.content.push('\n');
}
last.content.push_str(&m.content);
}
if matches!(m.role, MessageRole::Assistant) {
if let Some(calls) = m.tool_calls {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
}
}
merged += 1;
continue;
}
}
fixed.push(m);
}
if skipped > 0 || merged > 0 {
tracing::warn!(
skipped,
merged,
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
);
}
fixed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn sanitize_drops_placeholder_id_triplet() {
// 中毒场景assistant 头带 tool_missing_ 占位 id + 无主 tool_result
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("调用工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
assert_eq!(msgs[0].content, "调用工具");
// 持久化全量保留(自愈只改发送视图)
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_drops_unresolved_tool_call_head() {
// 真实 id 但全无 tool_result流式中断/异常)→ 全未闭合档,整头丢弃
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 两个 call 都无 result → 整头丢弃
let (msgs, _trimmed) = mgr.build_for_request(0);
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"全未闭合的 assistant 头应被剔除");
assert!(msgs.is_empty(), "全未闭合应整条删");
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
}
#[test]
fn sanitize_partial_resolve_rewrites_head() {
// 多 tool_call部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
// provider 协议铁律head 的每个 tool_call.id 必须都有 tool_result。
// 「不能整条删」——保住已发生的合法工具交互call_b历史。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("触发工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 只有 call_b 有 result → call_a 未闭合
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
let assistant_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.collect();
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
assert!(
head_calls.iter().all(|c| c.id != "call_a"),
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
);
// tool_result 只剩 call_b 的user 前置保留
let tool_msgs: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
assert!(
msgs.iter()
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
"无关 user 消息不应被误删"
);
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
#[test]
fn sanitize_drops_reverse_orphan_tool_result() {
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
];
let sanitized = sanitize_messages(msgs);
assert!(
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
sanitized
);
}
#[test]
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_1";
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("call_pending_1", placeholder_content),
];
let sanitized = sanitize_messages(msgs);
let kept: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
}
#[test]
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_2";
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
let healed_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.collect();
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
// 占位头 id 应含原 tool_call_id(精确配对)
assert!(
healed_heads[0].id.contains("call_pending_2"),
"补的头 id 应含原 tool_call_id, 实际 {}",
healed_heads[0].id
);
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
let result_id = msgs
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
// 持久化全量保留(view-only)
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
}
#[test]
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_ok", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 无 tool_missing_ 头(正常三元组不需自愈)
let missing_heads = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.count();
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
}
#[test]
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
//
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
//
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_cc";
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
ChatMessage::tool_result("call_pending_cc", placeholder_content),
];
let sanitized = sanitize_messages(msgs.clone());
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
assert!(sanitized.iter().any(|m|
matches!(m.role, MessageRole::Tool)
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
), "占位 tool_result 应保留");
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
// 断言:补了占位头(tool_missing_ 前缀 id)
let missing_heads: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
.collect();
assert_eq!(
missing_heads.len(),
1,
"应补 1 个占位头, 实际 {:?}",
missing_heads
);
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
let mut prev_is_assistant = false;
let mut consecutive = 0u32;
for m in &healed {
let is_assistant = matches!(m.role, MessageRole::Assistant);
if is_assistant && prev_is_assistant {
consecutive += 1;
}
prev_is_assistant = is_assistant;
}
assert_eq!(
consecutive, 0,
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
healed
.iter()
.map(|m| format!("{:?}", m.role))
.collect::<Vec<_>>()
);
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
let assistant_msgs: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(
assistant_msgs.len(),
1,
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
assistant_msgs.len(),
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
let merged = &assistant_msgs[0];
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
let merged_call_ids: Vec<&str> = merged
.tool_calls
.as_ref()
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
.unwrap_or_default();
assert!(
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
&& id.contains("call_pending_cc")),
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
merged_call_ids
);
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
let result_id = healed
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert!(
merged_call_ids.contains(&result_id.as_str()),
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
result_id,
merged_call_ids
);
}
#[test]
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
];
let sanitized = sanitize_messages(msgs);
let kept = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
}
#[test]
fn sanitize_mixed_multi_message_sequence() {
// 场景 4连续多条消息混合——
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
// 预期全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("开场"));
// 全闭合三元组
mgr.push(ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("ok_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
// 中毒占位 id 三元组(全未闭合)→ 整删
mgr.push(ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
// 部分闭合头keep_1 闭合drop_1 未闭合 → 重写为只剩 keep_1
mgr.push(ChatMessage::assistant_with_tools(
"调3",
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
mgr.push(ChatMessage::user("收尾"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
let head_ids: Vec<Vec<String>> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.map(|m| {
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect()
})
.collect();
assert!(
head_ids.iter().all(|ids| {
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
}),
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
);
let tool_ids: Vec<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
// ok_1全闭合保留、keep_1部分闭合重写后保留应在占位/drop_1 不在
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
assert!(
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
);
// 两条 user 消息原样保留
let user_contents: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.as_str())
.collect();
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
}

View File

@@ -341,6 +341,14 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
/// extract_key_info 保留的尾部行数。
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
///
@@ -374,6 +382,54 @@ pub fn should_summarize_tool_result(
///
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(obj) = val.as_object_mut() {
let mut truncated = false;
for (_key, field) in obj.iter_mut() {
// 数组截断
if let Some(arr) = field.as_array() {
if arr.len() > TOOL_RESULT_MAX_ARRAY {
*field = serde_json::Value::Array(
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
);
truncated = true;
}
}
// 字符串字段:先按行数截断,若不足再按字符数截断
if let Some(s) = field.as_str() {
let lines: Vec<&str> = s.lines().collect();
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if lines.len() > kept {
let mut out: Vec<&str> = Vec::new();
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
out.push("... (压缩中间内容) ...");
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
*field = serde_json::Value::String(out.join("\n"));
truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
*field = serde_json::Value::String(format!(
"{}...(截断,原始 {} 字符)...{}",
head, s.chars().count(), tail
));
truncated = true;
}
}
}
if truncated {
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
}
}
// JSON 解析成功但无需截断 → 原样返回
return content.to_string();
}
// 非 JSON 纯文本:按行数截断
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return String::new();
@@ -383,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary {
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。
let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT {
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
return format!(
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
tool_name, char_count, head, tail
);
}
return content.to_string();
}
@@ -877,18 +944,35 @@ mod tests {
}
#[test]
fn extract_key_info_single_line_no_newline_unchanged() {
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
fn extract_key_info_single_line_short_no_newline_unchanged() {
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
let content = "single line no newline";
assert_eq!(extract_key_info(content, "read_file"), content);
}
#[test]
fn extract_key_info_single_huge_line_no_newline_unchanged() {
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
fn extract_key_info_single_huge_line_no_newline_compressed() {
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("已压缩"), "应含压缩标记");
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
assert!(result.contains("(截断)"), "应含截断标记");
}
#[test]
fn extract_key_info_json_huge_string_field_truncated() {
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("_truncated"), "应含 _truncated 标记");
assert!(result.contains("src/main.rs"), "应保留 path 字段");
assert!(result.contains("(截断)"), "应含截断标记");
}
#[test]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,542 @@
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
//!
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
use std::path::{Path, PathBuf};
use std::process::Command;
/// worktree 管理错误
#[derive(Debug)]
pub enum WorktreeError {
/// Git 命令执行失败(stdout/stderr 含错误信息)
GitFailed(String),
/// 工程根目录无 .git(非 Git 工程,应降级串行)
NotGitRepo,
/// worktree 目录已存在(可能上次未清理)
AlreadyExists(PathBuf),
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
}
}
}
impl std::error::Error for WorktreeError {}
/// worktree 创建结果
#[derive(Debug, Clone)]
pub struct WorktreeHandle {
/// worktree 物理路径(SubTask 工具操作的 cwd)
pub path: PathBuf,
/// worktree 对应的分支名(merge 时用)
pub branch: String,
/// 所属 Plan id(清理时按 plan 批量)
pub plan_id: String,
/// SubTask id
pub subtask_id: String,
}
/// 在指定工程根目录下创建 SubTask 专属 worktree。
///
/// - `project_root`: 工程根目录(含 .git)
/// - `plan_id`: 所属 Plan id
/// - `subtask_id`: SubTask id
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
///
/// 返回 WorktreeHandle(path + branch)。
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
/// 分支名:`subtask/{plan_id}/{subtask_id}`
pub fn create_worktree(
project_root: &Path,
plan_id: &str,
subtask_id: &str,
base_branch: &str,
) -> Result<WorktreeHandle, WorktreeError> {
if !project_root.join(".git").exists() {
return Err(WorktreeError::NotGitRepo);
}
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
if wt_path.exists() {
return Err(WorktreeError::AlreadyExists(wt_path));
}
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
// git worktree add <path> -b <branch> <base_branch>
let output = Command::new("git")
.args([
"worktree",
"add",
wt_path.to_str().unwrap_or(""),
"-b",
&branch,
base_branch,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(format!(
"git worktree add 失败: {}",
stderr.trim()
)));
}
Ok(WorktreeHandle {
path: wt_path,
branch,
plan_id: plan_id.to_string(),
subtask_id: subtask_id.to_string(),
})
}
/// 在 worktree 内提交所有改动(git add -A + git commit)。
///
/// - `wt_path`: worktree 物理路径
/// - `message`: commit 消息
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
// git add -A
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
if !add.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git add 失败: {}",
String::from_utf8_lossy(&add.stderr).trim()
)));
}
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
let commit = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
if !commit.status.success() {
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
let stderr = String::from_utf8_lossy(&commit.stderr);
if stderr.contains("user.email") || stderr.contains("user.name") {
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
Command::new("git")
.args(["config", "--local", "user.email", "devflow@ai.local"])
.current_dir(wt_path)
.output()
.ok();
Command::new("git")
.args(["config", "--local", "user.name", "DevFlow Agent"])
.current_dir(wt_path)
.output()
.ok();
let retry = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
if !retry.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git commit 重试仍失败: {}",
String::from_utf8_lossy(&retry.stderr).trim()
)));
}
return Ok(());
}
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
}
Ok(())
}
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
///
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
/// 返回 Err 表示 merge 冲突。
pub fn merge_branch(
plan_wt_path: &Path,
subtask_branch: &str,
) -> Result<MergeResult, WorktreeError> {
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
let base = "HEAD";
let precheck = Command::new("git")
.args(["merge-tree", base, subtask_branch, base])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: precheck_stdout.to_string(),
});
}
// 无冲突,执行实际 merge
let merge = Command::new("git")
.args(["merge", "--no-edit", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
if !merge.status.success() {
// merge 冲突(git merge 返回非0)
let stderr = String::from_utf8_lossy(&merge.stderr);
let stdout = String::from_utf8_lossy(&merge.stdout);
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
let _ = Command::new("git")
.args(["merge", "--abort"])
.current_dir(plan_wt_path)
.output();
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: format!("{}\n{}", stdout, stderr),
});
}
Ok(MergeResult::Clean)
}
/// merge 结果
#[derive(Debug, Clone)]
pub enum MergeResult {
/// 干净合并(无冲突)
Clean,
/// 合并冲突(details 含 conflict 信息)
Conflict { branch: String, details: String },
}
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
// git worktree remove --force <path>
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
handle.path.to_str().unwrap_or(""),
])
.current_dir(project_root)
.output();
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
let _ = Command::new("git")
.args(["branch", "-D", &handle.branch])
.current_dir(project_root)
.output();
Ok(())
}
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
if !plan_wt_dir.exists() {
return Ok(());
}
// git worktree list 获取该 plan 下所有 worktree
let list = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
let list_str = String::from_utf8_lossy(&list.stdout);
for line in list_str.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
let path = PathBuf::from(path_str);
// 仅清理属于本 plan 的 worktree
if path.starts_with(&plan_wt_dir) {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", path_str])
.current_dir(project_root)
.output();
}
}
}
// 删除该 plan 的所有子任务分支
let branches = Command::new("git")
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
let branches_str = String::from_utf8_lossy(&branches.stdout);
for line in branches_str.lines() {
let branch = line.trim();
if !branch.is_empty() {
let _ = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(project_root)
.output();
}
}
// 清理空目录
let _ = std::fs::remove_dir_all(&plan_wt_dir);
Ok(())
}
/// 检测工程是否为 Git 仓库(有 .git 目录)。
pub fn is_git_repo(project_root: &Path) -> bool {
project_root.join(".git").exists()
}
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
///
/// 返回 (plan_worktree_path, plan_branch)。
pub fn create_plan_worktree(
project_root: &Path,
plan_id: &str,
) -> Result<(PathBuf, String), WorktreeError> {
if !is_git_repo(project_root) {
return Err(WorktreeError::NotGitRepo);
}
let base = current_branch(project_root)?;
let plan_branch = format!("plan/{}", plan_id);
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
if plan_wt_path.exists() {
return Err(WorktreeError::AlreadyExists(plan_wt_path));
}
let output = Command::new("git")
.args([
"worktree",
"add",
plan_wt_path.to_str().unwrap_or(""),
"-b",
&plan_branch,
&base,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git worktree add(plan) 失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok((plan_wt_path, plan_branch))
}
#[cfg(test)]
mod tests {
use super::*;
/// 创建临时 Git 仓库 + 初始 commit
fn setup_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
dir
}
#[test]
fn wt_01_create_and_isolate() {
let repo = setup_repo();
let root = repo.path();
let base = current_branch(root).unwrap();
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
assert!(handle.path.exists(), "worktree 目录应存在");
assert_eq!(handle.branch, "subtask/plan-test/A");
// 在 worktree 内写文件
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
// 主目录不应有此文件
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
// 清理
remove_worktree(root, &handle).unwrap();
}
#[test]
fn wt_02_parallel_isolation() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
// A 写 new_a.rs
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
// B 写 new_b.rs
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
// A 的 worktree 不应有 B 的文件
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
// B 的 worktree 不应有 A 的文件
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
remove_plan_worktrees(root, "plan-iso").unwrap();
}
#[test]
fn wt_03_commit_in_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
// 写文件 + commit
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
commit_worktree(&a.path, "Add feature").unwrap();
// 验证分支有新 commit
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(&a.path)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
remove_plan_worktrees(root, "plan-commit").unwrap();
}
#[test]
fn wt_04_merge_no_conflict() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
commit_worktree(&a.path, "A changes").unwrap();
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
commit_worktree(&b.path, "B changes").unwrap();
// merge A → plan
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
// merge B → plan
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
// plan worktree 应同时有 file_a.rs 和 file_b.rs
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
remove_plan_worktrees(root, "plan-merge").unwrap();
}
#[test]
fn wt_05_merge_conflict_same_file() {
let repo = setup_repo();
let root = repo.path();
// 写一个共享文件
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件的同一行
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
// merge A → 无冲突
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
// merge B → 冲突
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(
matches!(result_b, MergeResult::Conflict { .. }),
"同文件同行改应产生冲突"
);
remove_plan_worktrees(root, "plan-conf").unwrap();
}
#[test]
fn wt_06_non_git_returns_error() {
let dir = tempfile::tempdir().unwrap();
// 不 init git
let result = create_plan_worktree(dir.path(), "plan-ng");
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
}
#[test]
fn wt_07_cleanup_removes_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
assert!(a.path.exists());
remove_plan_worktrees(root, "plan-clean").unwrap();
// worktree 目录应被清理
assert!(!a.path.exists(), "worktree 目录应已清理");
// plan wt 目录也应被清理
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
}
}

View File

@@ -944,7 +944,7 @@ mod tests {
/// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(),
tool_type: "function".into(),
function: df_ai_core::types::ToolFunction {
name: name.to_string(),
description: String::new(),

View File

@@ -5,10 +5,12 @@ pub mod anthropic_compat;
pub mod anthropic_helpers;
pub mod context;
pub mod context_helpers;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent;
pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
@@ -28,12 +30,18 @@ pub mod openai_helpers;
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod persona;
pub mod plan_executor;
pub mod provider;
pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
pub mod sse_parser;
use provider::LlmProvider;
use reqwest::Client;

View File

@@ -6,7 +6,6 @@
use std::time::Duration;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use reqwest::Client;
use tracing::{debug, error, warn};
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
for i in 0..messages.len() {
let role = messages[i].role.clone();
if role != "assistant" {
continue;
}
let has_tc = messages[i].tool_calls.is_some();
if !has_tc {
continue;
}
let next_is_tool = i + 1 < messages.len()
&& matches!(messages[i + 1].role.as_str(), "tool");
if !next_is_tool {
messages[i].tool_calls = None;
tracing::warn!(
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
i, role,
);
}
}
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用");
let resp = self
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self
.client
.post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&openai_req)
.send()
.await?;
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
return Err(e.into());
}
Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
if !resp.status().is_success() {
let status = resp.status();
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
let mut last_usage: Option<TokenUsage> = None;
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
match result {
Ok(events) => {
for data in events {
let chunk = apply_openai_sse(&data, &mut last_usage);
chunks.push(Ok(chunk));
}
}
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx))
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
});
}
futures::stream::iter(chunks)
});
Ok(Box::pin(stream))
}

385
crates/df-ai/src/persona.rs Normal file
View File

@@ -0,0 +1,385 @@
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
//!
//! 构成 AI Native 的基础:角色划分是分工的前提。
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
//!
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
use std::collections::HashMap;
/// 内置人设 ID 常量
pub const PERSONA_CODER: &str = "coder";
pub const PERSONA_REVIEWER: &str = "reviewer";
pub const PERSONA_ARCHITECT: &str = "architect";
pub const PERSONA_TESTER: &str = "tester";
pub const PERSONA_ANALYST: &str = "analyst";
/// 人设能力标签枚举
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PersonaCapability {
/// 代码生成与修改
CodeGeneration,
/// Code Review
CodeReview,
/// 架构设计与分析
ArchitectureDesign,
/// 测试编写与执行
Testing,
/// 数据分析与搜索
Analysis,
/// 文件读写
FileOperation,
/// 命令执行
CommandExecution,
/// Git 操作
GitOperation,
/// 知识库操作
KnowledgeOperation,
/// 项目管理
ProjectManagement,
}
impl PersonaCapability {
pub fn label(&self) -> &'static str {
match self {
Self::CodeGeneration => "代码生成",
Self::CodeReview => "代码审查",
Self::ArchitectureDesign => "架构设计",
Self::Testing => "测试",
Self::Analysis => "分析",
Self::FileOperation => "文件操作",
Self::CommandExecution => "命令执行",
Self::GitOperation => "Git 操作",
Self::KnowledgeOperation => "知识库",
Self::ProjectManagement => "项目管理",
}
}
}
/// 人设定义
#[derive(Debug, Clone)]
pub struct AgentPersona {
/// 唯一标识(如 "coder", "reviewer")
pub id: String,
/// 人设名称(如 "程序员", "审查员")
pub name: String,
/// 人设描述(LLM 理解用)
pub description: String,
/// 能力标签集
pub capabilities: Vec<PersonaCapability>,
/// 工具白名单(空=不限,非空=仅允许列出的工具)
pub tool_whitelist: Vec<String>,
/// system prompt 模板(注入 {context} {goals} 等占位符)
pub system_prompt_template: String,
/// 优先级(选人设时冲突排名,0=最高)
pub priority: u8,
}
impl AgentPersona {
/// 检查此人设是否具备某项能力
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
self.capabilities.contains(cap)
}
/// 检查某工具是否在此人设的 whitelist 中
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
}
/// 构建带上下文的 system prompt
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
let goals_text = if goals.is_empty() {
String::new()
} else {
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
};
self.system_prompt_template
.replace("{context}", context)
.replace("{goals}", &goals_text)
}
}
/// 人设注册表 — 管理内置 + 自定义人设
#[derive(Debug, Clone)]
pub struct PersonaRegistry {
personae: HashMap<String, AgentPersona>,
/// 默认人设 id(兜底)
default_id: String,
}
impl PersonaRegistry {
/// 构造默认注册表(含 5 内置人设)
pub fn new() -> Self {
let mut reg = Self {
personae: HashMap::new(),
default_id: PERSONA_CODER.to_string(),
};
reg.register(Self::builtin_coder());
reg.register(Self::builtin_reviewer());
reg.register(Self::builtin_architect());
reg.register(Self::builtin_tester());
reg.register(Self::builtin_analyst());
reg
}
// ── 5 内置人设 ──
fn builtin_coder() -> AgentPersona {
AgentPersona {
id: PERSONA_CODER.to_string(),
name: "程序员".to_string(),
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能不关注大范围架构变更".to_string(),
capabilities: vec![
PersonaCapability::CodeGeneration,
PersonaCapability::FileOperation,
PersonaCapability::CommandExecution,
],
tool_whitelist: vec![], // 不限(全部工具可用)
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
priority: 1,
}
}
fn builtin_reviewer() -> AgentPersona {
AgentPersona {
id: PERSONA_REVIEWER.to_string(),
name: "审查员".to_string(),
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性返回审查意见不直接改代码".to_string(),
capabilities: vec![
PersonaCapability::CodeReview,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"git_diff".into(), "git_log".into(), "git_status".into(),
],
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_architect() -> AgentPersona {
AgentPersona {
id: PERSONA_ARCHITECT.to_string(),
name: "架构师".to_string(),
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
capabilities: vec![
PersonaCapability::ArchitectureDesign,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"write_file".into(), // 写架构文档
],
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
priority: 2,
}
}
fn builtin_tester() -> AgentPersona {
AgentPersona {
id: PERSONA_TESTER.to_string(),
name: "测试员".to_string(),
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
capabilities: vec![
PersonaCapability::Testing,
PersonaCapability::CommandExecution,
PersonaCapability::FileOperation,
],
tool_whitelist: vec![], // 不限
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_analyst() -> AgentPersona {
AgentPersona {
id: PERSONA_ANALYST.to_string(),
name: "分析师".to_string(),
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
capabilities: vec![
PersonaCapability::Analysis,
PersonaCapability::CommandExecution,
PersonaCapability::KnowledgeOperation,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "grep_search".into(),
"run_command".into(), "git_log".into(),
"knowledge_search".into(), "knowledge_inject".into(),
],
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
priority: 4,
}
}
// ── 注册表操作 ──
/// 注册/覆盖人设
pub fn register(&mut self, persona: AgentPersona) {
self.personae.insert(persona.id.clone(), persona);
}
/// 按 id 查人设
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
self.personae.get(id)
}
/// 取默认人设
pub fn default(&self) -> &AgentPersona {
self.personae.get(&self.default_id).expect("默认人设必存在")
}
/// 设置默认人设 id
pub fn set_default(&mut self, id: &str) {
if self.personae.contains_key(id) {
self.default_id = id.to_string();
}
}
/// 按意图推荐人设(启发式规则)
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
let lower = intent.to_lowercase();
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
} else if lower.contains("测试") || lower.contains("test") {
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
} else {
// 默认走 coder(代码生成是最常见场景)
self.default()
}
}
/// 获取全部人设列表
pub fn all(&self) -> Vec<&AgentPersona> {
let mut list: Vec<_> = self.personae.values().collect();
list.sort_by_key(|p| p.priority);
list
}
/// 人设数量
pub fn len(&self) -> usize {
self.personae.len()
}
pub fn is_empty(&self) -> bool {
self.personae.is_empty()
}
}
impl Default for PersonaRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_5_builtin() {
let reg = PersonaRegistry::new();
assert_eq!(reg.len(), 5);
assert!(reg.get(PERSONA_CODER).is_some());
assert!(reg.get(PERSONA_REVIEWER).is_some());
assert!(reg.get(PERSONA_ARCHITECT).is_some());
assert!(reg.get(PERSONA_TESTER).is_some());
assert!(reg.get(PERSONA_ANALYST).is_some());
}
#[test]
fn default_is_coder() {
let reg = PersonaRegistry::new();
assert_eq!(reg.default().id, PERSONA_CODER);
}
#[test]
fn recommend_reviewer() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("review the code changes");
assert_eq!(p.id, PERSONA_REVIEWER);
}
#[test]
fn recommend_architect() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("设计新的模块架构");
assert_eq!(p.id, PERSONA_ARCHITECT);
}
#[test]
fn recommend_tester() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("write tests for this module");
assert_eq!(p.id, PERSONA_TESTER);
}
#[test]
fn recommend_default_to_coder() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("implement a new feature");
assert_eq!(p.id, PERSONA_CODER);
}
#[test]
fn tool_whitelist_filters() {
let reviewer = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec!["read_file".into(), "search_files".into()],
system_prompt_template: "".into(),
priority: 5,
};
assert!(reviewer.is_tool_allowed("read_file"));
assert!(!reviewer.is_tool_allowed("write_file"));
}
#[test]
fn empty_whitelist_allows_all() {
let coder = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "".into(),
priority: 5,
};
assert!(coder.is_tool_allowed("run_command"));
assert!(coder.is_tool_allowed("write_file"));
}
#[test]
fn build_prompt_replaces_placeholders() {
let reg = PersonaRegistry::new();
let coder = reg.get(PERSONA_CODER).unwrap();
let prompt = coder.build_prompt("some context", &["goal1".into()]);
assert!(prompt.contains("程序员"));
assert!(prompt.contains("goal1"));
assert!(prompt.contains("some context"));
}
#[test]
fn register_custom_persona() {
let mut reg = PersonaRegistry::new();
let custom = AgentPersona {
id: "custom".into(),
name: "自定义".into(),
description: "test".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "You are custom".into(),
priority: 0,
};
reg.register(custom);
assert_eq!(reg.len(), 6);
assert!(reg.get("custom").is_some());
}
}

View File

@@ -0,0 +1,197 @@
//! Plan 执行器Phase 2 · DAG 分层调度)
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行上层全部完成才进下一层deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
//!
//! **当前状态**骨架实现PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
//!
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
use std::sync::Arc;
use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
use std::sync::atomic::{AtomicBool, Ordering};
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// 设置 Plan 执行开关。
pub fn set_plan_execution(enabled: bool) {
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
}
/// 读取 Plan 执行开关。
pub fn plan_execution_enabled() -> bool {
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
}
/// 单个 SubTask 的执行结果。
#[derive(Debug, Clone)]
pub struct SubTaskResult {
/// 对应 SubTask id
pub id: String,
/// 执行是否成功
pub success: bool,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
}
/// Plan 执行器:按 DAG 层级调度子任务。
///
/// 泛型参数 `F` 为子任务执行函数:
/// - 输入:`&SubTask`(子任务定义)
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
///
/// 调用方Phase 3 对接 agentic loop 时)传入真实的执行闭包,
/// 每个 SubTask 独立跑一轮 agentic loop共享父上下文快照。
pub struct PlanExecutor;
impl PlanExecutor {
/// 按 Plan DAG 分层执行。
///
/// - 层间串行:上层全部完成才进下一层
/// - 层内并行:同层 SubTask 用 JoinSet 并发
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
/// 若需中断策略,调用方据返回结果自行判断。
///
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
pub async fn execute<F, Fut>(
plan: &Plan,
mut run_subtask: F,
) -> anyhow::Result<Vec<SubTaskResult>>
where
F: FnMut(Arc<SubTask>, String) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
{
let layers = plan
.to_layers()
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
let mut results: Vec<SubTaskResult> = Vec::new();
let mut parent_summary = String::new();
for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!(
layer_idx,
task_count = layer.len(),
"[plan-exec] 执行第 {} 层, {} 个子任务",
layer_idx,
layer.len()
);
// 层内并行JoinSet 收集
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
id: task.id.clone(),
success: true,
output,
},
Err(e) => SubTaskResult {
id: task.id.clone(),
success: false,
output: e.to_string(),
},
};
results.push(sr);
}
// 汇总本层结果作为下层 parent_summary
let layer_summary: Vec<String> = results
.iter()
.filter(|r| layer.iter().any(|t| t.id == r.id))
.map(|r| format!("[{}] {}", r.id, r.output))
.collect();
parent_summary = layer_summary.join("\n");
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planner::{Plan, SubTask};
#[tokio::test]
async fn execute_simple_linear_plan() {
// a → b → c (三层,各一个任务)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[tokio::test]
async fn execute_parallel_layer() {
// a → {b, c} → d (三层,第二层两个并行)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn execute_with_failure_continues() {
// a(fail) → b: a 失败,b 仍执行(容错策略)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
if task.id == "a" {
anyhow::bail!("task-a failed")
}
Ok("ok".to_string())
})
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success); // a 失败
assert!(results[1].success); // b 仍执行
}
}

View File

@@ -15,8 +15,9 @@
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future;
use std::time::{Duration, SystemTime};
use std::time::Duration;
use rand::Rng;
use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
const JITTER_RATIO: f64 = 0.2;
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
///
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±20% jitter。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±50% jitter。
///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter")避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
// ±50% jitter相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms)
}

View File

@@ -0,0 +1,118 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
//! 即报错(且不可恢复):
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
//! - 缺少结束的 \n\n(连接断开常见)
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
//!
//! 本解析器实现:
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
//! - 容错:解析失败时跳过该事件继续,不中断流
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
buffer: Vec<u8>,
}
impl<S> SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
pub fn new(inner: S) -> Self {
Self {
inner,
buffer: Vec::with_capacity(8192),
}
}
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
fn parse_events(&mut self) -> Vec<SseEvent> {
let mut events = Vec::new();
loop {
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
if sep_pos.is_none() {
break;
}
let sep_pos = sep_pos.unwrap();
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
// 去掉末尾的 \n\n
let body_end = event_bytes.len().saturating_sub(2);
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
let data = Self::extract_data_fields(&event_text);
if !data.is_empty() {
events.push(data);
}
}
events
}
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
fn extract_data_fields(event_text: &str) -> String {
let mut data_parts: Vec<&str> = Vec::new();
for line in event_text.lines() {
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
data_parts.push(rest);
}
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
}
data_parts.join("\n")
}
}
impl<S> Stream for SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
type Item = Result<Vec<SseEvent>, String>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
use futures::StreamExt;
loop {
// 先尝试从 buffer 解析完整事件
let events = self.parse_events();
if !events.is_empty() {
return Poll::Ready(Some(Ok(events)));
}
// buffer 不足以解析出完整事件,从 inner 读更多数据
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk);
continue;
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
}
Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
if !self.buffer.is_empty() {
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
self.buffer.clear();
let data = Self::extract_data_fields(&remaining);
if !data.is_empty() {
return Poll::Ready(Some(Ok(vec![data])));
}
}
return Poll::Ready(None);
}
Poll::Pending => return Poll::Pending,
}
}
}
}

View File

@@ -0,0 +1,456 @@
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
//!
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
//!
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
//! 阻断主流程)。
use std::sync::OnceLock;
/// 环境快照 — 系统环境的不可变视图。
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvSnapshot {
/// 操作系统族:"windows" / "macos" / "linux"
pub os: String,
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
pub os_version: String,
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
pub shell: String,
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
pub path_sep: String,
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
pub encoding: String,
/// 工具版本(逐个探测,缺失为 None)
pub tools: ToolVersions,
}
/// 各开发工具的版本信息。
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct ToolVersions {
pub python: Option<String>,
pub node: Option<String>,
pub rust: Option<String>,
pub go: Option<String>,
pub docker: Option<String>,
pub git: Option<String>,
}
impl EnvSnapshot {
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
///
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
pub async fn detect() -> &'static EnvSnapshot {
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
if let Some(snap) = SNAPSHOT.get() {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
let snap = match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
).await {
Ok(Ok(s)) => s,
Ok(Err(join_err)) => {
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
EnvSnapshot::fallback()
}
Err(_elapsed) => {
eprintln!(
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
);
EnvSnapshot::fallback()
}
};
// 多任务竞态:均等价,以先到者为准。
let _ = SNAPSHOT.set(snap);
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
}
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
fn do_detect() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: detect_os_version(),
shell: detect_shell(),
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: detect_encoding(),
tools: ToolVersions {
python: probe_version("python", &["--version"]),
node: probe_version("node", &["--version"]),
rust: probe_version("rustc", &["--version"]),
go: probe_version("go", &["version"]),
docker: probe_version("docker", &["--version"]),
git: probe_version("git", &["--version"]),
},
}
}
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: String::new(),
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: "utf-8".into(),
tools: ToolVersions::default(),
}
}
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
///
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = vec![
"## 系统环境".to_string(),
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
format!("- Shell: {}", self.shell),
format!("- 路径分隔符: {}", self.path_sep),
format!("- 终端编码: {}", self.encoding),
];
if let Some(v) = &self.tools.python {
lines.push(format!("- Python: {}", v));
}
if let Some(v) = &self.tools.node {
lines.push(format!("- Node: {}", v));
}
if let Some(v) = &self.tools.rust {
lines.push(format!("- Rust: {}", v));
}
if let Some(v) = &self.tools.go {
lines.push(format!("- Go: {}", v));
}
if let Some(v) = &self.tools.docker {
lines.push(format!("- Docker: {}", v));
}
if let Some(v) = &self.tools.git {
lines.push(format!("- Git: {}", v));
}
lines.push(String::new());
lines.push("注意: 请生成本平台兼容的命令。".to_string());
lines.join("\n")
}
}
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
fn detect_os_version() -> String {
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
#[cfg(windows)]
{
if let Some(v) = read_windows_version() {
return v;
}
return String::new();
}
// macOS:sw_vers -productVersion 输出如 "12.5"
#[cfg(target_os = "macos")]
{
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
return String::new();
}
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
return rest.trim_matches('"').to_string();
}
}
}
return String::new();
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
String::new()
}
}
#[cfg(windows)]
fn read_windows_version() -> Option<String> {
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
let out = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
"DisplayVersion",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
// 输出形如: " DisplayVersion REG_SZ 22H2"
for line in text.lines() {
let trimmed = line.trim();
if let Some(idx) = trimmed.find("REG_SZ") {
let val = trimmed[idx + "REG_SZ".len()..].trim();
if !val.is_empty() {
return Some(format!("Windows {}", val));
}
}
}
None
}
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
fn detect_shell() -> String {
#[cfg(windows)]
{
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
return "pwsh".to_string();
}
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
return "powershell".to_string();
}
return "cmd".to_string();
}
#[cfg(not(windows))]
{
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
if let Ok(sh) = std::env::var("SHELL") {
// 取 basename(/bin/zsh → zsh)
let name = sh.rsplit('/').next().unwrap_or(&sh);
if !name.is_empty() {
return name.to_string();
}
}
"sh".to_string()
}
}
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
fn detect_encoding() -> String {
#[cfg(windows)]
{
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
if let Ok(out) = std::process::Command::new("chcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000)
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
if let Some(code) = extract_codepage(&text) {
return match code.as_str() {
"65001" => "utf-8".to_string(),
"936" => "gbk".to_string(),
"950" => "big5".to_string(),
other => format!("cp{}", other),
};
}
}
return "utf-8".to_string();
}
#[cfg(not(windows))]
{
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
if let Ok(lang) = std::env::var("LANG") {
if lang.to_ascii_uppercase().contains("UTF-8") {
return "utf-8".to_string();
}
}
"utf-8".to_string()
}
}
#[cfg(windows)]
fn extract_codepage(text: &str) -> Option<String> {
// 提取末尾数字("...936" / "...: 65001")。
let mut num = String::new();
for c in text.chars().rev() {
if c.is_ascii_digit() {
num.insert(0, c);
} else if !num.is_empty() {
break;
}
}
if num.is_empty() {
None
} else {
Some(num)
}
}
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next()?;
Some(extract_version_token(first_line))
}
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
///
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
fn extract_version_token(line: &str) -> String {
for token in line.split_whitespace() {
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
// "2.41.0.windows.1",实际应止于 "2.41.0")。
let bytes = token.as_bytes();
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
Some(i) => i,
None => continue,
};
let mut head = String::new();
loop {
// 收数字段
let seg_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
head.push_str(&token[seg_start..i]);
// 点号:仅当下一字符为数字时才续,否则收尾
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
head.push('.');
i += 1;
continue;
}
break;
}
if head.contains('.') {
return head;
}
}
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
line.trim().to_string()
}
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::Stdio;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_python_style() {
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
}
#[test]
fn extract_version_node_style() {
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
}
#[test]
fn extract_version_git_style() {
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
}
#[test]
fn extract_version_go_style() {
assert_eq!(
extract_version_token("go version go1.21.0 windows/amd64"),
"1.21.0"
);
}
#[test]
fn extract_version_no_match_returns_line() {
assert_eq!(extract_version_token("no version here"), "no version here");
}
#[cfg(windows)]
#[test]
fn extract_codepage_parsing() {
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
assert_eq!(
extract_codepage("Active code page: 65001"),
Some("65001".into())
);
assert_eq!(extract_codepage("no digits here"), None);
}
#[tokio::test]
async fn detect_returns_cached_static_ref() {
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
let a = EnvSnapshot::detect().await as *const _;
let b = EnvSnapshot::detect().await as *const _;
assert_eq!(a, b, "detect() 应返回同一静态引用");
}
#[test]
fn to_prompt_contains_os_and_shell() {
let snap = EnvSnapshot {
os: "test_os".into(),
os_version: "v1".into(),
shell: "test_shell".into(),
path_sep: "/".into(),
encoding: "utf-8".into(),
tools: ToolVersions {
python: Some("3.11".into()),
node: None,
rust: None,
go: None,
docker: None,
git: None,
},
};
let prompt = snap.to_prompt();
assert!(prompt.contains("test_os"));
assert!(prompt.contains("test_shell"));
assert!(prompt.contains("Python: 3.11"));
assert!(!prompt.contains("Node"));
assert!(prompt.contains("请生成本平台兼容的命令"));
}
}

View File

@@ -1,3 +1,6 @@
//! df-execute: 执行运行时 — Shell
//! df-execute: 执行运行时 — Shell + 环境感知
pub mod env_snapshot;
pub mod shell;
pub use env_snapshot::EnvSnapshot;

View File

@@ -37,22 +37,36 @@ impl Default for ShellType {
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
} else {
ShellType::Sh
}
}
}
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
fn probe_pwsh_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
CACHE.get().copied().unwrap_or(false)
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
fn probe_pwsh() -> bool {
///
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
async fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
if let Some(cached) = CACHE.get() {
return *cached;
}
let result = tokio::task::spawn_blocking(|| {
let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
}
cmd.status().map(|s| s.success()).unwrap_or(false)
})
.await
.unwrap_or(false);
// 多任务竞态时以先到者为准,均等价
let _ = CACHE.set(result);
result
}
/// Shell 命令执行请求
@@ -88,6 +107,10 @@ pub struct ShellRequest {
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type {
ShellType::PowerShell => {

View File

@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::new_id;
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
use futures::future::BoxFuture;
use serde_json::{json, Value};
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active");
let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name);
Box::pin(async move {
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord {
id: new_id(),
name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
};
let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active");
let status = arg_str_or(&args, "status", "planning");
medium_audit("update_project", &id);
Box::pin(async move {
let repo = ProjectRepo::new(&db);
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(e) => return err_str(e),
};
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord {
id: id.clone(),
name,
@@ -322,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move {
let repo = ProjectRepo::new(&db);
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal {
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
}
let norm = normalize_path(&path);
// 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
@@ -330,8 +338,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
conflict.name, conflict.id
));
}
// 仅更新 path 字段(用 update_field,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}"));
}
let updated = repo.get_by_id(&id).await.ok().flatten();
@@ -355,7 +363,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
list.retain(|t| t.project_id == *pid);
}
if let Some(st) = &status_filter {
list.retain(|t| t.status == *st);
list.retain(|t| t.status.as_str() == st.as_str());
}
json_ok(json!({ "tasks": list, "count": list.len() }))
}
@@ -384,7 +392,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id,
title,
description,
status: "todo".to_owned(),
status: TaskStatus::Todo,
priority,
branch_name: None,
assignee: None,
@@ -529,7 +537,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(),
title,
description,
status: "draft".to_owned(),
status: IdeaStatus::Draft,
priority,
score: None,
tags: None,

View File

@@ -15,3 +15,4 @@ tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -273,6 +273,7 @@ mod tests {
use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json;
// ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: "planning".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(),
title: "t1".to_string(),
description: "实现登录接口".to_string(),
status: "testing".to_string(),
status: TaskStatus::Testing,
priority: 2,
branch_name: None,
assignee: None,

View File

@@ -0,0 +1,419 @@
//! Docker 节点 — 在 Docker 容器内执行命令
//!
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
//!
//! 执行流程:
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
//! 时退出码语义混淆)。
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Docker 节点
pub struct DockerNode;
/// 卷挂载配置项(volumes 数组元素)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VolumeMount {
pub host: String,
pub container: String,
}
/// 从 NodeContext.config 解析出的 DockerNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
#[derive(Debug, Clone)]
pub struct DockerParams {
/// Docker 镜像名(必填,如 "rust:latest")
pub image: String,
/// 容器内执行命令(必填)
pub command: String,
/// 容器内工作目录(默认 "/workspace")
pub working_dir: String,
/// 超时秒数(默认 300)
pub timeout_secs: u64,
/// 卷挂载列表(可选)
pub volumes: Vec<VolumeMount>,
/// 环境变量(可选)
pub env: std::collections::HashMap<String, String>,
}
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
let image = config
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
.to_string();
let command = config
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
.to_string();
let working_dir = config
.get("working_dir")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "/workspace".to_string());
let timeout_secs = config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(300);
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
let volumes = config
.get("volumes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let host = item.get("host")?.as_str()?.to_string();
let container = item.get("container")?.as_str()?.to_string();
Some(VolumeMount { host, container })
})
.collect()
})
.unwrap_or_default();
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
let env = config
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Ok(DockerParams {
image,
command,
working_dir,
timeout_secs,
volumes,
env,
})
}
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
async fn check_docker_available() -> anyhow::Result<()> {
let request = df_execute::shell::ShellRequest {
command: "docker --version".to_string(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: Some(15),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await;
match result {
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
_ => anyhow::bail!("Docker 未安装或未运行"),
}
}
/// 构建 docker run 命令字符串。
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
fn build_command(params: &DockerParams) -> String {
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
for v in &params.volumes {
parts.push(format!(
"-v {}:{}",
shell_quote(&v.host),
shell_quote(&v.container)
));
}
for (k, val) in &params.env {
parts.push(format!("-e {}={}", k, shell_quote(val)));
}
parts.push(format!("-w {}", shell_quote(&params.working_dir)));
parts.push(shell_quote(&params.image));
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
// 与脚本节点一致由 shell 解释器解析。
parts.push(params.command.clone());
parts.join(" ")
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
fn shell_quote(s: &str) -> String {
if s
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
{
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
#[async_trait]
impl Node for DockerNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
if let Err(e) = check_docker_available().await {
anyhow::bail!(e.to_string());
}
let params = parse_params(&ctx.config)?;
let command = build_command(&params);
tracing::info!(
image = %params.image,
working_dir = %params.working_dir,
timeout_secs = params.timeout_secs,
"DockerNode 构建命令: {}",
command
);
let request = df_execute::shell::ShellRequest {
command,
// 宿主机工作目录对 docker run 无意义,置 None。
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: Some(params.timeout_secs),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await?;
let exit_code = result.exit_code.unwrap_or(-1);
tracing::info!(
exit_code,
duration_ms = result.duration_ms,
"DockerNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"image": params.image,
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": exit_code,
"duration_ms": result.duration_ms,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
"command": { "type": "string", "description": "容器内执行命令" },
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
"volumes": {
"type": "array",
"description": "卷挂载 [{host, container}]",
"items": {
"type": "object",
"properties": {
"host": { "type": "string" },
"container": { "type": "string" }
}
}
},
"env": { "type": "object", "description": "环境变量键值对" }
},
"required": ["image", "command"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string" },
"stdout": { "type": "string" },
"stderr": { "type": "string" },
"exit_code": { "type": "integer" },
"duration_ms": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"docker"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── parse_params: 必填缺失 ──
#[test]
fn params_missing_image_errors() {
let err = parse_params(&json!({ "command": "ls" }))
.unwrap_err()
.to_string();
assert!(err.contains("image"), "实际: {}", err);
}
#[test]
fn params_missing_command_errors() {
let err = parse_params(&json!({ "image": "rust:latest" }))
.unwrap_err()
.to_string();
assert!(err.contains("command"), "实际: {}", err);
}
// ── parse_params: 默认值 ──
#[test]
fn params_minimal_uses_defaults() {
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
assert_eq!(p.image, "alpine");
assert_eq!(p.command, "echo hi");
assert_eq!(p.working_dir, "/workspace");
assert_eq!(p.timeout_secs, 300);
assert!(p.volumes.is_empty());
assert!(p.env.is_empty());
}
// ── parse_params: 完整字段 ──
#[test]
fn params_full_fields() {
let p = parse_params(&json!({
"image": "rust:latest",
"command": "cargo test",
"working_dir": "/app",
"timeout_secs": 120
}))
.unwrap();
assert_eq!(p.image, "rust:latest");
assert_eq!(p.command, "cargo test");
assert_eq!(p.working_dir, "/app");
assert_eq!(p.timeout_secs, 120);
}
// ── parse_params: volumes 解析 ──
#[test]
fn params_volumes_parsed() {
let p = parse_params(&json!({
"image": "node",
"command": "npm ci",
"volumes": [
{ "host": "/host/a", "container": "/c/a" },
{ "host": "/host/b", "container": "/c/b" }
]
}))
.unwrap();
assert_eq!(p.volumes.len(), 2);
assert_eq!(
p.volumes[0],
VolumeMount {
host: "/host/a".into(),
container: "/c/a".into()
}
);
assert_eq!(p.volumes[1].container, "/c/b");
}
#[test]
fn params_volumes_skips_malformed_items() {
// 缺 container / 非 object 项应被跳过,不整体失败。
let p = parse_params(&json!({
"image": "node",
"command": "ls",
"volumes": [
{ "host": "/ok", "container": "/ok" },
{ "host": "/no-container" },
"not-an-object",
{ "container": "/no-host" }
]
}))
.unwrap();
assert_eq!(p.volumes.len(), 1);
assert_eq!(p.volumes[0].host, "/ok");
}
// ── parse_params: env 解析 ──
#[test]
fn params_env_parsed() {
let p = parse_params(&json!({
"image": "python",
"command": "pytest",
"env": {
"FOO": "bar",
"DEBUG": "1"
}
}))
.unwrap();
assert_eq!(p.env.len(), 2);
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
}
#[test]
fn params_env_empty_when_non_object() {
// env 非 object(误传字符串)时回退空 map,不报错。
let p = parse_params(&json!({
"image": "python",
"command": "ls",
"env": "should-be-object"
}))
.unwrap();
assert!(p.env.is_empty());
}
// ── build_command ──
#[test]
fn command_minimal_shape() {
let p = parse_params(&json!({
"image": "alpine",
"command": "echo hello"
}))
.unwrap();
let cmd = build_command(&p);
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
}
#[test]
fn command_includes_volumes_and_env() {
let p = parse_params(&json!({
"image": "rust:latest",
"command": "cargo build",
"working_dir": "/app",
"volumes": [
{ "host": "/host/src", "container": "/app" }
],
"env": { "CARGO_HOME": "/cargo" }
}))
.unwrap();
let cmd = build_command(&p);
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
assert_eq!(shell_quote("abc"), "abc");
}
#[test]
fn shell_quote_wraps_spaces() {
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
}
}

View File

@@ -0,0 +1,426 @@
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
//!
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
/// 支持的 Git 动作
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitAction {
Branch,
Checkout,
Commit,
Merge,
Push,
Status,
Log,
}
impl GitAction {
/// 从 config.action 字符串解析动作,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"branch" => Ok(GitAction::Branch),
"checkout" => Ok(GitAction::Checkout),
"commit" => Ok(GitAction::Commit),
"merge" => Ok(GitAction::Merge),
"push" => Ok(GitAction::Push),
"status" => Ok(GitAction::Status),
"log" => Ok(GitAction::Log),
other => anyhow::bail!(
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
GitAction::Branch => "branch",
GitAction::Checkout => "checkout",
GitAction::Commit => "commit",
GitAction::Merge => "merge",
GitAction::Push => "push",
GitAction::Status => "status",
GitAction::Log => "log",
}
}
}
/// 从 NodeContext.config 解析出的 GitNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
#[derive(Debug, Clone)]
pub struct GitParams {
pub action: GitAction,
/// 目标分支(branch/checkout/merge)
pub branch_name: Option<String>,
/// commit 信息(commit)
pub message: Option<String>,
/// 仓库路径(None=当前目录)
pub working_dir: Option<String>,
}
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
let action = config
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
let action = GitAction::parse(action)?;
let branch_name = config
.get("branch_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message = config
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let working_dir = config
.get("working_dir")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(GitParams {
action,
branch_name,
message,
working_dir,
})
}
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
fn build_command(params: &GitParams) -> anyhow::Result<String> {
match params.action {
GitAction::Branch => {
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
match &params.branch_name {
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
None => Ok("git branch".to_string()),
}
}
GitAction::Checkout => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
Ok(format!("git checkout {}", shell_quote(name)))
}
GitAction::Commit => {
let msg = params
.message
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
Ok(format!("git commit -m {}", shell_quote(msg)))
}
GitAction::Merge => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
Ok(format!("git merge {}", shell_quote(name)))
}
GitAction::Push => Ok("git push".to_string()),
GitAction::Status => Ok("git status".to_string()),
GitAction::Log => Ok("git log --oneline -20".to_string()),
}
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
fn shell_quote(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
#[async_trait]
impl Node for GitNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
let command = build_command(&params)?;
tracing::info!(
action = params.action.as_str(),
working_dir = ?params.working_dir,
"GitNode 构建命令: {}",
command
);
let request = df_execute::shell::ShellRequest {
command,
working_dir: params.working_dir.clone(),
env: std::collections::HashMap::new(),
timeout_secs: Some(60),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await?;
let exit_code = result.exit_code.unwrap_or(-1);
if exit_code != 0 {
anyhow::bail!(
"GitNode 执行失败 (exit_code={}): {}",
exit_code,
result.stderr.trim()
);
}
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
let parsed = parse_action_output(&params.action, &result.stdout);
tracing::info!(
action = params.action.as_str(),
exit_code,
duration_ms = result.duration_ms,
"GitNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"action": params.action.as_str(),
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": exit_code,
"duration_ms": result.duration_ms,
"parsed": parsed,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
},
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
"message": { "type": "string", "description": "commit 信息(commit)" },
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"stdout": { "type": "string" },
"stderr": { "type": "string" },
"exit_code": { "type": "integer" },
"duration_ms": { "type": "integer" },
"parsed": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}
/// 按 action 解析 stdout 成结构化字段。
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
/// - log:每行一条 `hash subject`
/// - commit:首段 hash
/// - 其余动作无强结构,返回空对象
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
match action {
GitAction::Branch => {
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
let current = stdout
.lines()
.find(|l| l.starts_with('*'))
.map(|l| l.trim_start_matches("* ").trim().to_string());
serde_json::json!({ "branches": branches, "current": current })
}
GitAction::Log => {
let entries: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|l| {
let l = l.trim();
let mut parts = l.splitn(2, ' ');
let hash = parts.next()?.to_string();
let subject = parts.next().unwrap_or("").to_string();
Some(serde_json::json!({ "hash": hash, "subject": subject }))
})
.collect();
serde_json::json!({ "entries": entries })
}
GitAction::Commit => {
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
let hash = stdout
.split_whitespace()
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
.unwrap_or("")
.to_string();
serde_json::json!({ "commit_hash": hash })
}
_ => serde_json::json!({}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── GitAction::parse ──
#[test]
fn parse_all_valid_actions() {
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
assert_eq!(a.as_str(), raw);
}
}
#[test]
fn parse_invalid_action_errors() {
let err = GitAction::parse("rebase").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
assert!(err.contains("rebase"));
}
#[test]
fn parse_empty_action_errors() {
let err = GitAction::parse("").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
}
// ── parse_params(config) ──
#[test]
fn params_missing_action_errors() {
let err = parse_params(&json!({ "branch_name": "main" }))
.unwrap_err()
.to_string();
assert!(err.contains("action"), "实际: {}", err);
}
#[test]
fn params_minimal_valid() {
let p = parse_params(&json!({ "action": "status" })).unwrap();
assert_eq!(p.action, GitAction::Status);
assert_eq!(p.branch_name, None);
assert_eq!(p.message, None);
assert_eq!(p.working_dir, None);
}
#[test]
fn params_full_fields() {
let p = parse_params(&json!({
"action": "commit",
"branch_name": "feature/x",
"message": "fix: 修复",
"working_dir": "/repo"
}))
.unwrap();
assert_eq!(p.action, GitAction::Commit);
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
}
// ── build_command ──
#[test]
fn command_branch_list_when_no_name() {
let p = parse_params(&json!({ "action": "branch" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch");
}
#[test]
fn command_branch_create_when_name() {
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch feat");
}
#[test]
fn command_checkout_requires_name() {
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("branch_name"), "实际: {}", err);
}
#[test]
fn command_commit_requires_message() {
let p = parse_params(&json!({ "action": "commit" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("message"), "实际: {}", err);
}
#[test]
fn command_commit_quotes_message_with_space() {
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
let cmd = build_command(&p).unwrap();
assert_eq!(cmd, "git commit -m \"fix bug a\"");
}
#[test]
fn command_merge_requires_name() {
let p = parse_params(&json!({ "action": "merge" })).unwrap();
assert!(build_command(&p).is_err());
}
#[test]
fn command_push_status_log_fixed() {
for (action, expected) in [
("push", "git push"),
("status", "git status"),
("log", "git log --oneline -20"),
] {
let p = parse_params(&json!({ "action": action })).unwrap();
assert_eq!(build_command(&p).unwrap(), expected);
}
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
assert_eq!(shell_quote("feat"), "feat");
}
#[test]
fn shell_quote_escapes_embedded_double_quote() {
let q = shell_quote("a\"b");
assert_eq!(q, "\"a\\\"b\"");
}
// ── parse_action_output ──
#[test]
fn parse_branch_output() {
let out = parse_action_output(
&GitAction::Branch,
"* main\n develop\n feature/x\n",
);
assert_eq!(out["current"], "main");
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
}
#[test]
fn parse_log_output() {
let out = parse_action_output(
&GitAction::Log,
"abc1234 fix bug\n deadbeef add feature\n",
);
let entries = out["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["hash"], "abc1234");
assert_eq!(entries[1]["subject"], "add feature");
}
}

View File

@@ -0,0 +1,319 @@
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
//!
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
//! 输出 status_code + body + (可选)响应头子集。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
/// 支持的 HTTP 方法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
}
impl HttpMethod {
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
None | Some("GET") => Ok(HttpMethod::Get),
Some("POST") => Ok(HttpMethod::Post),
Some("PUT") => Ok(HttpMethod::Put),
Some("DELETE") => Ok(HttpMethod::Delete),
Some(other) => anyhow::bail!(
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
}
}
}
/// 从 NodeContext.config 解析出的 HttpNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
#[derive(Debug, Clone)]
pub struct HttpParams {
pub method: HttpMethod,
pub url: String,
/// 请求头(JSON 对象 → Vec<(name, value)>)
pub headers: Vec<(String, String)>,
/// 请求体(可选)
pub body: Option<String>,
/// 超时秒数(缺省 30)
pub timeout_secs: u64,
}
/// 默认超时
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
let url = config
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
if url.trim().is_empty() {
anyhow::bail!("HttpNode url 不能为空");
}
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
let mut headers = Vec::new();
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
for (k, v) in obj {
match v.as_str() {
Some(s) => headers.push((k.clone(), s.to_string())),
None => {
tracing::warn!(
header = %k,
"HttpNode headers 中 header 值非字符串,忽略"
);
}
}
}
}
let body = config
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let timeout_secs = config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
Ok(HttpParams {
method,
url: url.to_string(),
headers,
body,
timeout_secs,
})
}
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
timeout_secs = params.timeout_secs,
"HttpNode 发起请求"
);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(params.timeout_secs))
.build()?;
let mut req = match params.method {
HttpMethod::Get => client.get(&params.url),
HttpMethod::Post => client.post(&params.url),
HttpMethod::Put => client.put(&params.url),
HttpMethod::Delete => client.delete(&params.url),
};
for (name, value) in &params.headers {
req = req.header(name, value);
}
if let Some(body) = &params.body {
req = req.body(body.clone());
}
let response = req.send().await?;
let status_code = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body_text = response.text().await?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
status_code,
body_len = body_text.len(),
"HttpNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"method": params.method.as_str(),
"url": params.url,
"status_code": status_code,
"body": body_text,
"content_type": content_type,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "GET"
},
"url": { "type": "string", "description": "请求 URL" },
"headers": {
"type": "object",
"description": "请求头 JSON 对象",
"additionalProperties": { "type": "string" }
},
"body": { "type": "string", "description": "请求体(可选)" },
"timeout_secs": { "type": "integer", "default": 30 }
},
"required": ["url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string" },
"url": { "type": "string" },
"status_code": { "type": "integer" },
"body": { "type": "string" },
"content_type": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── HttpMethod::parse ──
#[test]
fn method_defaults_to_get_when_absent() {
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
}
#[test]
fn method_case_insensitive() {
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
}
#[test]
fn method_invalid_errors() {
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
assert!(err.contains("PATCH"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_url_errors() {
let err = parse_params(&json!({ "method": "GET" }))
.unwrap_err()
.to_string();
assert!(err.contains("url"), "实际: {}", err);
}
#[test]
fn params_empty_url_errors() {
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
assert!(err.contains(""), "实际: {}", err);
}
#[test]
fn params_defaults_method_and_timeout() {
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
assert_eq!(p.method, HttpMethod::Get);
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
assert!(p.headers.is_empty());
assert_eq!(p.body, None);
}
#[test]
fn params_custom_timeout() {
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
assert_eq!(p.timeout_secs, 5);
}
#[test]
fn params_parses_headers_object() {
let p = parse_params(&json!({
"url": "https://x",
"headers": {
"Authorization": "Bearer abc",
"X-Trace-Id": "123"
}
}))
.unwrap();
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
let map: std::collections::HashMap<&str, &str> =
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
}
#[test]
fn params_non_string_header_value_skipped() {
// 非字符串值不应炸,应被跳过
let p = parse_params(&json!({
"url": "https://x",
"headers": { "X-Num": 123, "X-Ok": "yes" }
}))
.unwrap();
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
assert!(names.contains(&"X-Ok"));
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
}
#[test]
fn params_parses_body() {
let p = parse_params(&json!({
"url": "https://x",
"method": "POST",
"body": "{\"k\":1}"
}))
.unwrap();
assert_eq!(p.method, HttpMethod::Post);
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
}
#[test]
fn params_invalid_method_propagates() {
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
.unwrap_err()
.to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
}
}

View File

@@ -218,7 +218,7 @@ mod tests {
node_id: node_id.to_string(),
inputs: HashMap::new(),
config,
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
event_bus: event_bus.clone(),
node_status: StateMachine::new(),
}
@@ -235,7 +235,7 @@ mod tests {
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
node_id: node_id.to_string(),
decision: decision.to_string(),
decisions: vec![],
@@ -254,7 +254,7 @@ mod tests {
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
node_id: node_id.to_string(),
decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(),
@@ -477,8 +477,8 @@ mod tests {
dag.add_node("b".to_string(), Box::new(HumanNode));
dag.add_edge("a".to_string(), "b".to_string());
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let sm = executor.state_machine(); // 共享状态机spawn 后仍可读
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move {
executor
@@ -537,7 +537,7 @@ mod tests {
let mut dag = Dag::new();
dag.add_node("h".to_string(), Box::new(HumanNode));
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine();
let run_handle = tokio::spawn(async move {

View File

@@ -1,10 +1,15 @@
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
pub mod ai_node;
pub mod ai_self_review_node;
mod ai_node_helpers;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod human_node;
mod human_node_helpers;
pub mod notify_node;
pub mod subflow_node;
pub mod script_node;
pub mod task_advance_node;
pub mod task_state_machine;

View File

@@ -0,0 +1,292 @@
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
//!
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
//! - type=webhook:POST JSON {title, message} 到 webhook_url
//!
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
/// 通知类型
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifyType {
Desktop,
Webhook,
}
impl NotifyType {
/// 从 config.type 字符串解析,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"desktop" => Ok(NotifyType::Desktop),
"webhook" => Ok(NotifyType::Webhook),
other => anyhow::bail!(
"NotifyNode 非法 type: {}(合法值: desktop|webhook",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
NotifyType::Desktop => "desktop",
NotifyType::Webhook => "webhook",
}
}
}
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
#[derive(Debug, Clone)]
pub struct NotifyParams {
pub notify_type: NotifyType,
pub title: String,
pub message: String,
/// webhook 类型必填;desktop 类型忽略
pub webhook_url: Option<String>,
}
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
let raw_type = config
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
let notify_type = NotifyType::parse(raw_type)?;
let title = config
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("通知")
.to_string();
let message = config
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let webhook_url = config
.get("webhook_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// webhook 类型必须有 webhook_url
if notify_type == NotifyType::Webhook {
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
}
}
Ok(NotifyParams {
notify_type,
title,
message,
webhook_url,
})
}
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
match params.notify_type {
NotifyType::Desktop => {
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
tracing::info!(
title = %params.title,
message = %params.message,
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
);
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"delivered": true,
})))
}
NotifyType::Webhook => {
// webhook_url 在 parse_params 已校验非空
let url = params.webhook_url.as_deref().unwrap();
tracing::info!(
title = %params.title,
url = %url,
"NotifyNode webhook 推送"
);
let payload = serde_json::json!({
"title": params.title,
"message": params.message,
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
let (status_code, success, error) = match client
.post(url)
.json(&payload)
.send()
.await
{
Ok(resp) => {
let code = resp.status().as_u16();
(Some(code), code >= 200 && code < 300, None)
}
Err(e) => (None, false, Some(e.to_string())),
};
if !success {
tracing::warn!(
url = %url,
status_code = ?status_code,
error = ?error,
"NotifyNode webhook 发送失败(不阻断工作流)"
);
}
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"webhook_url": url,
"status_code": status_code,
"success": success,
"error": error,
})))
}
}
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["desktop", "webhook"]
},
"title": { "type": "string", "description": "通知标题" },
"message": { "type": "string", "description": "通知正文" },
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
},
"required": ["type"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"type": { "type": "string" },
"title": { "type": "string" },
"message": { "type": "string" },
"delivered": { "type": "boolean" },
"webhook_url": { "type": "string" },
"status_code": { "type": "integer" },
"success": { "type": "boolean" },
"error": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── NotifyType::parse ──
#[test]
fn type_parses_desktop_and_webhook() {
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
}
#[test]
fn type_invalid_errors() {
let err = NotifyType::parse("email").unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
assert!(err.contains("email"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_type_errors() {
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
assert!(err.contains("type"), "实际: {}", err);
}
#[test]
fn params_invalid_type_errors() {
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
}
#[test]
fn params_desktop_defaults_title_and_message() {
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
assert_eq!(p.notify_type, NotifyType::Desktop);
assert_eq!(p.title, "通知"); // 默认标题
assert_eq!(p.message, ""); // 默认空正文
assert_eq!(p.webhook_url, None); // desktop 不需要
}
#[test]
fn params_desktop_with_custom_fields() {
let p = parse_params(&json!({
"type": "desktop",
"title": "构建完成",
"message": "已发布 v1.0"
}))
.unwrap();
assert_eq!(p.title, "构建完成");
assert_eq!(p.message, "已发布 v1.0");
}
#[test]
fn params_webhook_requires_url() {
let err = parse_params(&json!({ "type": "webhook" }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_empty_url_errors() {
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_valid() {
let p = parse_params(&json!({
"type": "webhook",
"title": "告警",
"message": "CPU > 90%",
"webhook_url": "https://hooks.example.com/x"
}))
.unwrap();
assert_eq!(p.notify_type, NotifyType::Webhook);
assert_eq!(p.title, "告警");
assert_eq!(p.message, "CPU > 90%");
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
}
}

View File

@@ -6,6 +6,23 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 脚本节点
pub struct ScriptNode;
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
use std::sync::OnceLock;
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
static RUNTIME_SCRIPT_WHITELIST: OnceLock<String> = OnceLock::new();
/// 运行时黑名单配置(前端设置页写入,优先于环境变量)
static RUNTIME_SCRIPT_BLACKLIST: OnceLock<String> = OnceLock::new();
/// 注入运行时脚本安全配置(由 Tauri IPC 调用,前端设置页保存后触发)。
/// 空字符串表示「未配置」,回退到环境变量 / 默认行为。
pub fn set_script_safety_config(whitelist: &str, blacklist: &str) {
let wl = whitelist.trim();
let bl = blacklist.trim();
if !wl.is_empty() { let _ = RUNTIME_SCRIPT_WHITELIST.set(wl.to_string()); }
if !bl.is_empty() { let _ = RUNTIME_SCRIPT_BLACKLIST.set(bl.to_string()); }
}
#[async_trait]
impl Node for ScriptNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
shell_type: Default::default(),
};
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
// - 黑名单匹配时:直接拒绝执行
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
if let Some(denied) = check_command_policy(&cmd_name) {
tracing::warn!(
command = %command,
reason = %denied,
"ScriptNode 命令被策略拒绝"
);
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
}
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
for &kw in &dangerous_keywords {
if command.contains(kw) {
tracing::warn!(
keyword = %kw,
command = %command,
"ScriptNode 即将执行包含危险关键词的命令"
);
}
}
tracing::info!("ScriptNode 执行命令: {}", command);
let result = df_execute::shell::execute(request).await?;
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
"script"
}
}
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
///
/// 优先级(高→低):
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
///
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
///
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令在黑名单中(运行时配置)");
}
}
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令不在白名单中(运行时配置)");
}
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
if !list.is_empty() {
return None;
}
}
// 步骤3:回退到环境变量(兼容旧部署)
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
let blacklist: Vec<String> = match env_blacklist {
Some(raw) => raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
};
if blacklist.iter().any(|c| c == cmd_name) {
return Some("命令在黑名单中");
}
// 白名单:非空时命令名必须在白名单中才放行
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
let whitelist: Vec<String> = whitelist_raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
return Some("命令不在白名单中");
}
}
None
}

View File

@@ -0,0 +1,175 @@
//! SubflowNode — 嵌套子工作流节点
//!
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
use async_trait::async_trait;
use df_workflow::dag_def::DagDef;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use serde::{Deserialize, Serialize};
/// SubflowNode 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SubflowConfig {
/// 子工作流 DAG 定义(内联 JSON)
dag: serde_json::Value,
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
#[serde(default)]
max_depth: Option<u32>,
}
/// 最大递归深度(安全阀)
const MAX_DEPTH: u32 = 10;
/// SubflowNode — 嵌套子工作流
pub struct SubflowNode;
#[async_trait]
impl Node for SubflowNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
// 反序列化子 DAG
let sub_dag: DagDef = serde_json::from_value(config.dag)
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
if sub_dag.nodes.is_empty() {
anyhow::bail!("子工作流节点不能为空");
}
if max_depth == 0 {
anyhow::bail!("SubflowNode 超过最大嵌套深度");
}
tracing::info!(
node_count = sub_dag.nodes.len(),
edge_count = sub_dag.edges.len(),
max_depth,
"SubflowNode 加载子工作流"
);
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
Ok(NodeOutput::from_value(serde_json::json!({
"subflow": true,
"node_count": sub_dag.nodes.len(),
"edge_count": sub_dag.edges.len(),
"max_depth": max_depth,
"dag": sub_dag,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"dag": {
"type": "object",
"description": "子工作流 DAG 定义(nodes + edges)",
"properties": {
"nodes": { "type": "object" },
"edges": { "type": "array" }
}
},
"max_depth": {
"type": "integer",
"description": "最大嵌套深度(默认10)",
"default": 10
}
},
"required": ["dag"]
}),
output: serde_json::json!({}),
}
}
fn node_type(&self) -> &'static str {
"subflow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_workflow::eventbus::EventBus;
use df_workflow::state::StateMachine;
fn make_ctx(config: serde_json::Value) -> NodeContext {
NodeContext {
node_id: "test_subflow".into(),
inputs: Default::default(),
config,
execution_id: "exec_1".into(),
event_bus: EventBus::new(),
node_status: StateMachine::new(),
}
}
#[tokio::test]
async fn sub_01_valid_dag_returns_metadata() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": {
"nodes": {
"child": {
"id": "child",
"node_type": "script",
"config": {}
}
},
"edges": []
}
}));
let result = node.execute(ctx).await.unwrap();
assert_eq!(result.data["subflow"], true);
assert_eq!(result.data["node_count"], 1);
}
#[tokio::test]
async fn sub_02_empty_dag_rejected() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": { "nodes": {}, "edges": [] }
}));
let result = node.execute(ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("不能为空"));
}
#[tokio::test]
async fn sub_03_max_depth_zero_guard() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": {
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
"edges": []
},
"max_depth": 0
}));
let result = node.execute(ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
}
#[tokio::test]
async fn sub_04_missing_dag_field_errors() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({}));
let result = node.execute(ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn sub_05_schema_has_required_fields() {
let node = SubflowNode;
let schema = node.schema();
let params = &schema.params;
assert!(params["properties"]["dag"].is_object());
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
}
}

View File

@@ -174,14 +174,15 @@ mod tests {
use super::*;
use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
fn rec(id: &str, status: &str) -> TaskRecord {
fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "p1".to_string(),
title: format!("t-{id}"),
description: "".to_string(),
status: status.to_string(),
status,
priority: 2,
branch_name: None,
assignee: None,
@@ -206,7 +207,7 @@ mod tests {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: "planning".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
@@ -221,43 +222,43 @@ mod tests {
#[tokio::test]
async fn forward_path_todo_to_done() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing");
assert_eq!(r.status.as_str(), "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0);
}
#[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn multiple_regressions_accumulate() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -269,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, expected } => {
@@ -285,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
@@ -301,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::Validation(msg) => {
@@ -314,7 +315,7 @@ mod tests {
#[tokio::test]
async fn invalid_target_rejected() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_)));
}
@@ -332,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -340,13 +341,13 @@ mod tests {
.unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.status.as_str(), "in_review");
}
#[tokio::test]
async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo
.advance_status_atomic("t1", "todo", "in_review", false)
@@ -365,12 +366,12 @@ mod tests {
#[tokio::test]
async fn blocked_round_trip_does_not_bump() {
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked");
assert_eq!(r.status.as_str(), "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
}
@@ -460,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap();
repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap();
repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing");
assert_eq!(r.status.as_str(), "testing");
assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap();
repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0);
}
@@ -487,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap();
repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap();
repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap();
repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
}
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。

View File

@@ -22,27 +22,29 @@
//! - cancelled → 终态
// ============================================================
// 状态字符串常量 — df-types::TaskStatus::as_str 一一对应
// 状态字符串常量 — df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================
//
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// as_str 输出,本模块常量自动同步,编译期即可发现柡移
use df_types::types::TaskStatus;
/// 待开始
pub const TODO: &str = "todo";
pub const TODO: &str = TaskStatus::Todo.as_str();
/// 进行中
pub const IN_PROGRESS: &str = "in_progress";
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
/// 代码审查中
pub const IN_REVIEW: &str = "in_review";
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
/// 测试中
pub const TESTING: &str = "testing";
pub const TESTING: &str = TaskStatus::Testing.as_str();
/// 已完成(终态)
pub const DONE: &str = "done";
pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞
pub const BLOCKED: &str = "blocked";
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(终态)
pub const CANCELLED: &str = "cancelled";
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[

View File

@@ -11,3 +11,4 @@ tokio = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }

View File

@@ -1,4 +1,10 @@
//! 项目管理器 — 项目的 CRUD 与生命周期管理
//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
//!
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
//!
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
use serde::{Deserialize, Serialize};
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
pub tags: Vec<String>,
}
/// 项目管理器
/// 项目状态机错误
#[derive(Debug, thiserror::Error)]
pub enum ProjectTransitionError {
#[error("项目状态转换非法: {from:?} → {to:?}")]
IllegalTransition {
from: ProjectStatus,
to: ProjectStatus,
},
#[error("项目名称不能为空")]
EmptyName,
#[error("项目已处在终态 {0:?},不可再推进")]
TerminalState(ProjectStatus),
}
/// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束)
pub struct ProjectManager;
impl ProjectManager {
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
pub fn create(input: CreateProjectInput) -> Project {
pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
if input.name.trim().is_empty() {
return Err(ProjectTransitionError::EmptyName);
}
let now = chrono::Utc::now();
Project {
Ok(Project {
id: df_types::types::new_id(),
name: input.name,
description: input.description,
@@ -57,11 +80,15 @@ impl ProjectManager {
tags: input.tags,
created_at: now,
updated_at: now,
}
})
}
/// 从想法创建项目
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project {
pub fn create_from_idea(
name: String,
description: String,
idea_id: IdeaId,
) -> Result<Project, ProjectTransitionError> {
Self::create(CreateProjectInput {
name,
description,
@@ -71,4 +98,118 @@ impl ProjectManager {
})
}
/// 判断项目状态转换是否合法(状态机核心)。
///
/// 项目状态语义(与 ARCHITECTURE.md 一致):
/// - Planning → InProgress(开始), Cancelled(取消)
/// - InProgress → Testing(提交测试), Paused(暂停), Cancelled
/// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled
/// - Completed → 终态(不可变)
/// - Paused → InProgress(恢复), Cancelled
/// - Cancelled → 终态
pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool {
use ProjectStatus::*;
matches!((from, to),
(Planning, InProgress) | (Planning, Cancelled)
| (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled)
| (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled)
| (Paused, InProgress) | (Paused, Cancelled)
)
}
/// 执行状态转换,返回新状态或非法错误。
///
/// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后
/// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。
pub fn transition(
from: ProjectStatus,
to: ProjectStatus,
) -> Result<ProjectStatus, ProjectTransitionError> {
if !Self::can_transition(&from, &to) {
// 区分错误: 终态→任何 vs 一般非法
if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) {
return Err(ProjectTransitionError::TerminalState(from));
}
return Err(ProjectTransitionError::IllegalTransition { from, to });
}
Ok(to)
}
/// 是否为终态(不可再转换)
pub fn is_terminal(s: &ProjectStatus) -> bool {
matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_rejects_empty_name() {
let err = ProjectManager::create(CreateProjectInput {
name: " ".into(),
description: String::new(),
idea_id: None,
priority: Priority::default(),
tags: vec![],
}).unwrap_err();
assert!(matches!(err, ProjectTransitionError::EmptyName));
}
#[test]
fn main_path_planning_to_completed() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&Planning, &InProgress));
assert!(ProjectManager::can_transition(&InProgress, &Testing));
assert!(ProjectManager::can_transition(&Testing, &Completed));
}
#[test]
fn illegal_skips_rejected() {
use ProjectStatus::*;
assert!(!ProjectManager::can_transition(&Planning, &Completed));
assert!(!ProjectManager::can_transition(&Planning, &Testing));
assert!(!ProjectManager::can_transition(&InProgress, &Completed));
}
#[test]
fn terminal_states_block_all() {
use ProjectStatus::*;
for term in [Completed, Cancelled] {
for to in [Planning, InProgress, Testing, Paused] {
assert!(!ProjectManager::can_transition(&term, &to));
}
}
}
#[test]
fn paused_round_trip() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&InProgress, &Paused));
assert!(ProjectManager::can_transition(&Paused, &InProgress));
}
#[test]
fn transition_returns_target_on_legal() {
use ProjectStatus::*;
assert_eq!(
ProjectManager::transition(Planning, InProgress).unwrap(),
InProgress
);
}
#[test]
fn transition_errors_on_illegal() {
use ProjectStatus::*;
let err = ProjectManager::transition(Planning, Completed).unwrap_err();
assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. }));
}
#[test]
fn transition_errors_on_terminal_source() {
use ProjectStatus::*;
let err = ProjectManager::transition(Completed, InProgress).unwrap_err();
assert!(matches!(err, ProjectTransitionError::TerminalState(_)));
}
}

View File

@@ -32,13 +32,12 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result};
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
/// 读取期望 token(env 优先,fallback 硬编码)
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
}
/// 客户端首消息:身份宣告(简单协议)
@@ -347,7 +346,35 @@ async fn handle_inbound_text(
) -> Result<()> {
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
let payload: serde_json::Value =
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
// device 端收到 control 消息也仅 console.log,不影响业务。
if let Some(control_kind) = payload
.get("control_kind")
.and_then(|v| v.as_str())
{
if control_kind == "ping" && kind == ClientKind::Miniapp {
let pong_payload = serde_json::json!({"control_kind": "pong"});
let pong_msg = BroadcastMessage {
device_id: device_id.to_string(),
kind: crate::broadcast::MessageKind::Control,
source: conn_id,
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
payload: pong_payload,
ts: now_ms(),
};
let _ = state.route(&pong_msg).await;
tracing::trace!(
conn_id = conn_id.0,
device_id = %device_id,
"miniapp ping → relay pong(本地响应)"
);
return Ok(());
}
}
let now = now_ms();
let (msg_kind, from) = match kind {

View File

@@ -0,0 +1,233 @@
//! df-relay 单元测试(任务 #12
//!
//! 覆盖纯逻辑(不依赖 axum server
//! - ConnRegistry: add/remove/is_online/route四种路由规则
//! - BroadcastMessage 构造器
//! - RelayState async 包装
//! - next_conn_id 单调递增
#![cfg(test)]
use df_relay::{
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
RelayState,
};
use tokio::sync::mpsc;
// ---------- ConnId 分配 ----------
#[test]
fn next_conn_id_monotonic() {
let a = next_conn_id();
let b = next_conn_id();
let c = next_conn_id();
assert!(b.0 > a.0, "next_conn_id 应单调递增");
assert!(c.0 > b.0);
}
#[test]
fn conn_id_nil_is_zero() {
assert_eq!(ConnId::NIL.0, 0);
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
}
// ---------- ConnRegistry 基础操作 ----------
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
let (tx, rx) = mpsc::unbounded_channel();
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
}
/// 注册连接并返回 receiver保活防 channel 关闭)。
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
let (handle, rx) = make_handle(id, kind, device_id);
reg.add(handle);
rx
}
#[test]
fn registry_add_and_lookup() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
assert_eq!(reg.len(), 1);
assert!(reg.is_device_online("dev-1"));
assert!(!reg.is_device_online("dev-2"));
}
#[test]
fn registry_remove_returns_device_id() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
let removed = reg.remove(id);
assert_eq!(removed.as_deref(), Some("dev-1"));
assert_eq!(reg.len(), 0);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_remove_unknown_returns_none() {
let mut reg = ConnRegistry::new();
assert_eq!(reg.remove(ConnId(9999)), None);
}
#[test]
fn registry_supports_multi_conn_per_device() {
let mut reg = ConnRegistry::new();
let d1 = next_conn_id();
let m1 = next_conn_id();
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
assert_eq!(reg.len(), 2);
assert!(reg.is_device_online("dev-1"));
reg.remove(d1);
assert!(reg.is_device_online("dev-1"));
reg.remove(m1);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_empty_default() {
let reg = ConnRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
// ---------- 路由规则 ----------
#[test]
fn route_event_device_to_miniapp_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let other_mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
}
#[test]
fn route_command_miniapp_to_device_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Command,
source: mini,
from: ClientKind::Miniapp,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
}
#[test]
fn route_does_not_loop_back_to_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 0, "不应回环给来源");
}
#[test]
fn route_control_to_all_except_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini1 = next_conn_id();
let mini2 = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Control,
source: dev,
from: ClientKind::Device,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
}
#[test]
fn route_unknown_device_zero() {
let reg = ConnRegistry::new();
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0);
}
#[test]
fn route_event_not_delivered_to_device() {
let mut reg = ConnRegistry::new();
let dev1 = next_conn_id();
let dev2 = next_conn_id();
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
}
// ---------- BroadcastMessage 构造器 ----------
#[test]
fn from_device_sets_event_kind_and_from() {
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
assert_eq!(msg.device_id, "dev-1");
assert_eq!(msg.kind, MessageKind::Event);
assert_eq!(msg.from, ClientKind::Device);
assert_eq!(msg.source, ConnId(42));
assert_eq!(msg.ts, 12345);
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
}
// ---------- RelayState async 包装 ----------
#[tokio::test]
async fn relay_state_add_and_remove_conn() {
let state = RelayState::new();
let id = next_conn_id();
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
state.add_conn(handle).await;
assert!(state.is_device_online("dev-1").await);
let removed = state.remove_conn(id).await;
assert_eq!(removed.as_deref(), Some("dev-1"));
assert!(!state.is_device_online("dev-1").await);
}
#[tokio::test]
async fn relay_state_route_async() {
let state = RelayState::new();
let dev = next_conn_id();
let mini = next_conn_id();
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
state.add_conn(h1).await;
state.add_conn(h2).await;
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let n = state.route(&msg).await;
assert_eq!(n, 1);
}

View File

@@ -84,6 +84,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
pinned: row.get::<_, i32>("pinned")? != 0,
prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?,
pinned_goals: row.get("pinned_goals")?,
pending_approvals: row.get("pending_approvals")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -159,24 +161,24 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.created_at, rec.updated_at
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.updated_at, rec.id
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
],
)
}
@@ -262,6 +264,25 @@ impl AiToolExecutionRepo {
.map_err(storage_err)?
}
/// 清理超期的残留 pending 工具调用(旧会话遗留)。
///
/// `max_age_secs`: 超过此秒数的 pending 记录被标记为 interrupted(不硬删,保留审计痕迹)。
pub async fn cleanup_stale_pending(&self, max_age_secs: u64) -> Result<u64> {
let conn = self.conn.clone();
let cutoff_ms = (df_types::now_millis() as i64 - (max_age_secs as i64 * 1000)).to_string();
let affected = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard.execute(
"UPDATE ai_tool_executions SET status = 'interrupted' \
WHERE status = 'pending' AND CAST(requested_at AS INTEGER) < ?1",
params![cutoff_ms],
).map_err(storage_err)
})
.await
.map_err(storage_err)??;
Ok(affected as u64)
}
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
///
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码

View File

@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name};
@@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
id: row.get("id")?,
title: row.get("title")?,
description: row.get("description")?,
status: row.get("status")?,
status: {
let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?,
score: row.get("score")?,
tags: row.get("tags")?,
@@ -200,7 +204,7 @@ impl_repo!(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
rec.id, rec.title, rec.description, rec.status, rec.priority,
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
],
@@ -210,7 +214,7 @@ impl_repo!(
conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![
rec.title, rec.description, rec.status, rec.priority,
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id
],
@@ -297,10 +301,11 @@ impl IdeaRepo {
params_vec.push(Box::new(s.clone()));
}
if let Some(kw) = &keyword {
let pat = format!("%{kw}%");
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let pattern = format!("%{}%", query);
let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64;
tokio::task::spawn_blocking(move || {
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
let mut results = Vec::new();
if let Some(k) = &kind {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
}
} else {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1403,7 +1409,7 @@ mod tests {
id: id.to_string(),
title: title.to_string(),
description: String::new(),
status: "draft".to_string(),
status: IdeaStatus::Draft,
priority: 1,
score: None,
tags: None,

View File

@@ -128,6 +128,57 @@ impl AiMessageRepo {
.map_err(storage_err)?
}
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
///
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
///
/// 典型用法:
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
pub async fn list_recent(
&self,
conversation_id: &str,
limit: usize,
before_seq: Option<i64>,
) -> Result<Vec<AiMessageRecord>> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
let limit = limit.max(1) as i64;
let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
let rows = match before_seq {
Some(seq) => stmt
.query_map(params![conv_id, seq, limit], ai_message_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![conv_id, limit], ai_message_from_row)
.map_err(storage_err)?,
};
let mut results: Vec<AiMessageRecord> = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
results.reverse();
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。

View File

@@ -17,11 +17,15 @@
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod module_dependency_repo;
mod plan_repo;
mod conversation_repo;
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,11 +37,14 @@ 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::*;
pub use task_link_repo::*;
pub use task_repo::*;
pub use module_dependency_repo::*;
pub use plan_repo::*;
// ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板
@@ -305,6 +312,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);

View File

@@ -0,0 +1,95 @@
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
//!
//! 简化版:insert / delete / list_by_field(查 project_id)。
use std::sync::Arc;
use rusqlite::{params, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ModuleDependencyRecord;
use super::{storage_err};
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
Ok(ModuleDependencyRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
from_module_id: row.get("from_module_id")?,
to_module_id: row.get("to_module_id")?,
dep_type: row.get("dep_type")?,
label: row.get("label")?,
created_at: row.get("created_at")?,
})
}
pub struct ModuleDependencyRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ModuleDependencyRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let r = &record;
let affected = guard
.execute(
"INSERT INTO module_dependencies \
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
r.id, r.project_id, r.from_module_id, r.to_module_id,
r.dep_type, r.label, r.created_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn delete(&self, id: &str) -> Result<bool, 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 affected = guard
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let field = field.to_owned();
let value = value.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![value], dep_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)?
}
}

View File

@@ -0,0 +1,579 @@
//! 多 Agent 并行执行 Repo — ai_plans / ai_subtasks / ai_conflicts 表 CRUD(V36)
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
use std::sync::Arc;
use rusqlite::{params, OptionalExtension, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::{ConflictRecord, PlanRecord, SubTaskRecord};
use super::storage_err;
type Result<T> = std::result::Result<T, df_types::error::Error>;
fn plan_from_row(row: &Row<'_>) -> rusqlite::Result<PlanRecord> {
Ok(PlanRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
user_message_id: row.get("user_message_id")?,
status: row.get("status")?,
subtask_count: row.get("subtask_count")?,
created_at: row.get("created_at")?,
completed_at: row.get("completed_at")?,
})
}
fn subtask_from_row(row: &Row<'_>) -> rusqlite::Result<SubTaskRecord> {
Ok(SubTaskRecord {
id: row.get("id")?,
plan_id: row.get("plan_id")?,
persona_id: row.get("persona_id")?,
intent: row.get("intent")?,
status: row.get("status")?,
layer: row.get("layer")?,
deps: row.get("deps")?,
branch: row.get("branch")?,
created_at: row.get("created_at")?,
completed_at: row.get("completed_at")?,
})
}
fn conflict_from_row(row: &Row<'_>) -> rusqlite::Result<ConflictRecord> {
Ok(ConflictRecord {
id: row.get("id")?,
plan_id: row.get("plan_id")?,
file_path: row.get("file_path")?,
conflict_type: row.get("conflict_type")?,
subtask_a: row.get("subtask_a")?,
subtask_b: row.get("subtask_b")?,
diff_a: row.get("diff_a")?,
diff_b: row.get("diff_b")?,
resolution: row.get("resolution")?,
resolved_by: row.get("resolved_by")?,
created_at: row.get("created_at")?,
resolved_at: row.get("resolved_at")?,
})
}
// ============================================================
// PlanRepo
// ============================================================
pub struct PlanRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl PlanRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: PlanRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_plans \
(id, conversation_id, user_message_id, status, subtask_count, created_at, completed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
record.id, record.conversation_id, record.user_message_id,
record.status, record.subtask_count, record.created_at, record.completed_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn get(&self, id: &str) -> Result<Option<PlanRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let row = guard
.query_row("SELECT * FROM ai_plans WHERE id = ?1", params![id], plan_from_row)
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
pub async fn update_status(
&self,
id: &str,
status: &str,
completed_at: Option<&str>,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
let completed_at = completed_at.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_plans SET status = ?1, completed_at = ?2 WHERE id = ?3",
params![status, completed_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_conversation(&self, conv_id: &str) -> Result<Vec<PlanRecord>> {
let conn = self.conn.clone();
let conv_id = conv_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_plans WHERE conversation_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![conv_id], plan_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)?
}
}
// ============================================================
// SubTaskRepo
// ============================================================
pub struct SubTaskRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl SubTaskRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: SubTaskRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_subtasks \
(id, plan_id, persona_id, intent, status, layer, deps, branch, created_at, completed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
record.id, record.plan_id, record.persona_id, record.intent,
record.status, record.layer, record.deps, record.branch,
record.created_at, record.completed_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_plan(&self, plan_id: &str) -> Result<Vec<SubTaskRecord>> {
let conn = self.conn.clone();
let plan_id = plan_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_subtasks WHERE plan_id = ?1 ORDER BY layer ASC, created_at ASC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![plan_id], subtask_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)?
}
pub async fn update_status(
&self,
id: &str,
status: &str,
completed_at: Option<&str>,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
let completed_at = completed_at.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_subtasks SET status = ?1, completed_at = ?2 WHERE id = ?3",
params![status, completed_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// ConflictRepo
// ============================================================
pub struct ConflictRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ConflictRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ConflictRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_conflicts \
(id, plan_id, file_path, conflict_type, subtask_a, subtask_b, diff_a, diff_b, \
resolution, resolved_by, created_at, resolved_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.plan_id, record.file_path, record.conflict_type,
record.subtask_a, record.subtask_b, record.diff_a, record.diff_b,
record.resolution, record.resolved_by, record.created_at, record.resolved_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn list_pending(&self, plan_id: &str) -> Result<Vec<ConflictRecord>> {
let conn = self.conn.clone();
let plan_id = plan_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_conflicts WHERE plan_id = ?1 AND resolution = 'pending' ORDER BY created_at ASC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![plan_id], conflict_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)?
}
pub async fn resolve(
&self,
id: &str,
resolution: &str,
resolved_by: &str,
resolved_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let resolution = resolution.to_owned();
let resolved_by = resolved_by.to_owned();
let resolved_at = resolved_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conflicts SET resolution = ?1, resolved_by = ?2, resolved_at = ?3 WHERE id = ?4",
params![resolution, resolved_by, resolved_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
async fn setup() -> Database {
let db = Database::open_in_memory().await.expect("open_in_memory");
let conn = db.conn();
tokio::task::spawn_blocking(move || {
crate::migrations::run(&conn.blocking_lock()).expect("migrations");
})
.await
.expect("spawn_blocking");
db
}
#[tokio::test]
async fn repo_01_plan_insert_and_get() {
let db = setup().await;
let repo = PlanRepo::new(&db);
let plan = PlanRecord {
id: "plan-001".into(),
conversation_id: "conv-001".into(),
user_message_id: Some("msg-001".into()),
status: "planning".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
};
let id = repo.insert(plan.clone()).await.unwrap();
assert_eq!(id, "plan-001");
let got = repo.get("plan-001").await.unwrap().unwrap();
assert_eq!(got.status, "planning");
assert_eq!(got.subtask_count, 2);
}
#[tokio::test]
async fn repo_02_plan_update_status() {
let db = setup().await;
let repo = PlanRepo::new(&db);
repo.insert(PlanRecord {
id: "plan-002".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 0,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
assert!(repo.update_status("plan-002", "executing", None).await.unwrap());
let got = repo.get("plan-002").await.unwrap().unwrap();
assert_eq!(got.status, "executing");
}
#[tokio::test]
async fn repo_03_subtask_insert_and_list() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-003".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-001".into(),
plan_id: "plan-003".into(),
persona_id: Some("coder".into()),
intent: "读取代码".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: Some("subtask/plan-003/st-001".into()),
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-002".into(),
plan_id: "plan-003".into(),
persona_id: Some("coder".into()),
intent: "修改代码".into(),
status: "pending".into(),
layer: 1,
deps: Some(r#"["st-001"]"#.into()),
branch: Some("subtask/plan-003/st-002".into()),
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
let list = st_repo.list_by_plan("plan-003").await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].layer, 0); // layer 排序
assert_eq!(list[1].layer, 1);
}
#[tokio::test]
async fn repo_04_subtask_update_status() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-004".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-004".into(),
plan_id: "plan-004".into(),
persona_id: None,
intent: "test".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: None,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
assert!(st_repo.update_status("st-004", "done", Some("2026-07-01T01:00:00Z")).await.unwrap());
let list = st_repo.list_by_plan("plan-004").await.unwrap();
assert_eq!(list[0].status, "done");
}
#[tokio::test]
async fn repo_05_conflict_insert_and_list_pending() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let conf_repo = ConflictRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-005".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "merging".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
conf_repo
.insert(ConflictRecord {
id: "conf-001".into(),
plan_id: "plan-005".into(),
file_path: "src/main.rs".into(),
conflict_type: "file".into(),
subtask_a: Some("st-a".into()),
subtask_b: Some("st-b".into()),
diff_a: Some("-old\n+new_a".into()),
diff_b: Some("-old\n+new_b".into()),
resolution: "pending".into(),
resolved_by: None,
created_at: "2026-07-01T00:00:00Z".into(),
resolved_at: None,
})
.await
.unwrap();
let pending = conf_repo.list_pending("plan-005").await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].file_path, "src/main.rs");
}
#[tokio::test]
async fn repo_06_conflict_resolve() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let conf_repo = ConflictRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-006".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "merging".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
conf_repo
.insert(ConflictRecord {
id: "conf-002".into(),
plan_id: "plan-006".into(),
file_path: "src/lib.rs".into(),
conflict_type: "semantic".into(),
subtask_a: None,
subtask_b: None,
diff_a: None,
diff_b: None,
resolution: "pending".into(),
resolved_by: None,
created_at: "2026-07-01T00:00:00Z".into(),
resolved_at: None,
})
.await
.unwrap();
assert!(
conf_repo
.resolve("conf-002", "merged", "reviewer", "2026-07-01T02:00:00Z")
.await
.unwrap()
);
let pending = conf_repo.list_pending("plan-006").await.unwrap();
assert_eq!(pending.len(), 0, "解决后 pending 列表应为空");
}
#[tokio::test]
async fn repo_07_subtask_branch_null_for_non_git() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-007".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-007".into(),
plan_id: "plan-007".into(),
persona_id: None,
intent: "非 Git 工程".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: None, // 非 Git 工程
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
let list = st_repo.list_by_plan("plan-007").await.unwrap();
assert!(list[0].branch.is_none(), "非 Git 工程 branch 应为 NULL");
}
}

View File

@@ -219,6 +219,7 @@ mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
@@ -232,7 +233,7 @@ mod tests {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,

View File

@@ -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<ProjectModuleRecord, rusqlite::Error> {
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<Mutex<rusqlite::Connection>>,
}
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<bool, df_types::error::Error> {
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<Option<ProjectModuleRecord>, 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<Vec<ProjectModuleRecord>, 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<bool, df_types::error::Error> {
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<bool, 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 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<bool, 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 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());
}
}

View File

@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use df_types::types::ProjectStatus;
use crate::db::Database;
use crate::models::{
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
id: row.get("id")?,
name: row.get("name")?,
description: row.get("description")?,
status: row.get("status")?,
status: {
let s: String = row.get("status")?;
ProjectStatus::from_db_str(&s).unwrap_or_default()
},
idea_id: row.get("idea_id")?,
path: row.get("path")?,
stack: row.get("stack")?,
@@ -162,7 +166,7 @@ impl_repo!(
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
rec.id, rec.name, rec.description, rec.status, rec.idea_id,
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.created_at, rec.updated_at
],
)
@@ -171,7 +175,7 @@ impl_repo!(
conn.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
params![
rec.name, rec.description, rec.status, rec.idea_id,
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id
],
)
@@ -238,8 +242,9 @@ impl ProjectRepo {
if let Some(kw) = &q.keyword {
let trimmed = kw.trim();
if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
let pattern = format!("%{trimmed}%");
let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'");
let pattern = format!("%{escaped}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
}

View File

@@ -295,6 +295,7 @@ mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
@@ -308,7 +309,7 @@ mod tests {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,

View File

@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
"error_message", "started_at", "completed_at",
],
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
"ai_providers" => &[
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
"id", "name", "provider_type", "base_url", "default_model", "models",
"is_default", "config", "created_at", "updated_at",
],
"ai_conversations" => &[

View File

@@ -328,6 +328,7 @@ mod tests {
use super::*;
use crate::crud::TaskRepo;
use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord {
@@ -336,7 +337,7 @@ mod tests {
project_id: project_id.to_string(),
title: format!("task-{id}"),
description: String::new(),
status: "todo".to_string(),
status: TaskStatus::Todo,
priority: 1,
branch_name: None,
assignee: None,
@@ -369,7 +370,7 @@ mod tests {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use df_types::error::Result;
use df_types::types::TaskStatus;
use crate::db::Database;
use crate::models::TaskRecord;
@@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
project_id: row.get("project_id")?,
title: row.get("title")?,
description: row.get("description")?,
status: row.get("status")?,
status: {
let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?,
branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?,
@@ -122,7 +126,7 @@ impl_repo!(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
@@ -134,7 +138,7 @@ impl_repo!(
conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
@@ -374,10 +378,11 @@ impl TaskRepo {
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword {
let pat = format!("%{kw}%");
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
@@ -432,6 +437,60 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let keyword = query.keyword.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref pid) = project_id {
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref s) = status {
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}",
where_clauses.join(" AND ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let count: i64 = guard
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
.map_err(storage_err)?;
Ok(count)
})
.await
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
}
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
@@ -566,20 +625,21 @@ mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo")
trec_full(id, queue, parent_id, TaskStatus::Todo)
}
/// 全参 fixture(聚合测试需自定义 status 时用)。
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "proj-1".to_string(),
title: format!("task-{id}"),
description: String::new(),
status: status.to_string(),
status,
priority: 1,
branch_name: None,
assignee: None,
@@ -605,7 +665,7 @@ mod tests {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
@@ -724,16 +784,16 @@ mod tests {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
.await
.unwrap();
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
.await
.unwrap();
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
.await
.unwrap();
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
.await
.unwrap();
@@ -778,13 +838,13 @@ mod tests {
async fn set_status_for_aggregation_writes_status() {
let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo"))
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await
.unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap();
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新");
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
}
@@ -792,7 +852,7 @@ mod tests {
async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo"))
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await
.unwrap();
repo.soft_delete("parent").await.unwrap();

View File

@@ -43,7 +43,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -75,6 +77,11 @@ pub fn run(conn: &Connection) -> Result<()> {
(29, migrate_v29),
(30, migrate_v30),
(31, migrate_v31),
(32, migrate_v32),
(33, migrate_v33),
(34, migrate_v34),
(35, migrate_v35),
(36, migrate_v36),
];
for (version, migrate_fn) in steps {
@@ -902,6 +909,212 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
Ok(())
}
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
///
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
)?;
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
tracing::info!("迁移 v32 完成");
Ok(())
}
fn migrate_v33(conn: &Connection) -> Result<()> {
// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)
let has_col: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_col {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';"
)?;
tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)");
} else {
tracing::info!("v33: pending_approvals 列已存在,跳过");
}
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
let has_updated_at: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_updated_at {
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
}
} else {
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
tracing::info!("迁移 v33 完成");
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(())
}
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
///
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
fn migrate_v35(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library',
label TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
[],
)?;
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
tracing::info!("迁移 v35 完成");
Ok(())
}
/// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 +
/// ai_messages/ai_tool_executions 加 subtask_id 列)。
/// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
fn migrate_v36(conn: &Connection) -> Result<()> {
// 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期)
if !column_exists(conn, "ai_messages", "subtask_id") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?;
tracing::info!("v36: ai_messages 加 subtask_id 列");
}
// 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务)
if !column_exists(conn, "ai_tool_executions", "subtask_id") {
conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?;
tracing::info!("v36: ai_tool_executions 加 subtask_id 列");
}
// 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)",
[],
)?;
// 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)",
[],
)?;
// 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)",
[],
)?;
tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?;
tracing::info!("迁移 v36 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -1,6 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
use serde::{Deserialize, Serialize};
// ============================================================
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
pub id: String,
pub title: String,
pub description: String,
pub status: String,
pub status: IdeaStatus,
pub priority: i32,
pub score: Option<f64>,
pub tags: Option<String>, // JSON 数组
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
pub id: String,
pub name: String,
pub description: String,
pub status: String,
pub status: ProjectStatus,
pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>,
@@ -74,7 +75,7 @@ pub struct TaskRecord {
pub project_id: String,
pub title: String,
pub description: String,
pub status: String,
pub status: TaskStatus,
pub priority: i32,
pub branch_name: Option<String>,
pub assignee: Option<String>,
@@ -134,14 +135,14 @@ pub struct TaskRecord {
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。
/// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord {
pub id: String,
pub source_id: String,
pub target_id: String,
pub link_type: String,
pub link_type: LinkType,
pub remark: Option<String>,
pub created_at: String,
}
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
pub id: String,
pub workflow_id: String,
pub node_id: String,
pub node_type: String,
pub node_type: NodeType,
pub status: String,
pub input_json: Option<String>,
pub output_json: Option<String>,
@@ -279,7 +280,9 @@ pub struct NodeExecutionRecord {
// ============================================================
/// AI 提供商配置记录
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
#[derive(Clone, Serialize, Deserialize)]
pub struct AiProviderRecord {
pub id: String,
pub name: String,
@@ -307,6 +310,27 @@ pub struct AiProviderRecord {
pub weight: u32,
}
impl std::fmt::Debug for AiProviderRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiProviderRecord")
.field("id", &self.id)
.field("name", &self.name)
.field("provider_type", &self.provider_type)
.field("api_key", &"sk-****")
.field("base_url", &self.base_url)
.field("default_model", &self.default_model)
.field("models", &self.models)
.field("model_configs", &"***")
.field("is_default", &self.is_default)
.field("config", &"***")
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("enabled", &self.enabled)
.field("weight", &self.weight)
.finish()
}
}
/// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
fn default_enabled() -> bool {
true
@@ -336,6 +360,8 @@ pub struct AiConversationRecord {
pub pinned: bool, // 是否置顶(排序置前;UX-17)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
pub created_at: String,
pub updated_at: String,
}
@@ -438,3 +464,95 @@ pub struct KnowledgeEventRecord {
pub context_json: Option<String>, // 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<String>,
pub stack: Option<String>, // 技术栈 JSON 字符串
pub auto_detected: bool,
pub sort_order: i32,
pub created_at: String,
pub updated_at: String,
}
/// 工程依赖关系记录(V35 module_dependencies 表)。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModuleDependencyRecord {
pub id: String,
pub project_id: String,
pub from_module_id: String,
pub to_module_id: String,
/// 依赖类型:library / api / mq / shared / custom
pub dep_type: String,
/// 可选标签(自定义描述)
pub label: Option<String>,
pub created_at: String,
}
// ============================================================
// 多 Agent 并行执行模型 (V36)
// ============================================================
/// Plan 记录(一次用户消息触发的并行执行计划)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanRecord {
pub id: String,
pub conversation_id: String,
/// 触发 Plan 的用户消息 id
pub user_message_id: Option<String>,
/// planning / executing / merging / done / error
pub status: String,
pub subtask_count: i64,
pub created_at: String,
pub completed_at: Option<String>,
}
/// SubTask 记录(Plan 拆解出的子任务)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubTaskRecord {
pub id: String,
pub plan_id: String,
/// 分配的人设 id(coder/reviewer/architect/tester/analyst)
pub persona_id: Option<String>,
/// 子任务意图描述
pub intent: String,
/// pending / running / done / error
pub status: String,
/// DAG 层级(0 起)
pub layer: i64,
/// 依赖的 SubTask id 列表(JSON 数组字符串)
pub deps: Option<String>,
/// Git worktree 分支名(NULL=非 Git 工程降级串行)
pub branch: Option<String>,
pub created_at: String,
pub completed_at: Option<String>,
}
/// 冲突记录(合并时检测到的文件/语义冲突)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictRecord {
pub id: String,
pub plan_id: String,
pub file_path: String,
/// file(同文件路径) / semantic(编译失败)
pub conflict_type: String,
pub subtask_a: Option<String>,
pub subtask_b: Option<String>,
pub diff_a: Option<String>,
pub diff_b: Option<String>,
/// pending / a / b / merged / manual
pub resolution: String,
/// reviewer / user
pub resolved_by: Option<String>,
pub created_at: String,
pub resolved_at: Option<String>,
}

View File

@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
}
}
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
///
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
pub async fn get_provider_secret_async(id: String) -> Option<String> {
tokio::task::spawn_blocking(move || get_provider_secret(&id))
.await
.ok()
.flatten()
}
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() {
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
get_provider_secret(&record.id).unwrap_or_default()
}
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
///
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key;
}
get_provider_secret_async(record.id).await.unwrap_or_default()
}
/// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
}
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
.await
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
}
/// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
}
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
.await
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
}
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
let providers = repo.list_all().await?;

View File

@@ -8,6 +8,7 @@
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
// ---------- fixtures ----------
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
id: id.to_string(),
name: format!("proj-{id}"),
description: "desc".to_string(),
status: "active".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
project_id: project_id.to_string(),
title: format!("task-{id}"),
description: "desc".to_string(),
status: "todo".to_string(),
status: TaskStatus::Todo,
priority: 1,
branch_name: None,
assignee: None,
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
assert_ne!(rec.status, "done", "白名单拒绝后 status 不应被改写");
assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写");
}
#[tokio::test]

View File

@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// TCP/WS 建立连接(connect_async)超时上限。
///
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
self.cleanup_conn().await;
}
// 1. 建立 WS 连接
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
let connect_fut = tokio_tungstenite::connect_async(url);
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
Err(_) => {
return Err(TunnelError::Connect(format!(
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
)));
}
};
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split();

View File

@@ -160,6 +160,9 @@ pub enum Augmentation {
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
path: Option<SanitizedPath>,
/// Phase 4:项目关联信息(前 N 条任务/灵感/知识)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
},
/// @任务 注入体
Task {
@@ -174,6 +177,9 @@ pub enum Augmentation {
/// 所属项目名(join 展示用);游离任务为 None。
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
project_name: Option<String>,
/// Phase 4:任务关联信息(关联任务/所属项目上下文)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
},
/// @灵感 注入体
Idea {
@@ -185,6 +191,9 @@ pub enum Augmentation {
status: IdeaStatus,
#[serde(rename = "description")]
description: String,
/// Phase 4:灵感关联信息(已立项时所属项目)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
},
/// /技能 注入体
Skill {
@@ -426,6 +435,7 @@ mod tests {
status: ProjectStatus::InProgress,
description: "AI-native dev tool".to_string(),
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"project\""));
@@ -443,6 +453,7 @@ mod tests {
status: ProjectStatus::Planning,
description: String::new(),
path: None,
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
// skip_serializing_if 生效:path 字段不出现在 JSON 中
@@ -460,6 +471,7 @@ mod tests {
status: TaskStatus::Todo,
description: "do X".to_string(),
project_name: Some("devflow".to_string()),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"task\""));
@@ -476,6 +488,7 @@ mod tests {
title: "Multi-round parallel".to_string(),
status: IdeaStatus::Approved,
description: "desc".to_string(),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"idea\""));
@@ -511,6 +524,7 @@ mod tests {
status: ProjectStatus::Planning,
description: String::new(),
path: None,
extra: vec![],
};
assert_eq!(p.kind_str(), "project");
assert_eq!(p.section_label(), "项目");

View File

@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::types::NodeId;
use crate::types::{ExecutionId, NodeId};
/// 人工审批选择类型(F-260615-01)
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
@@ -24,7 +24,7 @@ pub enum SelectType {
/// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanApprovalResponse {
pub execution_id: String,
pub execution_id: ExecutionId,
pub node_id: NodeId,
pub decision: String,
#[serde(default)]
@@ -81,7 +81,7 @@ pub enum WorkflowEvent {
/// #[serde(default)] 向后兼容: 老事件/老 DB 反序列化(无 execution_id)填空串不炸。
WorkflowCompleted {
#[serde(default)]
execution_id: String,
execution_id: ExecutionId,
total_duration_ms: u64,
},
/// 工作流执行失败
@@ -89,13 +89,13 @@ pub enum WorkflowEvent {
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。
WorkflowFailed {
#[serde(default)]
execution_id: String,
execution_id: ExecutionId,
error: String,
failed_node: NodeId,
},
/// 人工审批请求
HumanApprovalRequest {
execution_id: String,
execution_id: ExecutionId,
node_id: NodeId,
title: String,
description: String,
@@ -106,7 +106,7 @@ pub enum WorkflowEvent {
},
/// 人工审批响应
HumanApprovalResponse {
execution_id: String,
execution_id: ExecutionId,
node_id: NodeId,
decision: String,
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)

View File

@@ -22,10 +22,116 @@ pub type ReleaseId = String;
pub type BranchId = String;
/// 插件 ID
pub type PluginId = String;
/// 执行 ID
pub type ExecutionId = String;
/// 执行 ID(IPC 边界透明序列化为字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ExecutionId(String);
impl ExecutionId {
/// 构造新执行 ID
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ExecutionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ExecutionId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ExecutionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ExecutionId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ExecutionId> for String {
fn from(id: ExecutionId) -> Self {
id.0
}
}
// ExecutionId 与 &str 的比较(测试/匹配中大量使用)
impl PartialEq<&str> for ExecutionId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ExecutionId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<String> for ExecutionId {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
/// 决策 ID
pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review)
pub type NodeType = String;
/// 任务链接类型(如 depends_on / blocks / relates_to)
pub type LinkType = String;
/// 工具调用类型(如 function)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ToolCallType(String);
impl ToolCallType {
/// 构造新工具调用类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolCallType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ToolCallType {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ToolCallType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolCallType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ToolCallType> for String {
fn from(t: ToolCallType) -> Self {
t.0
}
}
// ============================================================
// 时间工具
@@ -66,7 +172,9 @@ pub enum IdeaStatus {
impl IdeaStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self {
IdeaStatus::Draft => "draft",
IdeaStatus::PendingReview => "pending_review",
@@ -122,7 +230,9 @@ pub enum ProjectStatus {
impl ProjectStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self {
ProjectStatus::Planning => "planning",
ProjectStatus::InProgress => "in_progress",
@@ -180,7 +290,10 @@ pub enum TaskStatus {
impl TaskStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
pub const fn as_str(&self) -> &'static str {
match self {
TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress",

View File

@@ -11,7 +11,7 @@ edition = "2021"
# 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值;
# 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤,
# 则跳过该节点执行(保持 Pending),实现条件路由。
default = []
default = ["conditions-eval"]
conditions-eval = []
[dependencies]
@@ -23,3 +23,4 @@ async-trait = { workspace = true }
futures = "0.3"
anyhow = { workspace = true }
tracing = { workspace = true }
serde_yaml = "0.9"

View File

@@ -165,9 +165,10 @@ impl Default for Dag {
///
/// 规则:
/// - 两端均为 Object递归合并同 key 时节点级覆盖全局级,新增 key 各自保留)。
/// - 非两端 Object节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 keynode_configs 缺失即用全局)
/// - 非 Object/Null 两端:节点级直接覆盖全局级。
/// - 节点级为 `Null` 时跳过(保留全局配置),对应语义「节点未指定该配置」。
/// 若调用方希望「显式置空」,应在构造 Dag 时不写入该 keynode_configs 缺失即用全局)
/// 而非传 JSON null。
///
/// 用途DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
@@ -187,8 +188,15 @@ pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde
}
Value::Object(merged)
}
// 非两端 Object节点级覆盖全局级含 Null、标量、数组
_ => node.clone(),
// 节点级为 Null → 保留全局配置(不覆盖
(_, Value::Null) => global.clone(),
// 显式覆盖剩余变体,防止新变体被 _ 吞没
(Value::Number(_), _)
| (Value::String(_), _)
| (Value::Array(_), _)
| (Value::Bool(_), _)
| (Value::Null, _)
| (Value::Object(_), _) => node.clone(),
}
}
@@ -226,11 +234,10 @@ mod deep_merge_tests {
}
#[test]
fn missing_node_config_treated_as_noop() {
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global此处覆盖语义对照
// 若强行把缺失视作 Null应覆盖显式空语义与「缺失」不同缺失走 if let Some 分支)。
fn null_node_config_keeps_global() {
// 节点级为 Null → 保留全局(不覆盖),区别于「缺失 key 不调 deep_merge」。
let global = json!({"a": 1});
assert_eq!(deep_merge(&global, &json!(null)), json!(null));
assert_eq!(deep_merge(&global, &json!(null)), global);
}
#[test]

View File

@@ -30,8 +30,10 @@ impl EventBus {
/// 发送事件
pub async fn send(&self, event: WorkflowEvent) {
// broadcast::send 是同步的,忽略接收者已关闭的错误
let _ = self.sender.send(event);
// broadcast::send 是同步的接收者已关闭时记录警告
if let Err(e) = self.sender.send(event) {
tracing::warn!("EventBus 发送事件失败: {}", e);
}
}
/// 订阅事件

View File

@@ -3,7 +3,7 @@
use std::collections::HashMap;
use df_types::events::WorkflowEvent;
use df_types::types::NodeId;
use df_types::types::{ExecutionId, NodeId};
use crate::conditions::ConditionEngine;
use crate::dag::Dag;
@@ -18,7 +18,7 @@ pub struct DagExecutor {
/// 节点状态机
state_machine: StateMachine,
/// 工作流执行 ID由调用方传入下沉到每个 NodeContext
execution_id: String,
execution_id: ExecutionId,
}
impl DagExecutor {
@@ -26,7 +26,7 @@ impl DagExecutor {
///
/// `execution_id` 为本次工作流执行的唯一标识,会下沉到每个节点的 NodeContext
/// 用于节点内的事件关联、审计追踪等。
pub fn new(event_bus: EventBus, execution_id: String) -> Self {
pub fn new(event_bus: EventBus, execution_id: ExecutionId) -> Self {
Self {
event_bus,
state_machine: StateMachine::new(),

View File

@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
let dag = registry.build_dag(&def).expect("build_dag");
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
// 全局 config 写 foo=GLOBAL验证被 n1 节点级覆盖n2 回退用全局)
let outputs = executor
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
let start = std::time::Instant::now();
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
let elapsed = start.elapsed();
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
dag.add_edge("a".to_string(), "c".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
let err = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
let mut dag = Dag::new();
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
let result = executor.run(&dag, serde_json::Value::Null).await;
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
let mut dag = Dag::new();
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
let result = executor.run(&dag, serde_json::Value::Null).await;
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
let _ = executor.run(&dag, serde_json::Value::Null).await;
// 收集所有事件(run 已结束,事件总线无新事件)
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await

View File

@@ -8,3 +8,4 @@ pub mod executor;
pub mod node;
pub mod registry;
pub mod state;
pub mod template_loader;

View File

@@ -3,7 +3,7 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use df_types::types::NodeId;
use df_types::types::{ExecutionId, NodeId};
use super::eventbus::EventBus;
use super::state::StateMachine;
@@ -18,7 +18,7 @@ pub struct NodeContext {
/// 节点配置参数
pub config: serde_json::Value,
/// 工作流执行 ID
pub execution_id: String,
pub execution_id: ExecutionId,
/// 事件总线
pub event_bus: EventBus,
/// 节点状态机(用于检查取消状态)

View File

@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
use df_types::types::{NodeId, NodeStatus};
/// 节点状态机
///
/// # ⚠️ 并发安全警告
///
/// `state_machine` 方法(`get`、`transition`、`set_running`、`set_completed`、
/// `set_failed`、`set_cancelled`、`snapshot`、`is_cancelled`)内部持锁
/// `self.states.lock()`**不得在 `.await` 期间持锁**。
/// 若在异步上下文中调用,请确保获取结果后立即释放锁(即不要将锁跨越
/// `.await` 点)。当前所有方法均为同步且及时释放,符合此规则。
///
/// 共享语义:内部用 `Arc<Mutex<HashMap>>``clone()` 为浅拷贝Arc 引用计数 +1
/// 所有 clone 共享同一底层 HashMap。
#[derive(Debug, Clone)]
pub struct StateMachine {
/// 节点状态映射Arc 共享clone 浅拷贝同一 HashMap

View File

@@ -0,0 +1,330 @@
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
//!
//! 模板格式:
//! ```yaml
//! name: 代码审查
//! description: AI 驱动的代码审查流程
//! nodes:
//! review:
//! type: ai
//! config:
//! prompt: "审查以下代码变更..."
//! persona_id: reviewer
//! notify:
//! type: notify
//! config:
//! type: desktop
//! title: "审查完成"
//! edges:
//! - from: review
//! to: notify
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
/// YAML 模板顶层结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTemplate {
pub name: String,
#[serde(default)]
pub description: String,
pub nodes: HashMap<String, TemplateNode>,
#[serde(default)]
pub edges: Vec<TemplateEdge>,
}
/// 模板节点(YAML 友好命名)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateNode {
/// 节点类型(ai/script/human/git/http/notify/subflow)
#[serde(rename = "type")]
pub node_type: String,
#[serde(default)]
pub config: serde_json::Value,
#[serde(default)]
pub label: Option<String>,
}
/// 模板边(YAML 友好命名)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateEdge {
pub from: String,
pub to: String,
#[serde(default)]
pub condition: Option<String>,
}
/// 模板加载错误
#[derive(Debug)]
pub enum TemplateError {
/// YAML 解析失败
YamlParse(String),
/// 校验失败(空节点/未知类型/依赖环)
Validation(String),
}
impl std::fmt::Display for TemplateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
}
}
}
impl std::error::Error for TemplateError {}
/// 已知的节点类型白名单
const KNOWN_NODE_TYPES: &[&str] = &[
"ai", "script", "human", "git", "http", "notify", "subflow",
];
/// 从 YAML 字符串加载模板并转换为 DagDef
///
/// 步骤:
/// 1. YAML → WorkflowTemplate(serde_yaml)
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
/// 3. 转换为 DagDef
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
// 1. YAML 解析
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
// 2. 校验
validate_template(&template)?;
// 3. 转换为 DagDef
let mut dag = DagDef::new();
for (id, node) in &template.nodes {
dag.nodes.insert(
id.clone(),
NodeDef {
id: id.clone(),
node_type: node.node_type.clone(),
config: node.config.clone(),
label: node.label.clone(),
},
);
}
for edge in &template.edges {
dag.edges.push(EdgeDef {
source: edge.from.clone(),
target: edge.to.clone(),
condition: edge.condition.clone(),
});
}
Ok(dag)
}
/// 校验模板结构
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
// 节点非空
if t.nodes.is_empty() {
return Err(TemplateError::Validation("模板节点不能为空".into()));
}
// 节点类型合法
for (id, node) in &t.nodes {
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
return Err(TemplateError::Validation(format!(
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
id, node.node_type, KNOWN_NODE_TYPES
)));
}
}
// 边引用的节点存在
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
for edge in &t.edges {
if !node_ids.contains(&edge.from) {
return Err(TemplateError::Validation(format!(
"边的起点 '{}' 不存在", edge.from
)));
}
if !node_ids.contains(&edge.to) {
return Err(TemplateError::Validation(format!(
"边的终点 '{}' 不存在", edge.to
)));
}
}
// 依赖无环(DFS 检测)
if has_cycle(&t.edges) {
return Err(TemplateError::Validation("依赖存在环".into()));
}
Ok(())
}
/// DFS 检测边列表是否有环
fn has_cycle(edges: &[TemplateEdge]) -> bool {
use std::collections::{HashMap, HashSet};
// 建邻接表
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let mut visited: HashSet<&str> = HashSet::new();
let mut in_stack: HashSet<&str> = HashSet::new();
fn dfs<'a>(
node: &'a str,
adj: &HashMap<&'a str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
in_stack: &mut HashSet<&'a str>,
) -> bool {
if in_stack.contains(node) {
return true; // 环
}
if visited.contains(node) {
return false; // 已访问过,无环
}
visited.insert(node);
in_stack.insert(node);
if let Some(neighbors) = adj.get(node) {
for next in neighbors {
if dfs(next, adj, visited, in_stack) {
return true;
}
}
}
in_stack.remove(node);
false
}
for e in edges {
let start = e.from.as_str();
if dfs(start, &adj, &mut visited, &mut in_stack) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tpl_01_valid_template_loads() {
let yaml = r#"
name: 代码审查
description: AI 驱动的代码审查
nodes:
review:
type: ai
config:
prompt: "审查代码"
notify:
type: notify
config:
type: desktop
title: "完成"
edges:
- from: review
to: notify
"#;
let dag = load_template(yaml).unwrap();
assert_eq!(dag.nodes.len(), 2);
assert_eq!(dag.edges.len(), 1);
assert!(dag.nodes.contains_key("review"));
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
}
#[test]
fn tpl_02_empty_nodes_rejected() {
let yaml = "name: 空\nnodes: {}\n";
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_03_unknown_node_type_rejected() {
let yaml = r#"
name: test
nodes:
bad:
type: unknown_type
config: {}
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_04_cycle_detected() {
let yaml = r#"
name: 环
nodes:
a:
type: ai
config: {}
b:
type: ai
config: {}
edges:
- from: a
to: b
- from: b
to: a
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("")));
}
#[test]
fn tpl_05_edge_to_nonexistent_rejected() {
let yaml = r#"
name: test
nodes:
a:
type: ai
config: {}
edges:
- from: a
to: ghost
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_06_no_edges_ok() {
let yaml = r#"
name: 单节点
nodes:
solo:
type: script
config:
command: "echo hello"
"#;
let dag = load_template(yaml).unwrap();
assert_eq!(dag.nodes.len(), 1);
assert!(dag.edges.is_empty());
}
#[test]
fn tpl_07_invalid_yaml_rejected() {
let yaml = "not: valid: yaml: {{{";
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
}
#[test]
fn tpl_08_all_known_node_types() {
for nt in KNOWN_NODE_TYPES {
let yaml = format!(
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
nt
);
let result = load_template(&yaml);
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
}
}
}

10
dev-server.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
cd /d E:\wk-lab\devflow
echo [dev] 启动 DevFlow 开发服务器...
echo [dev] Vite + Tauri 后端将同时启动
echo [dev] 请勿关闭此窗口
echo.
C:\Users\23780\.bun\bin\bun.exe run tauri dev
echo.
echo [dev] 服务器已停止
pause

View File

@@ -0,0 +1,328 @@
# AntV X6 技术调研报告与引入计划
> 创建: 2026-06-30 | 状态: 调研完成,待决策
> 调研对象: AntV X6图编辑与可视化引擎
> 数据来源: GitHub APIantvis/X6+ npm registry 实时查询2026-06-30
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project依赖图构建`
---
## 一、为什么调研 X6
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
当前 DevFlow 前端(`src/`**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow。需要选型一个图引擎承担可视化层。
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
---
## 二、X6 是什么
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**Graph Editing and Visualization Engine
- GitHub: https://github.com/antvis/X6
- 文档: https://x6.antv.antgroup.com/
- License: **MIT**
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
与纯图表库ECharts/G2的区别X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
---
## 三、关键数据(实时校准,非记忆)
> ⚠️ 网上多数博客仍停留在「v2.x」**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
### 3.1 版本现状
| 通道 | 版本 | 说明 |
|---|---|---|
| **latest** | **3.1.7** | 当前正式稳定版v3 主线) |
| beta | 3.1.4-beta.1 | 预发布通道 |
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
### 3.2 仓库活跃度
| 指标 | 数值 |
|---|---|
| Stars | 18.3k |
| Forks | 2.5k |
| Network | 1886 |
| 最近 release | 持续迭代2026 年仍有多次 release |
| 维护状态 | **活跃** |
### 3.3 最近 release 亮点(佐证活跃度)
近期 PR 修复了:
- 节点事件无法冒泡到连线的问题(#4888
- stencil 插件搜索后高度自适应(#4975
说明交互细节仍在持续打磨,非弃维项目。
---
## 四、核心能力清单
| 能力 | 说明 | DevFlow 是否用得到 |
|---|---|---|
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签API调用/类库/MQ... |
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
| **布局算法** | dagre层次/ force力导向/ grid / circular | ✅ dagre 天然适合依赖图 |
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
| **数据格式** | JSON graph data导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
---
## 五、技术特性
### 5.1 架构
- **框架无关**:核心纯 TypeScript不绑定 React/Vue
- **渲染引擎**SVG默认/ Canvas大图性能更优
- **插件化**功能按需引入tree-shaking 友好v3 强化)
### 5.2 生态包v3 延续插件化)
| 包 | 用途 | DevFlow 需要 |
|---|---|---|
| `@antv/x6` | 核心引擎 | ✅ 必装 |
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
### 5.3 与 DevFlow 技术栈兼容性
| 维度 | DevFlow | X6 要求 | 兼容 |
|---|---|---|---|
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
| UI 库 | Arco Design | 无冲突 | ✅ |
| 构建 | Vite 6 | 支持 | ✅ |
| TS | 5.6 | 原生 TS | ✅ |
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
| 包管理 | bun.lock / npm | 均支持 | ✅ |
**无兼容性阻塞。**
---
## 六、竞品对比
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|---|---|---|---|---|---|---|
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
**结论**DevFlow 需要「可编辑依赖图」X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类X6 偏「图编辑」人机交互DevFlow 场景是后者。
---
## 七、DevFlow 落地场景评估
### 7.1 主场景多工程依赖关系图P0立即落地
对应任务 `[子4]`
```
[DevFlow 工程] ──API调用──> [u-talk 工程]
├──类库引入──> [df-types crate]
└──共享资源──> [MySQL]
```
- 节点 = module工程/crateVue 自定义节点展示名称/路径/技术栈
- 边 = module_dependency标签展示 dep_typeAPI调用/类库/MQ/共享资源/自定义)
- 布局 = dagre 层次布局(天然适合依赖关系)
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
### 7.2 次场景工作流编辑器P1未来
DevFlow 有工作流引擎AiNode/HumanNode/executor未来可做可视化工作流编辑器
- 拖拽节点AI 节点 / 人工节点 / 判断节点)
- 连线定义流转
- 与后端 workflow 定义双向同步
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
### 7.3 次场景任务依赖图P2
任务有 `depends_on / blocks / relates_to` 关联create_task_link可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
### 7.4 不适用场景
- ai-news纯 HTML/JS无图编辑需求— 不引入
- u-talk / u-desk — 暂无明确图编辑需求,不引入
---
## 八、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| v3 文档站是 SPA离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50不触瓶颈必要时切 Canvas 渲染 |
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KBTauri 本地资源可接受 |
**无阻塞级风险。**
---
## 九、引入计划
### 9.1 决策点(待人工拍板)
| 项 | 推荐 | 备选 |
|---|---|---|
| 是否引入 X6 | ✅ **引入** | vis-network编辑弱/ 自研 SVG成本高 |
| 版本 | v3.1.7latest | — |
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
| 引入范围 | 仅 DevFlow 前端 | — |
### 9.2 分阶段实施
#### Phase 0依赖安装与验证0.5 天)
```bash
# 在 DevFlow 前端目录
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
@antv/x6-vue-shape
```
交付物:
- 依赖装入 `package.json`
- 一个最小 demo 页面3 节点 2 边,验证渲染 + 拖拽 + 缩放)
- 验证 Tauri WebView 下无异常
#### Phase 1依赖关系图组件1.5 天,对接 `[子4]`
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
交付物:
- 新建 `src/components/DependencyGraph.vue`
- 封装 X6 Graph 实例生命周期管理mount 初始化、unmount 销毁)
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
- 边标签展示 dep_type
- dagre 自动布局
- 小地图 + 框选 + 撤销重做
- 事件:点击节点 → emit('select-module', id),父组件跳详情
#### Phase 2集成到项目详情页0.5 天,对接 `[子4]`
交付物:
- 项目详情页新增「依赖图」Tab
- 与工程列表 Tab 并列,数据同源
- 空状态处理(单工程无依赖时提示)
#### Phase 3未来工作流可视化编辑器
不在本次计划范围,待工作流引擎需求明确后再立项。
### 9.3 工程化约定
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount``graph.dispose()`,避免内存泄漏
2. **类型契约**:后端 `ModuleDependencyGraph`Rust serde→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil暂不需要
### 9.4 验收标准
- [ ] Phase 0demo 页渲染正常Tauri 下无报错
- [ ] Phase 1依赖图组件能渲染 ≥10 个工程节点 + 依赖边dagre 布局自动排布
- [ ] Phase 1拖拽节点、框选、撤销重做、小地图均可用
- [ ] Phase 1点击节点能触发跳转工程详情
- [ ] Phase 2项目详情页「依赖图」Tab 可用,多工程项目展示正确
- [ ] 单工程项目显示友好空状态
---
## 十、快速上手示例Vue 3 + v3
```typescript
// src/utils/x6-setup.ts
import { Graph } from '@antv/x6'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { History } from '@antv/x6-plugin-history'
import { Minimap } from '@antv/x6-plugin-minimap'
import { Scroller } from '@antv/x6-plugin-scroller'
export function createGraph(container: HTMLElement): Graph {
const graph = new Graph({
container,
background: { color: '#F2F7FA' },
grid: { visible: true, size: 10 },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
return graph
}
```
```typescript
// 数据映射示例:后端图结构 → X6 fromJSON
function toX6Data(graph: ModuleDependencyGraph) {
return {
nodes: graph.modules.map(m => ({
id: m.id,
shape: 'devflow-module', // 注册的 Vue 自定义节点
x: 0, y: 0, // dagre 布局后重排
data: { name: m.name, path: m.path, stack: m.stack },
})),
edges: graph.dependencies.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: [{ text: d.dep_type }],
})),
}
}
```
---
## 十一、结论与建议
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
2. **首选落地场景**`[子4] 多工程依赖关系图`与当前正在推进的多工程组织功能直接对接ROI 最高。
3. **无兼容性阻塞**技术栈Vue 3 + Vite 6 + Tauri WebView完全适配。
4. **建议批准 Phase 0**0.5 天安装验证),验证通过后推进 Phase 1-22 天)。
5. **暂不引入** u-talk / u-desk / ai-news待有明确图编辑需求再评估。
---
## 相关链接
- 官网: https://x6.antv.antgroup.com/
- GitHub: https://github.com/antvis/X6
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vuevue-shape 用法)
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project依赖图构建`

View File

@@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
- ID 字段统一使用 `TEXT` (UUID v4)
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
- JSON 字段使用 `TEXT` 存储 JSON 字符串
- 所有写操作统一返回 `df_core::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
- 所有写操作统一返回 `df_types::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
## update_field 的强制时间戳约束
@@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
- `crates/df-storage/src/lib.rs` — 存储层入口
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
- `crates/df-core/src/error.rs` — 统一错误类型
- `crates/df-types/src/error.rs` — 统一错误类型

View File

@@ -47,8 +47,12 @@
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 |
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | — | 📐 设计 | 消息级溯源 Phase 2:切读切换到 ai_messages 视图 |
---
@@ -59,20 +63,33 @@
| 文档 | 状态 | 核心内容 |
|---|---|---|
| [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) |
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 📐 设计待落地 | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec |
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 🗄 归档(被其他机制覆盖) | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec。**核验(2026-06-28):0 行代码,其核心价值(规格与实现一致性检查)已被 L1 求助协议 + ai_self_review gate 覆盖,归档不实施** |
| [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 |
| [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage |
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | 📐 设计待实施(P0) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 |
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 + 三模式(old_text/replace_lines/anchor)。**仅文本(不支持二进制 encoding,合理设计)** |
| [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) |
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | 📐 设计未实施 | 空 api_key 不覆盖未迁移态明文密钥 |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 |
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 |
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ScriptNode 命令执行安全边界:白名单/黑名单(env 配置)+ 危险关键词告警 |
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影。**核验:查询能力补全已落地(task/project/idea 均 keyword/order_by/limit/offset),性能优化待评审** |
| [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 |
| [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 |
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 + P2 切读全部完成 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 🟡 部分落地(基建就位,消费者未接) | pub-sub + request-reply + 流式 reply 统一总线。**核验(2026-06-28):基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入(空转)。阶段 2-6 待真实痛点驱动** |
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | ✅ 已落地(2026-06-28 核验) | 查询接口能力补全:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | ✅ Phase1 已落地(2026-06-28 核验) | AST 符号解析:read_symbol 工具已注册(tool_registry.rs:1506-1547)+ 基线测试守护 |
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
| [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) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
---
@@ -97,6 +114,15 @@
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
| [AI-Native方向与路线图-2026-06-29.md](./构想审查/AI-Native方向与路线图-2026-06-29.md) | 📐 方向 | AI Native 优先级矩阵:P0人设+多Agent→P1模板+Git/CI→P2审批政策+算法验证 |
### 根目录文档(不在子目录)
| 文档 | 状态 | 核心内容 |
|---|---|---|
| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 |
| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) |
---

View File

@@ -1,6 +1,6 @@
# AST 符号解析 — Code Intelligence 设计
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
> 日期:2026-06-24 | 状态:✅ Phase1 已落地(2026-06-28 核验:read_symbol 工具已注册 tool_registry.rs:1506-1547 + 基线测试守护)
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
## Context(为什么)

View File

@@ -1,7 +1,9 @@
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界)
> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界)
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
> 关联: [AI-Native方向与路线图](../构想审查/AI-Native方向与路线图-2026-06-29.md)AI Native 优先级矩阵与推进路线图)
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
---

View File

@@ -1,6 +1,6 @@
# Patch File 工具设计
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
> 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
> 关联 todo: F-260615-06 [P0]
---

View File

@@ -0,0 +1,393 @@
# 三层模型:流程模板 → 工作流 → 人设体系
> 创建: 2026-06-28 | 状态: 设计阶段
> 关联: [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
> 关联: [ARCHITECTURE.md](../../../ARCHITECTURE.md)(项目架构总纲,本文为专项展开)
---
## 一、背景与问题
DevFlow 的工作流引擎df-workflow已具备 DAG 定义、拓扑排序、节点调度、状态流转等核心能力。AI Chat 已具备单链 ReAct、工具调用、审批机制。
但现有架构缺少两个关键抽象:
| 缺失 | 导致的问题 |
|------|-----------|
| **流程模板**Template | 工作流定义与具体项目绑定,无法复用标准流程。每个项目需从零搭建 DAG |
| **人设**Persona | 所有 AINode 使用通用 LLM 调用,无角色分工。编码、审查、测试节点行为无差异 |
**解决方案**:引入三层模型——**模板层定义蓝图、工作流层执行实例、人设层注入角色**。
---
## 二、三层模型总览
```
┌──────────────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 可复用的阶段蓝图 │
│ │
│ ├─ 节点 DAG 定义(拓扑 + 类型 + 数据流) │
│ ├─ 建议人设标注persona_hint实例化时可覆盖
│ ├─ 质量门禁condition + on_fail
│ ├─ 产出物规范artifacts 声明) │
│ └─ 存储YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — 模板的运行时实例 │
│ │
│ ├─ 模板实例化(绑定具体项目参数、分支名、路径) │
│ ├─ DAG 执行(拓扑排序 + 并行调度 + 状态流转) │
│ ├─ 数据绑定inputs/outputs 按 ID 映射) │
│ ├─ 条件分支condition 求值 → 动态路由) │
│ ├─ 断点续跑(状态快照 + 恢复) │
│ └─ 载体df-workflow现有✅ 核心完成) │
├──────────────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色卡 │
│ │
│ ├─ system prompt角色定位、行为规范
│ ├─ allowed_tools该角色可调用的工具集
│ ├─ output_format输出约束
│ ├─ suggested_tier推荐模型层级
│ ├─ behavior rules如"每次输出前先检查..."
│ └─ 注入点AINode 执行时从 NodeContext 读取 persona_id │
└──────────────────────────────────────────────────────────┘
```
### 关键原则
1. **三层独立演化**:模板添加新节点类型、工作流优化调度算法、人设新增角色——互不阻塞
2. **交汇点单一**:三层只在 AINode 执行时交汇——工作流传递 persona_idNode 端根据 id 载入人设配置
3. **模板标注建议而非绑定**:模板只写 `persona_hint: coder`,实例化时可改为 `coder-rust``coder-py`
4. **人设可跨模板复用**`reviewer` 人设既可用于"功能开发模板"的审查节点,也可用于"Bug 修复模板"的审查节点
---
## 三、流程模板Template Layer
### 3.1 数据结构
```yaml
# templates/feature-dev.yaml
id: feature-dev # 模板唯一标识
name: 功能开发模板 # 显示名称
description: 标准功能开发全流程 # 描述
template_version: 1 # 模板版本(用于升级检测)
tags: ["feature", "standard"] # 分类标签
nodes:
- id: analysis # 节点 ID
type: ai # 节点类型ai / script / human / subflow
persona_hint: analyst # 建议人设(实例化时可覆盖)
prompt: "分析需求:{{inputs.requirement}}" # 提示词
inputs: # 数据输入映射
requirement: "$ctx.requirement" # $ctx = 工作流上下文参数
outputs: # 输出声明
prd: text
config: # 节点级配置(覆盖默认)
temperature: 0.3
- id: design
type: ai
persona_hint: architect
prompt: "基于 PRD 设计架构:{{inputs.analysis.prd}}"
inputs:
analysis: "$nodes.analysis" # $nodes = 上游节点输出
outputs:
arch_doc: text
- id: coding
type: ai
persona_hint: coder
prompt: "实现:{{inputs.design.arch_doc}}"
inputs:
design: "$nodes.design"
outputs:
code: text
- id: review
type: ai
persona_hint: reviewer
prompt: "审查代码:{{inputs.coding.code}}"
inputs:
coding: "$nodes.coding"
- id: test
type: script
prompt: "" # Script 节点用 command
command: "cargo test"
timeout_secs: 300
- id: release
type: human
prompt: "确认发布到生产?"
edges: # 显式边定义(可选,缺省按 nodes 顺序连接)
- from: analysis
to: design
- from: design
to: coding
- from: coding
to: review
- from: review
to: test
- from: test
to: release
conditions: # 条件分支
- node: review
if: "output.verdict != 'pass'"
goto: coding # 审查不通过,回编码节点
quality_gates: # 质量门禁
- node: review
condition: "output.verdict == 'pass'"
on_fail: "block" # block / goto / warn
- node: test
condition: "output.exit_code == 0"
on_fail: "goto coding"
artifacts: # 产出物声明
prd: "$nodes.analysis.prd"
arch: "$nodes.design.arch_doc"
code: "$nodes.coding.code"
review_report: "$nodes.review.text"
```
### 3.2 模板实例化流程
```
① 用户选择模板(如"功能开发模板"
② 填写实例化参数:
├─ project_id: 绑定到哪个项目
├─ requirement: 需求描述(注入 $ctx.requirement
├─ persona_overrides: 按节点覆盖人设
│ └─ coding → coder-rust该项目是 Rust 后端)
└─ branch: feature/search绑定 Git 分支)
③ 实例化引擎执行:
├─ 复制 DAG 拓扑
├─ 绑定数据映射(替换 $ctx / $nodes 占位符)
├─ 应用人设覆盖
├─ 创建 WorkflowRun状态 = pending
└─ 写入 DBworkflow_runs + workflow_nodes 表)
④ 工作流引擎调度执行
```
### 3.3 内置模板清单
| 模板 ID | 名称 | 适用场景 | 节点链 |
|---------|------|---------|--------|
| `feature-dev` | 功能开发 | 新增功能 | 需求分析 → 架构设计 → 编码 → 审查 → 测试 → 发布 |
| `bug-fix` | Bug 修复 | 缺陷修复 | 问题复现 → 根因分析 → 修复编码 → 回归测试 → 发布 |
| `algorithm-dev` | 算法开发 | 算法类功能 | 需求分析 → 算法设计 → 实现 → 基准测试 → 验证 → 发布 |
| `refactor` | 代码重构 | 重构优化 | 代码分析 → 重构计划 → 编码 → 审查 → 回归测试 |
> 模板为内置预设,用户可自定义模板(复制内置模板修改后存为用户模板)。
---
## 四、人设层Persona Layer
### 4.1 数据结构
```rust
/// 智能体人设 — Agent 角色卡
pub struct AgentPersona {
/// 人设标识(如 "coder-rust"、"reviewer"
pub id: PersonaId,
/// 人设名称
pub name: String,
/// 人设描述
pub description: String,
/// 系统提示词(核心——定义 Agent 的角色、行为规范)
pub system_prompt: String,
/// 可用工具列表(空 = 继承自父级配置)
pub allowed_tools: Vec<ToolName>,
/// 推荐模型层级
pub suggested_tier: ModelTier,
/// 输出格式约束
pub output_format: OutputFormat,
/// 行为规则
pub rules: Vec<BehaviorRule>,
/// Few-shot 样例
pub examples: Vec<PersonaExample>,
}
/// 行为规则
pub struct BehaviorRule {
pub rule_type: RuleType, // PreCheck / PostCheck / Constraint
pub description: String,
pub check_prompt: String, // AI 检查提示
}
/// 输出格式约束
pub enum OutputFormat {
FreeText,
Markdown,
Json { schema: Value },
Code { language: String },
}
```
### 4.2 内置人设清单
| 人设 ID | 名称 | 核心 system_prompt 要点 | 建议工具 |
|---------|------|------------------------|---------|
| `analyst` | 需求分析师 | 拆解用户故事、识别歧义、输出 PRD | read_file, search_knowledge |
| `architect` | 系统架构师 | 模块划分、接口设计、技术选型 | read_file, search_knowledge, write_file |
| `coder-rust` | Rust 工程师 | 类型安全、错误处理、性能优先 | read_file, write_file, search_code, list_directory, run_command |
| `coder-ts` | TS/前端工程师 | 组件复用、类型定义、响应式 | read_file, write_file, search_code, list_directory, run_command |
| `reviewer` | 代码审查员 | 安全漏洞、性能问题、CRITICAL/MAJOR/MINOR | read_file, search_code, git_diff |
| `tester` | 测试工程师 | 边界条件、覆盖率、测试隔离 | read_file, write_file, run_command |
| `algorithm` | 算法工程师 | 复杂度分析、精度对比、优化策略 | read_file, write_file, run_command, benchmark |
| `devops` | DevOps 工程师 | 容器化、CI/CD、监控告警 | read_file, write_file, run_command |
### 4.3 人设的内部结构persona.rs 设计)
```rust
// crates/df-ai/src/persona.rs新建
pub struct PersonaRegistry {
builtins: HashMap<PersonaId, AgentPersona>,
customs: HashMap<PersonaId, AgentPersona>,
}
impl PersonaRegistry {
pub fn new() -> Self { /* 载入内置人设 */ }
pub fn get(&self, id: &PersonaId) -> Option<&AgentPersona>;
pub fn register(&mut self, persona: AgentPersona); // 注册自定义人设
pub fn list(&self) -> Vec<&AgentPersona>;
pub fn get_system_prompt(&self, id: &PersonaId) -> Option<&str>;
pub fn filter_tools(&self, id: &PersonaId, all_tools: &[ToolDef]) -> Vec<ToolDef>;
}
```
### 4.4 人设注入时机
人设在两个入口注入,覆盖不同的使用场景:
```
场景 A: Workflow AINode 执行
DAG Executor → AiNode::execute()
→ NodeContext 中有 persona_id来自模板实例化
→ AiNode 调用 PersonaRegistry::get(persona_id)
→ 将 persona.system_prompt 附加到 LLM prompt 头部
→ 将 persona.allowed_tools 传入工具选择器
→ 执行 LLM complete()
场景 B: AI Chat Agentic Loop
run_agentic_loop()
→ 根据 intent 识别结果自动选人设
→ Code Intent → 自动绑定 coder 人设
→ 绑定后: system_prompt = coder.system_prompt
→ 绑定后: 工具面板 = coder.allowed_tools
→ 执行 ReAct 循环(带人设约束)
```
---
## 五、三层在现有代码中的落地映射
### 5.1 新增与修改文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `crates/df-ai/src/persona.rs` | **新建** | AgentPersona 结构体 + PersonaRegistry + 内置人设 |
| `crates/df-ai/src/lib.rs` | 修改 | 导出 `pub mod persona` |
| `crates/df-ai/src/coordinator.rs` | 修改 | 从空壳变为人设调度器——给子任务分配人设 |
| `crates/df-nodes/src/ai_node.rs` | 修改 | execute() 中读取 `NodeContext``persona_id`,加载人设配置 |
| `crates/df-workflow/src/node.rs` | 修改 | `NodeContext` 新增 `persona_id: Option<PersonaId>` 字段 |
| `crates/df-types/src/types.rs` | 修改 | 新增 `PersonaId` 类型 |
| `src-tauri/src/commands/ai/agentic.rs` | 修改 | `run_agentic_loop` 根据 intent 自动选人设 |
| `src-tauri/src/commands/ai/tool_registry.rs` | 修改 | 工具注册表支持按人设过滤 |
### 5.2 现有三条路径如何汇合
```
┌──────────────────────┐
│ 模板YAML
│ persona_hint: coder │
└──────────┬───────────┘
│ 实例化
┌──────────────────────┐
│ 工作流实例 │
│ NodeContext { │
│ persona_id: "coder"│
│ } │
└──────────┬───────────┘
│ 执行到 AINode
┌──────────────────────────────────────────────┐
│ AiNode::execute() │
│ ├─ 从 ctx.persona_id 查到人设配置 │
│ ├─ 拼接 system_prompt → LLM │
│ ├─ 限制工具集 → allowed_tools │
│ └─ 输出格式约束 → output │
└──────────────────────────────────────────────┘
```
---
## 六、与现有 Agent 架构的关系
current [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md) 提到的四个"无"中,人设层直接回应了以下问题:
| 原缺口 | 人设层如何解决 |
|--------|---------------|
| coordinator 空壳 | 人设调度是 coordinator 的第一个实现步骤:子任务按类型分配人设 |
| 单链 ReAct 无角色区分 | 人设注入后,同一 loop 按绑定的人设输出不同风格的响应 |
| Agent 能力边界模糊 | 人设的 `allowed_tools` 显式声明能力边界,"能做什么"由人设而非通用配置决定 |
> 人设层不解决所有缺口如执行类工具、MCP 外部工具),但它是多 Agent 协作的第一步。
---
## 七、Phase 落地建议
### Phase A数据结构 + 内置人设(单独推进,不阻塞其他任务)
```
目标: 定义 AgentPersona 结构体 + 5 个内置人设 + PersonaRegistry
文件: crates/df-ai/src/persona.rs
验证: PersonaRegistry::get("coder") 返回正确的人设配置
```
### Phase BAINode 接入人设
```
目标: AINode 执行时从 NodeContext 读 persona_id拼接 system_prompt
文件: crates/df-workflow/src/node.rs + crates/df-nodes/src/ai_node.rs
验证: 带 persona_id 的 AiNode 输出带有人设风格的文本
```
### Phase C流程模板系统
```
目标: YAML 模板定义 + 实例化引擎(模板 → 工作流 DAG
文件: df-workflow 新增模板加载逻辑
验证: 加载 feature-dev.yaml → 实例化为带 persona_hint 的 DAG
```
### Phase DAI Chat 接入人设
```
目标: run_agentic_loop 根据 intent 自动选人设,工具面板按人设过滤
文件: src-tauri/commands/ai/agentic.rs + tool_registry.rs
验证: Code Intent 下只暴露编码相关工具
```
---
## 八、设计决策
| 决策 | 选项 | 结论 | 理由 |
|------|------|------|------|
| 人设定义位置 | 编译期 vs 运行时 | 编译期内置 + 运行时扩展 | 内置人设保证基线质量,扩展性留给插件机制 |
| 模板格式 | YAML vs JSON vs Rust DSL | YAML | 人类可读写,适合非开发者定义模板 |
| 人设与 model 的关系 | 人设绑定 model vs 分离 | 分离(人设只建议 `suggested_tier` | 模型选择由调用方决定,人设不越界 |
| 模板实例化时机 | 启动时 vs 使用时 | 使用时lazy instantiation | 启动时加载数百模板影响冷启动 |

View File

@@ -1,6 +1,6 @@
# 全局事件数据总线设计
> 2026-06-21 · 专项设计 · 状态:构想定稿待评审
> 2026-06-21 · 专项设计 · 状态:🟡 部分落地(2026-06-28 核验:基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入空转。阶段 2-6 待真实痛点驱动)
> 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序
## 背景与动机

View File

@@ -0,0 +1,252 @@
# 后续批次测试用例设计 — 2026-07-01
> 覆盖:多Agent并行执行 / Git worktree 隔离 / 仲裁合并 / 模板系统 / Git-CI / 审批政策
> 关联:[多Agent并行执行与仲裁合并设计-2026-07-01.md](02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
---
## 一、测试分层
| 层级 | 范围 | 工具 |
|------|------|------|
| **单元测试** | 纯函数/数据结构/repo CRUD | `#[test]` Rust / vitest TS |
| **集成测试** | 跨模块协作(dispatch+merge+worktree) | `#[tokio::test]` |
| **端到端** | 用户发消息→Plan→并行→合并→展示 | 手动 + 日志验证 |
---
## 二、数据层与并行执行 测试用例
### 2.1 V36 迁移
| ID | 场景 | 预期 |
|----|------|------|
| MIG-01 | 新库空跑 V36 | 3 新表 + 2 列 ALTER 成功,ai_plans 为空 |
| MIG-02 | 老库(有数据)跑 V36 | ai_messages/ai_tool_executions 加 subtask_id 列(NULL),原有数据不动 |
| MIG-03 | V36 幂等(重跑) | 不崩溃,版本号已写入直接跳过 |
| MIG-04 | 老库 ai_messages 有 100 条数据 | subtask_id 全部为 NULL,查询不报错 |
### 2.2 Repo CRUD
| ID | 场景 | 预期 |
|----|------|------|
| REPO-01 | PlanRepo.insert + get | 写入后能按 id 读回,字段完整 |
| REPO-02 | PlanRepo.update_status | planning→executing→done 状态流转正确 |
| REPO-03 | SubTaskRepo.insert + list_by_plan | 按 plan_id 查子任务,layer 排序正确 |
| REPO-04 | SubTaskRepo.update_status | pending→running→done 流转 |
| REPO-05 | ConflictRepo.insert + list_pending | 按 plan_id 查 resolution=pending 的冲突 |
| REPO-06 | ConflictRepo.resolve | 更新 resolution + resolved_by + resolved_at |
| REPO-07 | SubTaskRepo 查 branch=NULL | 非 Git 工程的 SubTask branch 为 NULL,查询正常 |
### 2.3 Coordinator 拆解
| ID | 场景 | 输入 | 预期 |
|----|------|------|------|
| DEC-01 | 读+写意图 | "先 read 文件再修改" | 2 SubTask(read→write),write deps=[read] |
| DEC-02 | 纯读意图 | "分析现有代码结构" | 1 SubTask(read),无 deps |
| DEC-03 | 纯写意图 | "implement new feature" | 1 SubTask(write),无 deps |
| DEC-04 | 兜底意图 | "你好" | 1 SubTask(default) |
| DEC-05 | 人设分配 | "review this code" | SubTask persona=reviewer |
| DEC-06 | Plan 非空 | 任意非空输入 | plan.tasks.len() >= 1 |
### 2.4 Git worktree 隔离
| ID | 场景 | 预期 |
|----|------|------|
| WT-01 | worktree 创建 | `.devflow/wt/{plan_id}/A` 目录存在,git branch `subtask/{plan_id}/A` 存在 |
| WT-02 | worktree 内 write_file | 写入落到 worktree 目录,主目录文件不变 |
| WT-03 | 同层并行隔离 | SubTask A 写 main.rs,SubTask B 读 main.rs → B 读到的是 fork 基点内容,非 A 的改动 |
| WT-04 | worktree commit | SubTask 完成后 git add+commit,分支有新 commit |
| WT-05 | worktree 清理 | Plan 完成后 `.devflow/wt/{plan_id}/` 目录被移除 |
| WT-06 | 非 Git 工程降级 | 无 .git 的工程 → branch=NULL,走串行,不崩溃 |
| WT-07 | worktree 创建失败 | 磁盘满/权限不足 → 降级串行,日志告警 |
### 2.5 并行调度
| ID | 场景 | 预期 |
|----|------|------|
| PAR-01 | 线性链(2层×1任务) | Layer0 done → Layer1 启动,总耗时≈串行 |
| PAR-02 | 并行层(1层×3任务) | 3 SubTask 并发执行,总耗时≈最慢的一个 |
| PAR-03 | 钻石依赖 | A→{B,C}→D,B/C 并行,D 等 B+C 都 done |
| PAR-04 | 层间隔离 | Layer1 的 SubTask 能读到 Layer0 merge 后的改动 |
| PAR-05 | 单 SubTask 失败 | 标记 error,不中断同层其他 SubTask |
| PAR-06 | 全部 SubTask 失败 | Plan.status=error |
| PAR-07 | SubTask 超时(120s) | 标记 error,其他 SubTask 不受影响 |
### 2.6 Token 预算池
| ID | 场景 | 预期 |
|----|------|------|
| TOK-01 | 预算充足 | try_reserve(10k) → true,consumed+=10k |
| TOK-02 | 预算超限 | consumed=98k,total=100k,try_reserve(5k) → false |
| TOK-03 | 并发 reserve | 2 个 SubTask 同时 reserve(CAS 竞争) | 只有一个成功 |
| TOK-04 | 超限降级串行 | 预算用完 → 剩余 SubTask 串行执行(不拒绝) |
### 2.7 事件协议
| ID | 场景 | 预期 |
|----|------|------|
| EVT-01 | AiPlanCreated | 前端 PlanProgress 立即展示 DAG 层结构 |
| EVT-02 | AiSubTaskStatusChanged | 节点状态从 pending→running→done 更新 |
| EVT-03 | AiMergeCompleted(无冲突) | 合并输出展示,无冲突徽章 |
| EVT-04 | AiMergeCompleted(有冲突) | 冲突徽章出现 |
| EVT-05 | AiConflictResolved | 冲突徽章消失 |
| EVT-06 | 事件双写 | emit 和 publish_event 都触发 |
---
## 三、仲裁合并与冲突 测试用例
### 3.1 Git merge 冲突检测
| ID | 场景 | 预期 |
|----|------|------|
| MRG-01 | 无冲突合并 | A 改 file1,B 改 file2 → merge-tree 无冲突 → git merge 成功 |
| MRG-02 | 同文件冲突 | A 改 main.rs 第10行,B 改 main.rs 第10行 → conflict markers 出现 |
| MRG-03 | 同文件不冲突区 | A 改 main.rs 第10行,B 改 main.rs 第50行 → git 自动合并成功 |
| MRG-04 | 跨层同文件 | Layer0 改 main.rs,merge 到 plan;Layer1 改 main.rs → 基于已含 Layer0 的版本,无冲突 |
| MRG-05 | 三方合并预检 | git merge-tree 返回非空冲突列表 → 不执行 merge,标记 conflict |
### 3.2 编译检查(semantic 冲突)
| ID | 场景 | 预期 |
|----|------|------|
| CMP-01 | 合并后编译通过 | cargo check exit 0 → 无 semantic 冲突 |
| CMP-02 | 合并后编译失败 | A 删函数 foo(),B 在调 foo() → cargo check 失败 → conflict_type=semantic |
| CMP-03 | 前端项目 | tsc --noEmit 失败 → semantic 冲突 |
| CMP-04 | 无构建工具的工程 | 跳过编译检查(无 cargo/tsc) → 不阻塞 merge |
### 3.3 Reviewer 仲裁
| ID | 场景 | 预期 |
|----|------|------|
| ARB-01 | Reviewer 推荐 A | diff_a 更完整 → resolution=a,resolved_by=reviewer |
| ARB-02 | Reviewer 推荐 merged | 两者各有价值 → resolution=merged,给出合并方案 |
| ARB-03 | Reviewer 无法判断 | 两者等价 → resolution=pending,留给用户 |
| ARB-04 | Reviewer 超时 | LLM 调用超时 → 自动降级为 pending |
### 3.4 冲突解决闭环
| ID | 场景 | 预期 |
|----|------|------|
| RSV-01 | 用户选接受 A | resolution=a,文件应用 A 的改动,B 的改动丢弃 |
| RSV-02 | 用户选手动 | resolution=manual,用户在 worktree 手动编辑后 commit |
| RSV-03 | 全部冲突解决 | Plan.status 从 merging→done |
| RSV-04 | 前端徽章更新 | AiConflictResolved 事件 → 冲突徽章消失 |
### 3.5 合并产出落回主对话
| ID | 场景 | 预期 |
|----|------|------|
| OUT-01 | 无冲突合并 | 主对话新增 1 条 assistant 消息(merged_output) |
| OUT-02 | 有冲突合并 | 主对话新增 1 条 + 冲突徽章 |
| OUT-03 | 展开查看子 Agent 轨迹 | 点击展开 → 从 ai_messages WHERE subtask_id=? 加载,按 SubTask 分组 |
| OUT-04 | 折叠状态默认 | 新消息默认折叠,只显示摘要 |
### 3.6 命令互斥锁
| ID | 场景 | 预期 |
|----|------|------|
| LCK-01 | 同目录 npm install 并发 | 第二个等第一个完成才执行(不并行) |
| LCK-02 | 不同目录 npm install | 并行执行(锁 key 含目录) |
| LCK-03 | cargo + npm 同目录 | 不互斥(锁 key 含命令名) |
---
## 四、模板系统 测试用例
### 4.1 YAML 模板加载
| ID | 场景 | 预期 |
|----|------|------|
| TPL-01 | 合法 YAML 加载 | DagDef 结构完整,nodes/edges/conditions 正确解析 |
| TPL-02 | 缺必填字段 | nodes 为空 → 报错"模板节点不能为空" |
| TPL-03 | 依赖闭环 | A→B→A → 报错"依赖存在环" |
| TPL-04 | 未知节点类型 | node.type="UnknownNode" → 报错"未知节点类型" |
| TPL-05 | 内置模板加载 | 3 个内置模板(代码审查/bug修复/功能开发)全部加载成功 |
| TPL-06 | 自定义模板 CRUD | 创建→读取→更新→删除,持久化到 DB |
### 4.2 SubflowNode
| ID | 场景 | 预期 |
|----|------|------|
| SUB-01 | 嵌套子工作流执行 | SubflowNode 加载子模板 → 递归执行 → 返回子工作流结果 |
| SUB-02 | 子工作流审批 | 子工作流内的 HumanNode 审批 → 挂起 → 恢复 |
| SUB-03 | 嵌套深度限制 | 超过 MAX_PLAN_DEPTH → 报错 |
### 4.3 新节点
| ID | 场景 | 预期 |
|----|------|------|
| NODE-01 | GitNode 创建分支 | branch_name 存在,git branch 列表可见 |
| NODE-02 | GitNode commit | 工作区改动已提交,git log 有新记录 |
| NODE-03 | HTTPNode GET | 请求成功,返回 body 存入 NodeOutput |
| NODE-04 | HTTPNode 超时 | 30s 超时 → 返回错误,不阻塞 |
| NODE-05 | NotifyNode 桌面通知 | 系统通知弹出 |
| NODE-06 | NotifyNode Webhook | POST 到飞书/钉钉,HTTP 200 |
---
## 五、Git/CI 集成 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| CI-01 | DockerNode 构建成功 | docker run cargo build → exit 0 → 构建产物存在 |
| CI-02 | DockerNode 构建失败 | docker run cargo build → exit !=0 → 返回 stderr |
| CI-03 | PR 创建 | GitNode push → API 创建 PR → PR URL 返回 |
| CI-04 | CI 状态读取 | 调 API → 返回 check 结果(passed/failed/pending) |
| CI-05 | 全链路 | 模板→分支→代码→commit→push→PR→CI→结果回显,全流程不中断 |
| CI-06 | CI 失败时 | 前端 CI 面板显示红色,日志可查看 |
---
## 六、审批政策 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| POL-01 | coder+Low → auto | 匹配规则 → 自动执行 |
| POL-02 | coder+High → approve | 匹配规则 → 走审批 |
| POL-03 | reviewer+任意 → auto | reviewer 只读工具,全部自动 |
| POL-04 | 无匹配规则 | 降级到 auto_exec_mode 三档(向后兼容) |
| POL-05 | 策略热加载 | 修改策略 → 下次工具调用立即生效(不重启) |
| POL-06 | 策略冲突检测 | 同一 Persona+Node+Risk 有多条规则 → 优先级最高的生效 |
---
## 七、God 文件拆分 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| REF-01 | agentic/mod.rs 拆分 | 拆出 compress/title/knowledge_inject 后,原文件<800 行,cargo check 通过 |
| REF-02 | context.rs 拆分 | 拆出 sanitize/budget/compress 后,原文件<800 行,所有 context 测试通过 |
| REF-03 | MessageList.vue 拆分 | 拆出 StreamRenderer/ToolCardGroup/EmptyState,vue-tsc 通过 |
| REF-04 | ChatInput.vue 拆分 | 拆出 MentionPopover/EnrichmentPanel,vue-tsc 通过 |
| REF-05 | 拆分后行为不变 | 原有所有单元测试/集成测试全绿 |
---
## 八、端到端手动测试
| ID | 场景 | 步骤 | 预期 |
|----|------|------|------|
| E2E-01 | 简单并行任务 | 发"先读取 main.rs 再修改 utils.rs" | PlanProgress 展示 2 层,Layer0 读,Layer1 写,最终合并输出 |
| E2E-02 | 并行同文件冲突 | 发"同时重构 auth.rs 的两个函数" | 2 SubTask 并行改同文件 → 冲突徽章 → 用户选择 → 最终输出 |
| E2E-03 | 取消并行任务 | 发消息→Plan 执行中→点停止 | 所有 SubTask 停止,worktree 清理,Plan.status=error |
| E2E-04 | 切换会话后台执行 | 发消息→切换到另一个会话 | 原 Plan 后台继续,PlanProgress 在原会话保留 |
| E2E-05 | 非 Git 工程 | 在无 .git 的项目中发并行任务 | 降级串行执行,正常完成 |
| E2E-06 | 模板驱动全链路 | 选"功能开发"模板→AI 执行 | 从模板加载工作流→创建分支→写代码→测试→PR |
| E2E-07 | 长对话不崩溃 | 50+ 轮对话后发并行任务 | token 预算池正常,不 OOM,PlanProgress 正常展示 |
---
## 九、边界与异常
| ID | 场景 | 预期 |
|----|------|------|
| EDGE-01 | 空 Plan | decompose 返回空 Plan → 不启动 dispatch,主对话正常回复(退单 Agent) |
| EDGE-02 | 全部 SubTask 超时 | Plan.status=error,前端展示超时提示,worktree 清理 |
| EDGE-03 | Git 不可用 | git 命令失败 → 降级串行,日志告警,不崩溃 |
| EDGE-04 | DB 写入失败 | PlanRepo.insert 失败 → 降级不落库,内存态执行,日志告警 |
| EDGE-05 | worktree 磁盘满 | 创建失败 → 降级串行 |
| EDGE-06 | token 预算=0 | 设置项 df-ai-plan-token-budget=0 → 不限制(等价无限) |
| EDGE-07 | 冲突解决时会话删除 | resolve_conflict IPC 到达时会话已删 → 忽略,清理 conflict 记录 |

View File

@@ -0,0 +1,592 @@
# 多 Agent 并行执行与仲裁合并设计 — 2026-07-01
> 性质:架构设计 / 数据模型 / 事件协议 / UI 交互
> 关联: [AI-Native方向与路线图-2026-06-29.md](../构想审查/AI-Native方向与路线图-2026-06-29.md)P0 智能体人设 + 多 Agent 协作)
> 关联: [单对话并行多轮-设计-2026-06-20.md](../单对话并行多轮-设计-2026-06-20.md)Plan DAG 结构与分层调度)
> 关联: [Agent架构说明-2026-06-14.md](Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
> 用途:Batch 37-38 的实施依据
---
## 一、设计目标
DevFlow 的定位是 AI Native——AI 是系统的主要操作者,人的角色是「决策者 + 政策制定者」。
多 Agent 并行执行要解决的核心问题:
| 问题 | 现状(单 Agent | 多 Agent 后 |
|------|-----------------|------------|
| 复杂任务拆解 | LLM 自行在单轮内拆,上下文窗口受限 | Coordinator 拆为 SubTask各自独立上下文 |
| 并行产出 | 串行,无冲突 | 多 Agent 同时改文件 → 数据竞争 |
| 用户感知 | 单条流式输出 | 多 Agent 产出交织,用户分不清谁是谁 |
| 错误恢复 | 一个工具失败 → LLM 下一轮决策 | 一个 SubTask 失败 → Coordinator 决定是否继续 |
---
## 二、数据模型(方案 C混合
### 2.1 设计原则
1. **messages 不膨胀** — 只加 `subtask_id` 外键列,不把 Plan/Conflict 字段塞进去
2. **重实体独立** — Plan / SubTask / Conflict 各自独立表,各自演进
3. **历史兼容**`subtask_id = NULL` 表示单 Agent 时期消息,零回归
4. **FK 三层链路** — messages → SubTask → Plan 可追溯完整生命周期
### 2.2 ER 图
```mermaid
erDiagram
ai_conversations ||--o{ ai_plans : "1:N 触发"
ai_plans ||--o{ ai_subtasks : "1:N 拆解"
ai_plans ||--o{ ai_conflicts : "1:N 冲突"
ai_subtasks ||--o{ ai_messages : "1:N 产出(subtask_id FK)"
ai_subtasks ||--o{ ai_tool_executions : "1:N 工具调用(subtask_id FK)"
ai_conflicts }o--|| ai_subtasks : "subtask_a"
ai_conflicts }o--|| ai_subtasks : "subtask_b"
ai_plans {
TEXT id PK
TEXT conversation_id FK
TEXT user_message_id "触发 Plan 的用户消息"
TEXT status "planning/executing/merging/done/error"
INTEGER subtask_count
TEXT created_at
TEXT completed_at "可空"
}
ai_subtasks {
TEXT id PK
TEXT plan_id FK
TEXT persona_id "coder/reviewer/architect/..."
TEXT intent "子任务意图描述"
TEXT status "pending/running/done/error"
INTEGER layer "DAG 层级(0起)"
TEXT deps "JSON 数组: 依赖的 SubTask id 列表"
TEXT created_at
TEXT completed_at "可空"
}
ai_conflicts {
TEXT id PK
TEXT plan_id FK
TEXT file_path "冲突文件路径"
TEXT subtask_a FK
TEXT subtask_b FK
TEXT diff_a "Agent A 对该文件的改动摘要"
TEXT diff_b "Agent B 对该文件的改动摘要"
TEXT resolution "pending/a/b/merged/manual"
TEXT resolved_by "reviewer/user"
TEXT created_at
TEXT resolved_at "可空"
}
```
### 2.3 现有表改动
```sql
-- V36 迁移
ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT;
ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT;
-- 新建 3 张表(见 ER 图字段)
CREATE TABLE ai_plans (...);
CREATE TABLE ai_subtasks (
...
-- worktree 分支名(git worktree 隔离机制,见 §4.2)
branch TEXT -- 如 "subtask/{plan_id}/A"
);
CREATE TABLE ai_conflicts (
...
-- 冲突类型(见 §4.2 四层防线)
conflict_type TEXT DEFAULT 'file' -- 'file'(同文件) / 'semantic'(编译失败)
);
```
### 2.4 三层关联查询示例
```sql
-- 查某 Plan 的全部产出(消息 + 工具)
SELECT m.* FROM ai_messages m
JOIN ai_subtasks s ON m.subtask_id = s.id
WHERE s.plan_id = ?;
-- 查某 Plan 的所有冲突
SELECT c.* FROM ai_conflicts c WHERE c.plan_id = ? AND c.resolution = 'pending';
-- 查某对话的全部 Plan 历史
SELECT p.* FROM ai_plans p WHERE p.conversation_id = ? ORDER BY created_at DESC;
```
---
## 三、事件协议
### 3.1 新增事件
| 事件 | 时机 | 载荷 | 前端响应 |
|------|------|------|---------|
| `AiPlanCreated` | Coordinator.decompose 完成后 | `{ plan_id, layers: [[{id, persona, intent, status}],...] }` | PlanProgress 展示 DAG |
| `AiSubTaskStatusChanged` | SubTask 状态变更pending→running→done/error | `{ subtask_id, plan_id, status, persona_id }` | PlanProgress 更新节点状态 + 工具卡分组徽章 |
| `AiMergeCompleted` | Coordinator.merge 完成后 | `{ plan_id, merged_output, conflicts: [{id, file, subtask_a, subtask_b}] }` | 展示合并结果 + 冲突徽章 |
| `AiConflictResolved` | 用户/reviewer 解决冲突后 | `{ conflict_id, resolution, resolved_by }` | 冲突徽章消失 + 最终输出更新 |
### 3.2 事件流时序
```
用户发送消息
AiPlanCreated { plan_id, layers }
↓ (PlanProgress 立即展示)
Layer 0 启动
AiSubTaskStatusChanged { subtask_0, running }
AiSubTaskStatusChanged { subtask_1, running } ← 层内并行
↓ (工具卡片按 subtask_id 分组,带 persona 徽章)
AiSubTaskStatusChanged { subtask_0, done }
AiSubTaskStatusChanged { subtask_1, done }
Layer 1 启动 ...
AiMergeCompleted { plan_id, merged_output, conflicts }
↓ (有冲突 → 冲突徽章;无冲突 → 直接展示合并输出)
用户点击冲突 → 展示 diff_a/diff_b → 选择
AiConflictResolved { conflict_id, resolution }
最终输出展示
```
---
## 四、并行执行策略
### 4.1 调度规则
```
Plan.to_layers() → [[subtask_0, subtask_1], [subtask_2], [subtask_3, subtask_4]]
Layer 0并行 Layer 1串行 Layer 2并行
```
| 规则 | 说明 |
|------|------|
| 层间串行 | 上层全部 done + **Git merge 到 plan 分支后** 才创建下一层 worktree(fork 天然包含上层改动) |
| 层内并行 | 同层 SubTask 各自创建 Git worktree,JoinSet 并发执行 |
| 写工具天然隔离 | 每个 SubTask 在独立 worktree 内工作,write_file/patch_file 写到 worktree 目录(不重定向) |
| 只读工具天然可见 | read_file 读 worktree 内文件(含本 worktree 改动 + fork 基点的全部历史) |
| 层内隔离 | 同层并行 SubTask 互相看不到对方的 worktree 改动(独立分支,完全隔离) |
### 4.2 Git worktree 隔离机制
**核心思路**:用 Git worktree 替代自建暂存区,复用 Git 原生的分支隔离/diff/merge/conflict 能力。
#### 优势(vs 自建暂存区)
| 维度 | 自建暂存区 | Git worktree |
|------|-----------|-------------|
| 隔离 | 手动 cp 文件 | `git worktree add` 天然隔离 |
| diff | 自己实现比对 | `git diff` 原生(含上下文/行级/二进制) |
| 合并 | 手动 cp 或自写 merge | `git merge` 三方合并 |
| 冲突解决 | 自建 diff 展示 | `git merge-tree` 预检 + conflict markers |
| 回滚 | 手动备份/恢复 | `git reset` / `git checkout` |
| 审计 | 自建日志 | commit history 天然审计链 |
| AI 工具兼容 | write_file 需重定向路径 | worktree 就是普通目录,所有工具原样工作 |
| PR 集成 | 需额外实现 | plan 分支直接创建 PR |
#### 数据流
```
Plan 启动:
git worktree add .devflow/wt/{plan_id} -b plan/{plan_id}
(基于工程当前分支创建 plan 工作分支)
Layer 0 并行:
SubTask A → git worktree add .devflow/wt/{plan_id}/A -b subtask/{plan_id}/A
(基于 plan 分支创建,A 的所有工具操作在此 worktree 内,天然隔离)
SubTask B → git worktree add .devflow/wt/{plan_id}/B -b subtask/{plan_id}/B
(同上,B 完全隔离,A 看不到 B 的改动,反之亦然)
SubTask A/B done 后各自 git add + git commit(改动落入各自分支)
Layer 0 merge(本层全部 done 后):
① cd .devflow/wt/{plan_id} (plan 分支)
② git diff plan..subtask/{plan_id}/A → A 的改动集
③ git diff plan..subtask/{plan_id}/B → B 的改动集
④ 同文件交集 → git merge-tree (三方合并预检)
无冲突 → git merge subtask/{plan_id}/A && git merge subtask/{plan_id}/B
有冲突 → Conflict 表记录 + Reviewer 仲裁 / 用户选择
⑤ 合并完成 → plan 分支已含 Layer 0 全部改动
Layer 1 fork:
基于 plan 分支最新 commit 创建新 worktree(天然包含 Layer 0 改动)
Plan 完成:
① 编译检查:在 plan worktree 跑 cargo check / tsc --noEmit
失败 → semantic 冲突标记
② plan 分支创建 PR(可选,或直接 merge 回主分支)
③ git worktree remove 清理所有子 worktree + plan worktree
```
#### 读工具兼容规则
| 场景 | 行为 |
|------|------|
| 同层并行 SubTask 互读 | 不可见(独立 worktree + 独立分支) |
| 下一层读上一层产出 | 可见(上一层已 merge 到 plan 分支,新 worktree 基于此创建) |
| SubTask 读自己 worktree 内的写入 | 可见(worktree 是真实目录,写完即读) |
#### 非 Git 工程的降级
未绑定 Git 的工程(无 .git 目录)降级为串行执行(单 SubTask 逐个跑,无 worktree 隔离)。
此时 ai_subtasks.branch 为 NULL,Coordinator.dispatch 走原串行路径。
### 4.3 冲突防护四层防线
| 防线 | 机制 | 覆盖冲突类型 |
|------|------|------------|
| **第一层:层间串行 + merge 后才 fork** | 上一层 merge 到 plan 分支后,下一层 worktree 基于此创建 | 跨层同文件覆盖 |
| **第二层:同层 worktree 隔离 + merge-tree 预检** | 独立分支 + git 三方合并检测 | 同文件并发写 |
| **第三层:编译检查** | merge 完成后在 plan worktree 跑 cargo check / tsc --noEmit,失败 → 标记语义冲突 | 跨文件语义冲突(删函数/改签名) |
| **第四层:命令互斥锁** | run_command 对同目录的 npm/cargo 加 mutex | 资源竞争(npm install 并发) |
### 4.4 Token 预算管控
```rust
struct TokenBudgetPool {
total: AtomicU64, // 全局预算(来自设置项)
consumed: AtomicU64, // 已消耗
}
impl TokenBudgetPool {
fn try_reserve(&self, estimate: u64) -> bool {
// CAS 循环:consumed + estimate <= total
}
}
```
- 每个 SubTask 启动前向预算池申请估算额度
- 超限时 Coordinator 拒绝启动新 SubTask(降级为串行顺序执行剩余任务)
- 预算来源:设置项 `df-ai-plan-token-budget`(默认 100k tokens)
### 4.5 错误传播
| 场景 | 策略 |
|------|------|
| SubTask 执行失败 | 记录 error 状态,**不中断其他同层 SubTask**(容错) |
| 全部 SubTask 失败 | Coordinator 标记 Plan 状态为 error,前端展示错误 |
| 部分 SubTask 失败 | merge 时跳过失败 SubTask 的分支(不 merge),只合并成功的 |
| 子 Agent 超时 | 单 SubTask 超时(默认 120s)→ 标记 error,不影响其他 |
| Git merge 冲突 | conflict markers 保留在 plan worktree,Reviewer Agent 仲裁或用户手动解决 |
| 编译检查失败 | 标记 semantic 冲突,Reviewer Agent 尝试修复或标记给用户 |
| worktree 创建失败 | 降级为串行(无 worktree 隔离,逐 SubTask 在主目录执行) |
---
## 五、UI 交互设计
### 5.1 PlanProgress 组件(发送即展示)
```
┌─────────────────────────────────────────┐
│ 📋 执行计划 2/4 │
├─────────────────────────────────────────┤
│ Layer 1 │
│ ▶ 🔵 [coder] 重构代码 running │
│ 🟢 [architect] 分析结构 done │
│ ↓ │
│ Layer 2 │
│ ⏸ [tester] 补测试 pending │
│ ↓ │
│ Layer 3 │
│ ⏸ [reviewer] 审查 pending │
└─────────────────────────────────────────┘
```
- **发送即展示**:用户发消息后 Coordinator 分解完成立即展示
- **实时更新**AiSubTaskStatusChanged 驱动节点状态变化
- **折叠工具卡**:每个 SubTask 下的工具卡片折叠归组(点击展开)
- **层间箭头**DAG 层级关系可视化
### 5.2 工具卡片分组subtask_id 归组)
```
┌─ 🔵 [coder·重构代码] ──────────────┐
│ ▸ read_file main.rs ✓ done │
│ ▸ patch_file utils.rs ✓ done │
└────────────────────────────────────┘
┌─ 🟢 [architect·分析结构] ──────────┐
│ ▸ list_directory ✓ done │
│ ▸ 分析结论: 模块耦合度偏高... │
└────────────────────────────────────┘
```
- 每个 SubTask 一个折叠容器,带 persona 颜色徽章
- 工具卡片归入对应 SubTask 容器
- 默认折叠,有审批/错误时自动展开
### 5.3 冲突展示(徽章非阻塞)
```
┌─────────────────────────────────────────┐
│ ✅ 执行完成 ⚠ 2 处冲突待处理 │
├─────────────────────────────────────────┤
│ 合并输出: │
│ ... │
├─────────────────────────────────────────┤
│ ⚠ main.rs — Agent A vs Agent B │
│ [查看 diff] [接受 A] [接受 B] [手动] │
│ ⚠ utils.rs — Agent A vs Agent B │
│ [查看 diff] [接受 A] [接受 B] [手动] │
└─────────────────────────────────────────┘
```
- 冲突用**徽章**提示,不打断阅读流
- 点击展开 diff 对比 + resolution 按钮
- reviewer Agent 可自动给出推荐(`resolved_by=reviewer`),用户确认即可
---
## 六、取消与中断
| 操作 | 行为 |
|------|------|
| 用户点停止 | 停止所有并行 SubTask整体取消 |
| 单 SubTask 超时 | 只标记该 SubTask error不影响其他 |
| 会话切换 | 后台 SubTask 继续执行F-09 并发语义一致) |
| 会话删除 | 所有关联 SubTask 停止 + Plan 标记 error |
---
## 七、实施分批
### 数据层 + Git worktree 隔离 + 并行执行
| # | 任务 | 文件 |
|---|------|------|
| 1 | V36 迁移(3 新表 + 2 ALTER + subtasks.branch + conflicts.conflict_type) | migrations.rs |
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD | 新 repo 文件 |
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
### 仲裁合并 + 冲突 UI + 编译检查
| # | 任务 | 文件 |
|---|------|------|
| 1 | Coordinator.merge: git merge-tree 三方合并预检 | coordinator.rs + git_worktree.rs |
| 2 | 冲突检测:同文件路径(file 类型) + 编译失败(semantic 类型) | coordinator.rs |
| 3 | Reviewer Agent 仲裁(persona 扩展:读 diff → 推荐 resolution + 理由) | persona.rs |
| 4 | 合并产出落回主对话(方案 C:可展开单条) | coordinator.rs + MessageList.vue |
| 5 | ConflictResolver.vue(git diff 展示 + resolution 按钮) | 新组件 |
| 6 | resolve_conflict IPC + AiConflictResolved 事件闭环 | AiChatEvent |
| 7 | 命令互斥锁(run_command 对同目录 npm/cargo 加 mutex) | audit/approval.rs |
| 8 | agentic/mod.rs 拆分(2222 行 → 提取 compress/title/knowledge_inject 独立模块) | agentic/*.rs |
| 9 | AiChat 进度条补全(后端 AiAgentRound 补 max_rounds + 前端 completed 计数) | AiChat.vue |
| 10 | context.rs 拆分(1956 行 → 提取 sanitize/budget/compress 独立模块) | context/*.rs |
---
## 八、子 Agent 上下文隔离
### 8.1 双重隔离:上下文 + 文件系统
多 Agent 并行执行时需要两层隔离:
| 层 | 隔离对象 | 机制 |
|---|---------|------|
| 上下文隔离 | messages / currentText / tool_results / pending_approvals | 独立 ContextManager 实例 |
| 文件隔离 | 工程文件(worktree 内的源码) | Git worktree(见 §4.2) |
### 8.2 上下文隔离
```rust
struct SubTaskContext {
/// 从主对话 fork 的快照(只读父上下文 + 用户原始消息)
parent_snapshot: Vec<ChatMessage>,
/// 独立的 ContextManager(不写回主对话)
messages: ContextManager,
/// 分配的人设
persona: AgentPersona,
/// 子任务 id(产出消息标记 subtask_id)
subtask_id: String,
/// Git worktree 路径(工具操作的工作目录,见 §4.2)
worktree_path: Option<PathBuf>, // None = 非 Git 工程降级串行
}
```
**隔离规则**:
| 维度 | 主对话 | 子 Agent |
|------|--------|----------|
| messages | 用户消息 + 合并产出 | fork 快照 + 独立执行轨迹 |
| ContextManager | 主对话的 | 每个 SubTask 独立实例 |
| pending_approvals | 主对话的 | 各 SubTask 独立(无并发写竞争) |
| tool_results | 写入子 Agent 的 messages | 合并时提取摘要写入主对话 |
| **文件系统** | 主工程目录 | **Git worktree 隔离**(§4.2) |
| **工作目录** | 主工程路径 | **worktree_path**(工具的 cwd 重定向到此) |
### 8.3 合并产出落回主对话(方案 C:可展开单条)
```
SubTask 完成 → ExecutionResult { output, success }
Coordinator.merge(results) → MergeResult { merged_output, conflicts }
主对话 push 单条可展开 assistant 消息
```
**展示策略**:
- **默认折叠**:主对话只展示一条 assistant 消息(merged_output 摘要)
- **点击展开**:展开后按 SubTask 分组,每组显示完整执行轨迹(从 ai_messages WHERE subtask_id=? 拉取)
- **冲突内联**:合并消息底部内联冲突列表(点击展开 diff + resolution 按钮)
- **子 Agent 执行轨迹不堆入主对话流**:只在 ai_messages 表保留(带 subtask_id),展开时按需加载
---
## 九、前端类型定义
```typescript
// api/types.ts 新增
/** Plan 执行状态 */
export type PlanStatus = 'planning' | 'executing' | 'merging' | 'done' | 'error'
/** SubTask 执行状态 */
export type SubTaskStatus = 'pending' | 'running' | 'done' | 'error'
/** 冲突解决状态 */
export type ConflictResolution = 'pending' | 'a' | 'b' | 'merged' | 'manual'
/** Plan 记录 */
export interface PlanRecord {
id: string
conversation_id: ConvId
user_message_id: MessageId
status: PlanStatus
subtask_count: number
created_at: string
completed_at: string | null
}
/** SubTask 记录 */
export interface SubTaskRecord {
id: string
plan_id: string
persona_id: string
intent: string
status: SubTaskStatus
layer: number
deps: string[] // JSON 解析后的数组
/** Git worktree 分支名(NULL=非 Git 工程降级串行) */
branch: string | null
created_at: string
completed_at: string | null
}
/** 冲突类型 */
export type ConflictType = 'file' | 'semantic'
/** 冲突记录 */
export interface ConflictRecord {
id: string
plan_id: string
/** 冲突文件路径(semantic 类型时为触发编译失败的入口文件) */
file_path: string
/** 冲突类型:file=同文件路径 / semantic=编译失败 */
conflict_type: ConflictType
subtask_a: string
subtask_b: string
/** git diff 输出(A 的改动) */
diff_a: string
/** git diff 输出(B 的改动) */
diff_b: string
resolution: ConflictResolution
resolved_by: 'reviewer' | 'user' | null
created_at: string
resolved_at: string | null
}
/** PlanProgress 展示用 DAG 层结构 */
export interface PlanLayer {
items: Array<{
id: string
persona: string
intent: string
status: SubTaskStatus
}>
}
```
---
## 十、状态机
### 10.1 Plan 状态流转
```mermaid
stateDiagram-v2
[*] --> planning: Coordinator.decompose()
planning --> executing: Plan 写入 DB + emit AiPlanCreated
executing --> merging: 所有 SubTask done/error
merging --> done: merge 完成 + 无冲突
merging --> done: 所有冲突已解决
merging --> error: merge 失败
executing --> error: 全部 SubTask 失败
done --> [*]
error --> [*]
```
### 10.2 SubTask 状态流转
```mermaid
stateDiagram-v2
[*] --> pending: Plan.decompose 创建
pending --> running: dispatch 分配 + JoinSet 启动
running --> done: agentic loop 收敛(无 tool_calls)
running --> error: 超时 / 全部工具失败
done --> [*]
error --> [*]
```
### 10.3 Conflict 状态流转
```mermaid
stateDiagram-v2
[*] --> pending: merge 检测到冲突
pending --> resolved: reviewer 推荐 + 用户确认
pending --> resolved: 用户手动选择 a/b/merged
resolved --> [*]
```
---
## 十一、事件总线集成
4 个新事件均走现有双写机制(`app_handle.emit` + `ai_event_bus.publish_event`),
与 AiToolCallStarted/AiCompleted 等关键状态变更事件一致:
```rust
let ev = AiChatEvent::AiPlanCreated { ... };
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
```
- `emit`:桌面端前端监听
- `publish_event`:tunnel subscriber 透传小程序
---
## 十二、不做的方向
| 方向 | 放弃理由 | 可能的时机 |
|------|---------|-----------|
| 工具级并行(同轮多 tool_call JoinSet | 并发写 pending_approvals + LLM 对乱序 tool_result 行为不可预测 | 永不LLM 已能单轮多 tool_call |
| 单 SubTask 取消 | MVP 简化,整体取消即可 | P1 以后按需 |
| 双栏 diff 对比 UI | 违背 AI Native 定位(让用户做 AI 该做的合并) | 永不reviewer Agent 仲裁替代) |
| SubTask 级审批门控 | 审批在工具级RiskLevel已足够 | P2 审批政策配置 |

View File

@@ -3,7 +3,7 @@
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
>
> 背景R-PD-1全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
> 状态:📐 **设计完成,未实施** | 创建2026-06-15 | 来源:全局代码 review 2026-06-15
> 状态: **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建2026-06-15 | 来源:全局代码 review 2026-06-15
---

View File

@@ -2,7 +2,7 @@
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期2026-06-15
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
> 状态:✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)
---

View File

@@ -0,0 +1,224 @@
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
> 关联: [项目知识图谱与任务队列系统-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
┌─────────────────────────┬──────────────────────────────────────┐
│ [📁] [📝 3] 视图切换 │ 文件内容预览 │
│ ─────────────────────── │ │
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号) │
│ 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 · 📝 查看变更 │
└─────────────────────────┴──────────────────────────────────────┘
```
**视图切换**: 左侧栏顶部两个图标按钮:
- 📁 文件树视图(默认)
- 📝 变更视图(带变更数量角标)
**变更视图**:
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
- 初始 50 条,支持"加载更多"分页
- 点击提交行 → 展开该提交的变更文件列表
- 点击变更文件 → 展示该文件的 diff
**内容预览增强**:
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
- 滚动条在预览区域内部,不溢出页面层
### 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) |
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
### Batch 13 新增功能
| 功能 | 说明 |
|---|---|
| 行号显示 | 文件预览左侧显示行号列 |
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |

Some files were not shown because too many files have changed in this diff Show More