Compare commits

..

102 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
285 changed files with 30046 additions and 8000 deletions

View File

@@ -1,5 +1,12 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link) # musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行 # 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] [target.x86_64-unknown-linux-musl]
linker = "rust-lld" linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"] rustflags = ["-C", "link-self-contained=y"]

23
.gitignore vendored
View File

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

202
Cargo.lock generated
View File

@@ -779,6 +779,20 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -835,6 +849,7 @@ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"dashmap",
"df-ai", "df-ai",
"df-execute", "df-execute",
"df-ideas", "df-ideas",
@@ -877,13 +892,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"bytes",
"df-ai-core", "df-ai-core",
"df-types", "df-types",
"eventsource-stream",
"futures", "futures",
"rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -953,6 +970,7 @@ dependencies = [
"df-storage", "df-storage",
"df-types", "df-types",
"df-workflow", "df-workflow",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -968,6 +986,7 @@ dependencies = [
"df-types", "df-types",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -1039,6 +1058,7 @@ dependencies = [
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -1290,17 +1310,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -1640,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1651,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1989,6 +2002,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -2490,6 +2504,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2537,12 +2557,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2645,16 +2659,6 @@ dependencies = [
"memoffset", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3344,6 +3348,61 @@ dependencies = [
"memchr", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -3372,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "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]] [[package]]
@@ -3383,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -3395,6 +3474,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3495,6 +3583,8 @@ dependencies = [
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -3502,6 +3592,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3511,6 +3602,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2", "wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -3634,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -3646,6 +3739,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -3757,7 +3851,7 @@ dependencies = [
"generic-array", "generic-array",
"num", "num",
"once_cell", "once_cell",
"rand", "rand 0.8.6",
"serde", "serde",
"zbus 4.4.0", "zbus 4.4.0",
] ]
@@ -3978,6 +4072,19 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -5231,7 +5338,7 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"native-tls", "native-tls",
"rand", "rand 0.8.6",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 1.0.69",
"utf-8", "utf-8",
@@ -5249,7 +5356,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"rand", "rand 0.8.6",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 1.0.69",
"utf-8", "utf-8",
@@ -5337,6 +5444,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -5604,6 +5717,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.4" version = "0.2.4"
@@ -5660,6 +5783,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -6397,7 +6529,7 @@ dependencies = [
"hex", "hex",
"nix", "nix",
"ordered-stream", "ordered-stream",
"rand", "rand 0.8.6",
"serde", "serde",
"serde_repr", "serde_repr",
"sha1", "sha1",

View File

@@ -22,3 +22,7 @@ regex = "1"
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。 # Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。 # 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] } 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 — 项目进展与工作交接 # 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 原生创作流程驾驶舱,从想法到创作成果的全流程管理 | | 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite | | 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` | | 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) | | 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) | | Git 状态 | 已纳入版本控制(main 分支) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop | | AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
--- ---
@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 | | Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------| |-------|--------|--------|--------|---------|
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 | | df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | 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-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 复用) | | df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) |
@@ -59,7 +59,7 @@
``` ```
真实可用路径(有端到端逻辑): 真实可用路径(有端到端逻辑):
df-core (类型基础) df-types (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus) → df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行) → df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表) → df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 | | 任务 | 状态 | 说明 |
|------|------|------| |------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 | | df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus | | df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD | | df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 | | df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行 ### [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、失败中止下游层 - **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 - **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 对应方法) - **批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 - **批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 const text = res.data
if (!text) return if (!text) return
// 活性:任意入站帧更新(relay pong,用心跳期间入站消息替代判活) // 活性:任意入站帧更新(relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now() this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238) // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)

View File

@@ -215,6 +215,44 @@ function resetWatchdog(): void {
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS) 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 彻底停) */ /** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有) // 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
// 审批计时器:工具完成 → 清(审批已生效)
clearApprovalTimer(event.id)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填) // P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
pendingApprovals.push(info) pendingApprovals.push(info)
} }
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启) clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
if (tc) { if (tc) {
tc.status = 'pending_approval' tc.status = 'pending_approval'
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
} }
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填) // P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
case 'AiDirAuthRequired': { case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗 clearWatchdog() // 挂起等授权,停看门狗
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮 // F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。 // (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = { const authInfo: AiToolCallInfo = {
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
generating.value = false generating.value = false
agentRound.value = 0 agentRound.value = 0
clearWatchdog() // 终态:停看门狗 clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被 // P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。 // 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) { if (lastApprovedTc.value) {
@@ -674,7 +717,14 @@ function subscribeWs(): void {
} }
}, },
onControl: (msg: ControlMessage) => { 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) console.log('[useAiChat] control', msg)
}, },
onStatus: (status: WsStatus, detail?: string) => { onStatus: (status: WsStatus, detail?: string) => {
@@ -928,7 +978,7 @@ function switchConversation(convId: string): void {
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填) lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。 // MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
clearWatchdog() clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。 // F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
@@ -1015,7 +1065,7 @@ function newConversation(): void {
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起 maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志 lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false generating.value = false
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
} }
/** /**
@@ -1037,7 +1087,7 @@ function syncOnConnect(): void {
// req2:拉完整历史 // req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } }) wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态 // req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } }) wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
} }

View File

@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
reconnectMaxDelay: 30000, reconnectMaxDelay: 30000,
} }
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */ /** storage key(持久化完整 MiniappConfig JSON) */
let _config: MiniappConfig = { ...defaultConfig } 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 { export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config return _config
} }
export function setConfig(patch: Partial<MiniappConfig>): void { export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch } _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", "name": "devflow",
"dependencies": { "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", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@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", "marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "4", "vue-router": "4",
@@ -24,6 +32,20 @@
}, },
}, },
"packages": { "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/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=="], "@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/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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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-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-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=="], "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/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价None=true / "active"=true / "truncated"=false /// 行为与旧反面排除完全等价None=true / "active"=true / "truncated"=false
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
matches!(self.status.as_deref(), None | Some("active")) matches!(self.status, None | Some(MessageStatus::Active))
} }
} }
impl ToolDefinition { impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self { pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self { Self {
tool_type: "function".into(), tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters }, function: ToolFunction { name: name.into(), description: description.into(), parameters },
} }
} }
@@ -166,22 +166,22 @@ mod tests {
// "active" // "active"
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("active".to_string()); m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active"); assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值与旧实现等价false // "truncated" — 当前取值与旧实现等价false
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("truncated".to_string()); m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active"); assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离 // "archived_segment" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi"); 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白名单隔离"); assert!(!m.is_active(), "archived_segment 应不 active白名单隔离");
// "compressed" — 阶段2 待引入,白名单自动隔离 // "compressed" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("compressed".to_string()); m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active白名单隔离"); assert!(!m.is_active(), "compressed 应不 active白名单隔离");
} }

View File

@@ -103,11 +103,11 @@ pub struct ChatMessage {
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯) /// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见; /// 消息状态UX-09 编辑重生成None 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 /// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>, pub status: Option<MessageStatus>,
/// DeepSeek thinking 模式的推理内容(多轮需回传) /// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
@@ -148,11 +148,91 @@ pub enum MessageRole {
Tool, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition { pub struct ToolDefinition {
#[serde(rename = "type")] #[serde(rename = "type")]
pub tool_type: String, pub tool_type: ToolType,
pub function: ToolFunction, pub function: ToolFunction,
} }
@@ -169,7 +249,7 @@ pub struct ToolFunction {
pub struct ToolCall { pub struct ToolCall {
pub id: String, pub id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: String, pub call_type: ToolType,
pub function: ToolCallFunction, pub function: ToolCallFunction,
} }

View File

@@ -14,6 +14,10 @@ anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
# HTTP + 流式 # HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json"] } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
bytes = "1"
futures = "0.3" 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 块不可跨文件故作此切分。 //! 本模块仅保留 Provider struct + implHTTP 调用Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use std::time::Duration; use std::time::Duration;
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用"); 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())) .auth_headers(self.client.post(self.messages_url()))
.json(&body) .json(&body)
.version(reqwest::Version::HTTP_11) .version(reqwest::Version::HTTP_11)
.send() .send();
.await let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
{ Ok(Ok(r)) => r,
Ok(r) => r, Ok(Err(e)) => {
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!( tracing::error!(
is_timeout = e.is_timeout(), is_timeout = e.is_timeout(),
is_connect = e.is_connect(), is_connect = e.is_connect(),
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
std::error::Error::source(&e) 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() { if !resp.status().is_success() {
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text); anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
} }
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
let mut usage_accum: Option<TokenUsage> = None; let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。 // 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 messages_summary = Self::summarize_messages(&body.messages);
let stream = resp
.bytes_stream() let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
.eventsource() let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
.map(move |event| match event { let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
Ok(ev) => { match result {
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum); Ok(events) => {
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream for data in events {
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。 let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
if let Some(err) = chunk.error.as_mut() { if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary); *err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
} }
Ok(chunk)
} }
Err(e) => { 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); let ctx = format!("Anthropic SSE 错误: {}", e);
error!(error = %e, "Anthropic SSE 事件流错误"); error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx)) chunks.push(Err(anyhow::anyhow!("{}", ctx)));
} }
}); }
futures::stream::iter(chunks)
});
Ok(Box::pin(stream)) 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 不应污染内存全量");
}
}

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 填占位)。 /// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition { fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
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 { function: df_ai_core::types::ToolFunction {
name: name.to_string(), name: name.to_string(),
description: String::new(), description: String::new(),

View File

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

View File

@@ -6,7 +6,6 @@
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。 // assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages); 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| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default()) .map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用"); 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 .client
.post(self.chat_url()) .post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&openai_req) .json(&openai_req)
.send() .send();
.await?; 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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body); anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
} }
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。 // 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 mut last_usage: Option<TokenUsage> = None;
let stream = resp let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
.bytes_stream() let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
.eventsource() let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
.map(move |event| match event { match result {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)), Ok(events) => {
for data in events {
let chunk = apply_openai_sse(&data, &mut last_usage);
chunks.push(Ok(chunk));
}
}
Err(e) => { 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); let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx); error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx)) chunks.push(Err(anyhow::anyhow!("{}", ctx)));
} }
}); }
futures::stream::iter(chunks)
});
Ok(Box::pin(stream)) 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 调用低频可接受。 //! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future; use std::future::Future;
use std::time::{Duration, SystemTime}; use std::time::Duration;
use rand::Rng;
use tracing::warn; use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。 /// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// 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 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
} }
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// 指数退避 + 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)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// ///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1 /// 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 { pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例 // ±50% jitter相对 base 时长的浮动比例
let nanos = SystemTime::now() let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
.duration_since(SystemTime::UNIX_EPOCH) let factor = 1.0 + jitter_ratio * JITTER_RATIO;
.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]
let ms = (base_ms as f64 * factor).max(0.0) as u64; let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms) 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 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`, // BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。 // PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。 // 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
if cfg!(windows) { if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell } if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
} else { } else {
ShellType::Sh 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 缓存,只探一次)。 /// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
/// ///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。 /// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快), /// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。 /// 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(); 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"); let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0"); cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
} }
cmd.status().map(|s| s.success()).unwrap_or(false) cmd.status().map(|s| s.success()).unwrap_or(false)
}) })
.await
.unwrap_or(false);
// 多任务竞态时以先到者为准,均等价
let _ = CACHE.set(result);
result
} }
/// Shell 命令执行请求 /// Shell 命令执行请求
@@ -88,6 +107,10 @@ pub struct ShellRequest {
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now(); 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 shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {

View File

@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; 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 futures::future::BoxFuture;
use serde_json::{json, Value}; 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)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); 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); medium_audit("create_project", &name);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: new_id(), id: new_id(),
name, name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
}; };
let name = arg_str_or(&args, "name", ""); let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", ""); 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); medium_audit("update_project", &id);
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); 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), Err(e) => return err_str(e),
}; };
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
name, name,
@@ -322,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}")); medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); 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); let norm = normalize_path(&path);
// 路径冲突检测 // 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() { 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 conflict.name, conflict.id
)); ));
} }
// 仅更新 path 字段(用 update_field,保留其它) // 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) { if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}")); return CallToolResult::error(format!("项目不存在: {id}"));
} }
let updated = repo.get_by_id(&id).await.ok().flatten(); 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); list.retain(|t| t.project_id == *pid);
} }
if let Some(st) = &status_filter { 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() })) json_ok(json!({ "tasks": list, "count": list.len() }))
} }
@@ -384,7 +392,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id, project_id,
title, title,
description, description,
status: "todo".to_owned(), status: TaskStatus::Todo,
priority, priority,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -529,7 +537,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(), id: new_id(),
title, title,
description, description,
status: "draft".to_owned(), status: IdeaStatus::Draft,
priority, priority,
score: None, score: None,
tags: None, tags: None,

View File

@@ -15,3 +15,4 @@ tokio = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { 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::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json; use serde_json::json;
// ============================================================ // ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: "t1".to_string(), title: "t1".to_string(),
description: "实现登录接口".to_string(), description: "实现登录接口".to_string(),
status: "testing".to_string(), status: TaskStatus::Testing,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: 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(), node_id: node_id.to_string(),
inputs: HashMap::new(), inputs: HashMap::new(),
config, config,
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
event_bus: event_bus.clone(), event_bus: event_bus.clone(),
node_status: StateMachine::new(), node_status: StateMachine::new(),
} }
@@ -235,7 +235,7 @@ mod tests {
) { ) {
event_bus event_bus
.send(WorkflowEvent::HumanApprovalResponse { .send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
decision: decision.to_string(), decision: decision.to_string(),
decisions: vec![], decisions: vec![],
@@ -254,7 +254,7 @@ mod tests {
) { ) {
event_bus event_bus
.send(WorkflowEvent::HumanApprovalResponse { .send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
decision: String::new(), decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(), 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_node("b".to_string(), Box::new(HumanNode));
dag.add_edge("a".to_string(), "b".to_string()); dag.add_edge("a".to_string(), "b".to_string());
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(); // 共享状态机spawn 后仍可读 let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move { let run_handle = tokio::spawn(async move {
executor executor
@@ -537,7 +537,7 @@ mod tests {
let mut dag = Dag::new(); let mut dag = Dag::new();
dag.add_node("h".to_string(), Box::new(HumanNode)); 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 sm = executor.state_machine();
let run_handle = tokio::spawn(async move { 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_node;
pub mod ai_self_review_node; pub mod ai_self_review_node;
mod ai_node_helpers; mod ai_node_helpers;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod human_node; pub mod human_node;
mod human_node_helpers; mod human_node_helpers;
pub mod notify_node;
pub mod subflow_node;
pub mod script_node; pub mod script_node;
pub mod task_advance_node; pub mod task_advance_node;
pub mod task_state_machine; 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; 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] #[async_trait]
impl Node for ScriptNode { impl Node for ScriptNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult { async fn execute(&self, ctx: NodeContext) -> NodeResult {
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
shell_type: Default::default(), 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); tracing::info!("ScriptNode 执行命令: {}", command);
let result = df_execute::shell::execute(request).await?; let result = df_execute::shell::execute(request).await?;
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
"script" "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 super::*;
use df_storage::crud::ProjectRepo; use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord}; 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 { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: format!("t-{id}"), title: format!("t-{id}"),
description: "".to_string(), description: "".to_string(),
status: status.to_string(), status,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -206,7 +207,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -221,43 +222,43 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn forward_path_todo_to_done() { async fn forward_path_todo_to_done() {
let repo = setup().await; 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(); 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); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").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, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); 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(); 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); assert_eq!(r.review_rounds, 0);
} }
#[tokio::test] #[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() { async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await; 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(); 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"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() { async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await; 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(); 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"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn multiple_regressions_accumulate() { async fn multiple_regressions_accumulate() {
let repo = setup().await; 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) // 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_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -269,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, expected } => { df_types::error::Error::InvalidState { current, expected } => {
@@ -285,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, .. } => { df_types::error::Error::InvalidState { current, .. } => {
@@ -301,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err { match err {
df_types::error::Error::Validation(msg) => { df_types::error::Error::Validation(msg) => {
@@ -314,7 +315,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_target_rejected() { async fn invalid_target_rejected() {
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_))); assert!(matches!(err, df_types::error::Error::Validation(_)));
} }
@@ -332,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。 // 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false) repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -340,13 +341,13 @@ mod tests {
.unwrap(); .unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功 // 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").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");
} }
#[tokio::test] #[tokio::test]
async fn cas_returns_none_when_status_mismatch() { async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo let r = repo
.advance_status_atomic("t1", "todo", "in_review", false) .advance_status_atomic("t1", "todo", "in_review", false)
@@ -365,12 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; 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(); 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 不累加"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").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, "解除 blocked 不累加"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
} }
@@ -460,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() { async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成) // in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await; 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 to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.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"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过) // 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 to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.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); assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过) // 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 to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.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); assert_eq!(r.review_rounds, 0);
} }
@@ -487,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() { async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await; 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(); let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review"); assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); 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)"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 // 即便当前推进链 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(); let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress"); assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); 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); assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。 // 验证: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"); let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress) // 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap(); 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。 /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。

View File

@@ -22,27 +22,29 @@
//! - cancelled → 终态 //! - cancelled → 终态
// ============================================================ // ============================================================
// 状态字符串常量 — df-types::TaskStatus::as_str 一一对应 // 状态字符串常量 — df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================ // ============================================================
// //
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型), // 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case), // 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致 // 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] = &[ pub const ALL_STATES: &[&str] = &[

View File

@@ -11,3 +11,4 @@ tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { 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}; use serde::{Deserialize, Serialize};
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
pub tags: Vec<String>, 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; pub struct ProjectManager;
impl ProjectManager { impl ProjectManager {
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成 /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。 /// (见 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(); let now = chrono::Utc::now();
Project { Ok(Project {
id: df_types::types::new_id(), id: df_types::types::new_id(),
name: input.name, name: input.name,
description: input.description, description: input.description,
@@ -57,11 +80,15 @@ impl ProjectManager {
tags: input.tags, tags: input.tags,
created_at: now, created_at: now,
updated_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 { Self::create(CreateProjectInput {
name, name,
description, 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::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result}; use crate::error::{RelayError, Result};
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。 /// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
/// 读取期望 token(env 优先,fallback 硬编码)
fn expected_token() -> String { 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<()> { ) -> Result<()> {
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage // 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值 // 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 now = now_ms();
let (msg_kind, from) = match kind { 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

@@ -85,6 +85,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
prompt_tokens: row.get("prompt_tokens")?, prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?, completion_tokens: row.get("completion_tokens")?,
pinned_goals: row.get("pinned_goals")?, pinned_goals: row.get("pinned_goals")?,
pending_approvals: row.get("pending_approvals")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -160,24 +161,24 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, created_at, updated_at) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.created_at, rec.updated_at rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( 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, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12", "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![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.updated_at, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
], ],
) )
} }
@@ -263,6 +264,25 @@ impl AiToolExecutionRepo {
.map_err(storage_err)? .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。 /// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
/// ///
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码 /// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码

View File

@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database; use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo; use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name}; 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")?, id: row.get("id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, 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")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
tags: row.get("tags")?, 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ 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.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at rec.scores, rec.related_ids, rec.created_at, rec.updated_at
], ],
@@ -210,7 +214,7 @@ impl_repo!(
conn.execute( 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", "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![ 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.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id rec.scores, rec.related_ids, rec.updated_at, rec.id
], ],
@@ -297,10 +301,11 @@ impl IdeaRepo {
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(kw) = &keyword { 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 p1 = params_vec.len() + 1;
let p2 = p1 + 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.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。 /// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> { pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone(); 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 kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64; let limit_i = limit as i64;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
let mut results = Vec::new(); let mut results = Vec::new();
if let Some(k) = &kind { if let Some(k) = &kind {
let mut stmt = guard 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)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
} }
} else { } else {
let mut stmt = guard 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)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1403,7 +1409,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,

View File

@@ -128,6 +128,57 @@ impl AiMessageRepo {
.map_err(storage_err)? .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) 的消息(左闭右开)。 /// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
/// ///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。 /// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。

View File

@@ -17,11 +17,15 @@
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod module_dependency_repo;
mod plan_repo;
mod conversation_repo; mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod project_event_repo; mod project_event_repo;
mod project_module_repo;
mod project_repo; mod project_repo;
mod project_service_repo; mod project_service_repo;
mod settings; mod settings;
@@ -33,11 +37,14 @@ pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use project_event_repo::*; pub use project_event_repo::*;
pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
pub use project_service_repo::*; pub use project_service_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*; pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
pub use module_dependency_repo::*;
pub use plan_repo::*;
// ============================================================ // ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板 // 辅助宏 — 消除多个 Repo 的重复样板
@@ -305,6 +312,7 @@ mod baseline_tests {
let _ = TaskLinkRepo::new(&db); let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db); let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db); let _ = ProjectServiceRepo::new(&db);
let _ = ProjectModuleRepo::new(&db);
let _ = BranchRepo::new(&db); let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db); let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::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 super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) { async fn setup() -> (crate::db::Database, ProjectEventRepo) {
@@ -232,7 +233,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: 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 tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::ProjectStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::{ use crate::models::{
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
id: row.get("id")?, id: row.get("id")?,
name: row.get("name")?, name: row.get("name")?,
description: row.get("description")?, 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")?, idea_id: row.get("idea_id")?,
path: row.get("path")?, path: row.get("path")?,
stack: row.get("stack")?, 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ 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 rec.path, rec.stack, rec.created_at, rec.updated_at
], ],
) )
@@ -171,7 +175,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8", "UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
params![ 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 rec.path, rec.stack, rec.updated_at, rec.id
], ],
) )
@@ -238,8 +242,9 @@ impl ProjectRepo {
if let Some(kw) = &q.keyword { if let Some(kw) = &q.keyword {
let trimmed = kw.trim(); let trimmed = kw.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)"); let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{trimmed}%"); 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.clone()));
params_vec.push(Box::new(pattern)); params_vec.push(Box::new(pattern));
} }

View File

@@ -295,6 +295,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) { async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
@@ -308,7 +309,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: 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", "id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
"error_message", "started_at", "completed_at", "error_message", "started_at", "completed_at",
], ],
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
"ai_providers" => &[ "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", "is_default", "config", "created_at", "updated_at",
], ],
"ai_conversations" => &[ "ai_conversations" => &[

View File

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

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::TaskStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::TaskRecord; 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")?, project_id: row.get("project_id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, 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")?, priority: row.get("priority")?,
branch_name: row.get("branch_name")?, branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?, 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ 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.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -134,7 +138,7 @@ impl_repo!(
conn.execute( 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", "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![ 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.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -374,10 +378,11 @@ impl TaskRepo {
} }
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { 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 p1 = params_vec.len() + 1;
let p2 = p1 + 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.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -432,6 +437,60 @@ impl TaskRepo {
.map_err(storage_err)? .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),按创建时间升序。 /// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
/// ///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status /// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
@@ -566,20 +625,21 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord { 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 时用)。 /// 全参 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 { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "proj-1".to_string(), project_id: "proj-1".to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: status.to_string(), status,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -605,7 +665,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -724,16 +784,16 @@ mod tests {
let repo = setup().await; let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap(); repo.insert(trec("parent", "todo", None)).await.unwrap();
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo) // 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 .await
.unwrap(); .unwrap();
repo.insert(trec_full("c2", "todo", Some("parent"), "todo")) repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress")) repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
.await .await
.unwrap(); .unwrap();
repo.insert(trec_full("c4", "todo", Some("parent"), "done")) repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
.await .await
.unwrap(); .unwrap();
@@ -778,13 +838,13 @@ mod tests {
async fn set_status_for_aggregation_writes_status() { async fn set_status_for_aggregation_writes_status() {
let repo = setup().await; let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型) // 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap(); let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
assert!(ok, "应命中写入"); assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap(); 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"); assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
} }
@@ -792,7 +852,7 @@ mod tests {
async fn set_status_for_aggregation_skips_soft_deleted() { async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await; let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
repo.soft_delete("parent").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 表, // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
let steps: [(i32, fn(&Connection) -> Result<()>); 32] = [ // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -76,6 +78,10 @@ pub fn run(conn: &Connection) -> Result<()> {
(30, migrate_v30), (30, migrate_v30),
(31, migrate_v31), (31, migrate_v31),
(32, migrate_v32), (32, migrate_v32),
(33, migrate_v33),
(34, migrate_v34),
(35, migrate_v35),
(36, migrate_v36),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -905,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化) /// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
/// ///
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在, /// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 /// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> { fn migrate_v32(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
@@ -917,6 +923,198 @@ fn migrate_v32(conn: &Connection) -> Result<()> {
Ok(()) 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -1,6 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体 //! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: IdeaStatus,
pub priority: i32, pub priority: i32,
pub score: Option<f64>, pub score: Option<f64>,
pub tags: Option<String>, // JSON 数组 pub tags: Option<String>, // JSON 数组
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub status: String, pub status: ProjectStatus,
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用) /// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>, pub path: Option<String>,
@@ -74,7 +75,7 @@ pub struct TaskRecord {
pub project_id: String, pub project_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: TaskStatus,
pub priority: i32, pub priority: i32,
pub branch_name: Option<String>, pub branch_name: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -134,14 +135,14 @@ pub struct TaskRecord {
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示 /// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。 /// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, /// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。 /// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord { pub struct TaskLinkRecord {
pub id: String, pub id: String,
pub source_id: String, pub source_id: String,
pub target_id: String, pub target_id: String,
pub link_type: String, pub link_type: LinkType,
pub remark: Option<String>, pub remark: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
pub id: String, pub id: String,
pub workflow_id: String, pub workflow_id: String,
pub node_id: String, pub node_id: String,
pub node_type: String, pub node_type: NodeType,
pub status: String, pub status: String,
pub input_json: Option<String>, pub input_json: Option<String>,
pub output_json: Option<String>, pub output_json: Option<String>,
@@ -279,7 +280,9 @@ pub struct NodeExecutionRecord {
// ============================================================ // ============================================================
/// AI 提供商配置记录 /// AI 提供商配置记录
#[derive(Debug, Clone, Serialize, Deserialize)] ///
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
#[derive(Clone, Serialize, Deserialize)]
pub struct AiProviderRecord { pub struct AiProviderRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -307,6 +310,27 @@ pub struct AiProviderRecord {
pub weight: u32, 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。 /// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
fn default_enabled() -> bool { fn default_enabled() -> bool {
true true
@@ -336,7 +360,8 @@ pub struct AiConversationRecord {
pub pinned: bool, // 是否置顶(排序置前;UX-17) pub pinned: bool, // 是否置顶(排序置前;UX-17)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库) pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库) pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]') pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -439,3 +464,95 @@ pub struct KnowledgeEventRecord {
pub context_json: Option<String>, // JSON: 因 event_type 而异 pub context_json: Option<String>, // JSON: 因 event_type 而异
pub timestamp: String, 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(兼容未迁移老库) /// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() { 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() 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(覆盖) /// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?; let entry = entry_for(id)?;
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e)) 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 删除时清理) /// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?; let entry = entry_for(id)?;
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e)) 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 置空(失败保留明文下次重试,非阻断) /// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> { pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
let providers = repo.list_all().await?; let providers = repo.list_all().await?;

View File

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

View File

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

View File

@@ -435,6 +435,7 @@ mod tests {
status: ProjectStatus::InProgress, status: ProjectStatus::InProgress,
description: "AI-native dev tool".to_string(), description: "AI-native dev tool".to_string(),
path: Some(SanitizedPath::new("E:/wk-lab/devflow")), path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
extra: vec![],
}; };
let json = serde_json::to_string(&a).expect("serialize"); let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"project\"")); assert!(json.contains("\"kind\":\"project\""));
@@ -452,6 +453,7 @@ mod tests {
status: ProjectStatus::Planning, status: ProjectStatus::Planning,
description: String::new(), description: String::new(),
path: None, path: None,
extra: vec![],
}; };
let json = serde_json::to_string(&a).expect("serialize"); let json = serde_json::to_string(&a).expect("serialize");
// skip_serializing_if 生效:path 字段不出现在 JSON 中 // skip_serializing_if 生效:path 字段不出现在 JSON 中
@@ -469,6 +471,7 @@ mod tests {
status: TaskStatus::Todo, status: TaskStatus::Todo,
description: "do X".to_string(), description: "do X".to_string(),
project_name: Some("devflow".to_string()), project_name: Some("devflow".to_string()),
extra: vec![],
}; };
let json = serde_json::to_string(&a).expect("serialize"); let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"task\"")); assert!(json.contains("\"kind\":\"task\""));
@@ -485,6 +488,7 @@ mod tests {
title: "Multi-round parallel".to_string(), title: "Multi-round parallel".to_string(),
status: IdeaStatus::Approved, status: IdeaStatus::Approved,
description: "desc".to_string(), description: "desc".to_string(),
extra: vec![],
}; };
let json = serde_json::to_string(&a).expect("serialize"); let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"idea\"")); assert!(json.contains("\"kind\":\"idea\""));
@@ -520,6 +524,7 @@ mod tests {
status: ProjectStatus::Planning, status: ProjectStatus::Planning,
description: String::new(), description: String::new(),
path: None, path: None,
extra: vec![],
}; };
assert_eq!(p.kind_str(), "project"); assert_eq!(p.kind_str(), "project");
assert_eq!(p.section_label(), "项目"); assert_eq!(p.section_label(), "项目");

View File

@@ -22,10 +22,116 @@ pub type ReleaseId = String;
pub type BranchId = String; pub type BranchId = String;
/// 插件 ID /// 插件 ID
pub type PluginId = String; pub type PluginId = String;
/// 执行 ID /// 执行 ID(IPC 边界透明序列化为字符串)
pub type ExecutionId = String; #[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 /// 决策 ID
pub type DecisionId = String; 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 { 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 { match self {
IdeaStatus::Draft => "draft", IdeaStatus::Draft => "draft",
IdeaStatus::PendingReview => "pending_review", IdeaStatus::PendingReview => "pending_review",
@@ -122,7 +230,9 @@ pub enum ProjectStatus {
impl 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 { match self {
ProjectStatus::Planning => "planning", ProjectStatus::Planning => "planning",
ProjectStatus::InProgress => "in_progress", ProjectStatus::InProgress => "in_progress",
@@ -180,7 +290,10 @@ pub enum TaskStatus {
impl 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 { match self {
TaskStatus::Todo => "todo", TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress", TaskStatus::InProgress => "in_progress",

View File

@@ -23,3 +23,4 @@ async-trait = { workspace = true }
futures = "0.3" futures = "0.3"
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
serde_yaml = "0.9"

View File

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

View File

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

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

View File

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

View File

@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
use df_types::types::{NodeId, NodeStatus}; 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)] #[derive(Debug, Clone)]
pub struct StateMachine { pub struct StateMachine {
/// 节点状态映射Arc 共享clone 浅拷贝同一 HashMap /// 节点状态映射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) - ID 字段统一使用 `TEXT` (UUID v4)
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String) - 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
- JSON 字段使用 `TEXT` 存储 JSON 字符串 - 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 的强制时间戳约束 ## 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/lib.rs` — 存储层入口
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移 - `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-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-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-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-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) + 修复方案 | | [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 能力边界(查实的事实,非构想) | | [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 阻断 | | [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 | | [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 收敛) | | [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) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 | | [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 | | [工作流脚本执行边界-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 下推 / 精确拉取 / 缓存 / 字段投影 | | [查询效率优化方案-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 阶段路径 | | [任务推进链实施路径-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 模板 | | [推进链阶段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) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 | | [消息级溯源设计-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-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) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) | | [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 | | [多主题并存补充论证-多轮模式-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 设计 # 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 > 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
## Context(为什么) ## Context(为什么)

View File

@@ -1,7 +1,9 @@
# Agent 架构与能力边界(系统现状记录) — 2026-06-14 # 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 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计 > 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
--- ---

View File

@@ -1,6 +1,6 @@
# Patch File 工具设计 # Patch File 工具设计
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0 > 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
> 关联 todo: F-260615-06 [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 跨端小程序 > 关联:[[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 报废,密钥永久丢失。 > 背景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 > 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期2026-06-15 > 日期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 事件推送 |

View File

@@ -1,7 +1,7 @@
# 条件表达式引擎设计R-PD-3 / T-260614-11 # 条件表达式引擎设计R-PD-3 / T-260614-11
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条 > 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
> 性质:设计文档(供用户核对方案),不含实现 > 性质:✅ **已落地**(2026-06-28 核验:ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor feature flag 集成 + 前端边条件编辑入口 + 中英文翻译)
> 关联todo `T-260614-11 条件表达式引擎升级`、R-P2-13set_skipped/set_waiting 已删) > 关联todo `T-260614-11 条件表达式引擎升级`、R-P2-13set_skipped/set_waiting 已删)
--- ---

View File

@@ -0,0 +1,267 @@
# 架构债迁移设计方案2026-06-29
> 创建: 2026-06-29 | 状态: 设计草案
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
---
## 一、概览
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估****不在本批次实施代码**(每项都需独立 PR + e2e 验证)。
| 编号 | 问题 | 风险 | 建议时机 |
|------|------|------|---------|
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop | Phase 4 之前 |
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
| #19 | SQLite 单连接 Mutex | 中DB 层基础) | 性能瓶颈出现时 |
---
## 二、#15 — AI 核心状态机抽离
### 现状
```
src-tauri/src/commands/ai/
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState应属 df-ai
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai
├── commands.rs ← IPC 层(应留在 src-tauri
├── tool_registry ← 工具注册IPC 层依赖,留在 src-tauri
└── ...
crates/df-ai/src/
├── provider.rs ← LlmProvider trait正确
├── openai_compat.rs ← HTTP正确
├── anthropic_compat.rs← HTTP正确
├── coordinator.rs ← 空壳(应承担 agentic loop
└── ...
```
### 目标架构
```
crates/df-ai-session/ ← 新 crate避免 df-ai 引 reqwest 同时引会话状态)
├── session.rs ← AiSession / PerConvState / SessionState
├── agentic_loop.rs ← run_agentic_loop纯逻辑依赖 LlmProvider trait
├── events.rs ← AiChatEvent enum
└── approvals.rs ← PendingApproval / ApprovalKind
src-tauri/src/commands/ai/
├── mod.rs ← 仅 IPC glueinvoke_handler 注册 + AppState 转发)
├── commands.rs ← ai_send_message / ai_approve / ai_reject
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types
```
### 迁移路径4 阶段)
**阶段 1**:创建 `df-ai-session` crate仅迁移类型定义`AiSession` / `AiChatEvent`不改逻辑。src-tauri 改为 re-export。
**阶段 2**:迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变(`pub async fn run_agentic_loop(...)`),仅改归属。
**阶段 3**:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」依赖 IPC 层,保留)。
**阶段 4**src-tauri 仅保留 IPC glue所有 AI 状态机测试可在 df-ai-session 独立运行。
### 风险
- **循环依赖**`agentic_loop` 需要 LlmProviderdf-ai-core+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**:把 AiToolRegistry trait 下沉到 df-ai-corehandler 实现留在 src-tauri。
- **测试覆盖**:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
---
## 三、#8 — IPC 错误结构化
### 现状
```rust
// src-tauri/src/commands/mod.rs:26
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
// 所有 IPC 命令
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
```
df-types/error.rs 的 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }`)在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串,无法分辨错误类型。
### 目标架构
```rust
// df-types/src/error.rs 新增 Serialize
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum IpcError {
NotFound { entity: String, id: String },
Validation { field: String, reason: String },
InvalidState { entity: String, current: String, expected: Vec<String> },
Unauthorized { reason: String },
Internal { message: String },
}
// IPC 命令改为
async fn xxx() -> Result<T, IpcError> { ... }
```
Tauri invoke 自动 serialize 为 JSON
```json
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
```
### 前端消费
```ts
// src/api/types.ts
type IpcError =
| { kind: 'not_found'; data: { entity: string; id: string } }
| { kind: 'validation'; data: { field: string; reason: string } }
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
| { kind: 'unauthorized'; data: { reason: string } }
| { kind: 'internal'; data: { message: string } }
// 使用
try { await invoke(...) }
catch (e: unknown) {
const err = e as IpcError
if (err.kind === 'not_found') router.push('/projects')
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
}
```
### 迁移路径
**阶段 1**df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`
**阶段 2**src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**:旧前端读 `err.message` 时降级为字符串IpcError 实现 `Display` 输出 message
**阶段 3**:前端 api 层加 `IpcError` 类型 + 在关键 viewProjects/Tasks/Ideas按 kind 分支处理。
**阶段 4**:删除 `err_str` 辅助函数。
### 风险
- **Tauri invoke 错误序列化**Tauri 默认把 `Result::Err` 经 JSON 传前端,但前端 `invoke().catch()` 拿到的是 `string``object`**需验证**Tauri v2 对自定义 error 类型的序列化行为。
- **向后兼容**:旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。
---
## 四、#18 — 配置四源统一
### 现状(散落 env::var 读取)
| 读取点 | 用途 | 性质 |
|--------|------|------|
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置panic 兜底) |
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |
### 目标架构
**不强行统一所有读取**OS 路径 / Shell 探测等系统变量性质不同),而是:
1. **df-types 新增 `AppConfig` struct**(集中定义项目级配置项)
2. **启动时一次性读取 env + DB KV**,构造 `AppConfig` 实例
3. **AppState 持有 `Arc<AppConfig>`**,各模块经 state 读取
4. **文档化配置矩阵**env / DB KV / 默认值 / panic 的关系
```rust
// df-types/src/config.rs
pub struct AppConfig {
pub relay: RelayConfig,
pub script_policy: ScriptPolicyConfig,
pub ai_node: AiNodeConfig,
pub env_probe: bool,
}
pub struct RelayConfig {
pub addr: String, // 默认 "127.0.0.1:8080"
pub token: String, // 缺失 panic
}
pub struct ScriptPolicyConfig {
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
pub whitelist: Vec<String>, // 默认 []
}
// src-tauri/src/state.rs
pub struct AppState {
pub config: Arc<AppConfig>,
// ...
}
```
### 迁移路径
**阶段 1**df-types 定义 `AppConfig`,在 src-tauri 启动时构造。
**阶段 2**:逐个替换散落的 `std::env::var``state.config.xxx`。**保持 env var 作为 override**(向后兼容)。
**阶段 3**:前端 Settings 增加「环境变量覆盖」可视化。
### 风险
- **df-relay 独立 binary**relay 是独立进程,不经 src-tauri AppState。**解决**df-relay 自己构造 `RelayConfig`(共享 df-types::config 类型)。
---
## 五、#19 — SQLite 连接池
### 现状
```rust
// crates/df-storage/src/db.rs:13-16
pub struct Database {
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
}
```
注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
### 目标架构
```rust
pub struct Database {
pool: r2d2::Pool<SqliteConnectionManager>,
}
impl Database {
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(...)
}
}
```
### 迁移路径
**阶段 1**df-storage 加 `r2d2` + `r2d2_sqlite` 依赖,改 `Database` 内部为 pool。
**阶段 2**:所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`spawn_blocking 内获取连接)。
**阶段 3**WAL 模式 + 合理 pool_size桌面应用 4-8 即可)。
### 风险
- **WAL 已开**`db.rs:22 PRAGMA journal_mode=WAL`读写不互斥pool 能真正并行。
- **事务原子性**:现有批量插入用 `unchecked_transaction()`pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
- **测试改造**:所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。
---
## 六、本批次已落地(参考)
| 编号 | 改动 | 文件 |
|------|------|------|
| #17 | TaskStatus 字符串常量从 enum 派生(`as_str` 改 const fn | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
## 七、推进顺序建议
1. **#8 IPC 错误结构化** — 风险中等,收益直接(前端可差异化处理)
2. **#15 AI 状态机抽离** — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
3. **#18 配置统一** — 与 #15 协同(新 crate 也需要读配置)
4. **#19 连接池** — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)

View File

@@ -1,6 +1,6 @@
# F-260621-02 查询能力维度补全方案(任务/项目/灵感) # F-260621-02 查询能力维度补全方案(任务/项目/灵感)
> **状态**:方案设计(待办登记),待排期实施 > **状态**:✅ **已落地**(2026-06-28 核验:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE)
> **日期**:2026-06-21 > **日期**:2026-06-21
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全 > **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化) > **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)

View File

@@ -0,0 +1,290 @@
# 测试落地实施方案 — 2026-07-01
> 关联:[后续批次测试用例设计-2026-07-01.md](后续批次测试用例设计-2026-07-01.md)
> 用途:定义每个测试用例的具体落地方式、文件位置、执行命令
---
## 一、项目现有测试基建
| 层 | 现状 | 工具 |
|----|------|------|
| Rust 单元测试 | df-ai 370+ 测试,df-storage 集成测试,df-execute 集成测试 | `#[test]` / `#[tokio::test]` |
| 前端测试 | **无**(package.json 无 vitest/jest) | 需引入 vitest |
| 端到端 | 无自动化(手动验证 + 日志) | 手动 |
---
## 二、各层级落地方式
### 2.1 Rust 单元测试(`#[test]` / `#[tokio::test]`)
**适用用例**:MIG / REPO / DEC / PAR / TOK / MRG / CMP / ARB / TPL / POL
**文件位置**:与现有模式一致——`mod tests` 嵌入源文件底部。
| 用例类别 | 落地文件 | 测试数量 |
|---------|---------|---------|
| MIG-01~04 | `df-storage/src/migrations.rs` mod tests | 4 |
| REPO-01~07 | `df-storage/src/crud/plan_repo.rs` mod tests | 7 |
| DEC-01~06 | `df-ai/src/coordinator.rs` mod tests(已有 6 个,补全) | 6 |
| PAR-01~07 | `df-ai/src/coordinator.rs` mod tests(mock executor) | 7 |
| TOK-01~04 | `df-ai/src/coordinator.rs` mod tests | 4 |
| MRG-01~05 | `df-ai/src/coordinator.rs` mod tests(mock merge) | 5 |
| CMP-01~04 | `df-ai/src/coordinator.rs` mod tests(mock 编译结果) | 4 |
| ARB-01~04 | `df-ai/src/persona.rs` mod tests | 4 |
| TPL-01~06 | `df-workflow/src/template_loader.rs` mod tests | 6 |
| POL-01~06 | `src-tauri/src/policy.rs` mod tests | 6 |
**执行命令**:
```bash
# 全量
cargo test --workspace
# 按 crate
cargo test -p df-ai
cargo test -p df-storage
# 按用例名
cargo test -p df-ai coordinator::tests::decompose_read_then_write
# 只跑并行相关
cargo test -p df-ai -- coordinator::tests::par_
```
### 2.2 Rust 集成测试(`tests/` 目录)
**适用用例**:WT / SUB / NODE / LCK / CI(需要真实 Git/Shell/DB 环境)
| 用例类别 | 落地文件 | 说明 |
|---------|---------|------|
| WT-01~07 | `df-ai/tests/git_worktree.rs` | 真实 git worktree 操作(tempdir 临时仓库) |
| LCK-01~03 | `df-ai/tests/command_lock.rs` | 真实 tokio 并发 + mutex 验证 |
| SUB-01~03 | `df-nodes/tests/subflow_node.rs` | 真实 Executor + 嵌套模板 |
| NODE-01~06 | `df-nodes/tests/nodes.rs` | GitNode/HTTPNode/NotifyNode 各自独立 |
| CI-01~02 | `df-nodes/tests/docker_node.rs` | 需 Docker 环境(`#[ignore]` 标注,CI 环境跑) |
**WT 测试关键代码模式**(参考现有 `df-execute/tests/shell.rs`):
```rust
// df-ai/tests/git_worktree.rs
use tempfile::TempDir;
use std::process::Command;
fn setup_git_repo() -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(dir.path()).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"])
.current_dir(dir.path()).output().unwrap();
Command::new("git").args(["config", "user.name", "test"])
.current_dir(dir.path()).output().unwrap();
// 写初始文件 + commit
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
Command::new("git").args(["add", "."]).current_dir(dir.path()).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(dir.path()).output().unwrap();
dir
}
#[tokio::test]
async fn wt_01_worktree_create_and_isolate() {
let repo = setup_git_repo();
let plan_id = "test-plan-001";
// 创建 worktree
let wt_path = repo.path().join(".devflow/wt").join(plan_id).join("A");
let output = Command::new("git")
.args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "subtask/test-plan-001/A"])
.current_dir(repo.path())
.output().unwrap();
assert!(output.status.success(), "worktree 创建失败");
assert!(wt_path.exists(), "worktree 目录应存在");
// 写文件到 worktree
std::fs::write(wt_path.join("new.rs"), "// A 的改动").unwrap();
// 主目录不应有 new.rs
assert!(!repo.path().join("new.rs").exists(), "主目录不应有 A 的改动");
// 清理
Command::new("git").args(["worktree", "remove", wt_path.to_str().unwrap(), "--force"])
.current_dir(repo.path()).output().unwrap();
}
```
### 2.3 前端测试(引入 vitest)
**现状**:项目无前端测试基建。需要引入 vitest。
**引入步骤**(纳入数据层批次实施):
```bash
bun add -d vitest @vue/test-utils jsdom
```
`vitest.config.ts`:
```typescript
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
})
```
`package.json``"test": "vitest"` 脚本。
**适用用例**:EVT / OUT / REF(前端)
| 用例类别 | 落地文件 | 说明 |
|---------|---------|------|
| EVT-01~06 | `src/components/ai/__tests__/PlanProgress.test.ts` | 组件事件驱动渲染 |
| OUT-01~04 | `src/components/ai/__tests__/MessageList.test.ts` | 合并产出展示/折叠 |
| REF-03~05 | `src/components/ai/__tests__/MessageList.test.ts` | 拆分后行为不变 |
**执行命令**:
```bash
bun run test # watch 模式
bun run test -- --run # 单次跑
bun run test -- PlanProgress # 按文件名过滤
```
### 2.4 端到端手动验证(测试脚本 + 日志)
**适用用例**:E2E / EDGE
**落地方式**:测试用 DB + 日志分析脚本,非自动化框架。
**手动验证检查清单格式**(每个批次完成时填写):
```markdown
## E2E-01 简单并行任务
- [ ] 发送消息:"先读取 main.rs 再修改 utils.rs"
- [ ] PlanProgress 展示 2 层 DAG
- [ ] Layer 0 (read) 完成 → Layer 1 (write) 启动
- [ ] 最终输出包含合并结果
- [ ] ai_subtasks 表有 2 条记录,status=done
- [ ] ai_messages 表有 subtask_id 非空的记录
- [ ] worktree 目录已清理(.devflow/wt/ 下无残留)
```
**验证用 SQL 查询**(批次完成后在 dev 数据库执行):
```sql
-- 检查 Plan 记录
SELECT id, status, subtask_count, created_at FROM ai_plans ORDER BY created_at DESC LIMIT 5;
-- 检查 SubTask 状态
SELECT s.id, s.persona_id, s.status, s.layer, s.branch
FROM ai_subtasks s
JOIN ai_plans p ON s.plan_id = p.id
WHERE p.id = ? -- 替换为实际 plan_id
ORDER BY s.layer, s.created_at;
-- 检查冲突记录
SELECT c.file_path, c.conflict_type, c.resolution, c.resolved_by
FROM ai_conflicts c WHERE c.plan_id = ?;
-- 检查消息归属
SELECT m.id, m.role, m.subtask_id, substr(m.content, 1, 60) as preview
FROM ai_messages m WHERE m.conversation_id = ? AND m.subtask_id IS NOT NULL
ORDER BY m.seq DESC LIMIT 20;
```
**worktree 残留检查**:
```bash
# Plan 完成后应无残留
ls -la .devflow/wt/ # 应为空或不存在
# 异常残留时的清理
git worktree list
git worktree prune
```
---
## 三、落地优先级
| 优先级 | 范围 | 时机 |
|--------|------|------|
| **P0 随编码落地** | Rust 单元测试(DEC/PAR/TOK/MRG/ARB) | 每个批次编码时同步写 |
| **P0 随编码落地** | Rust 集成测试(WT) | worktree 模块编码时同步写 |
| **P1 批次末尾** | 前端测试基建引入 + EVT/OUT | 数据层批次末尾引入 vitest |
| **P2 批次完成后** | E2E 手动验证 | 每个批次完成后用检查清单验证 |
| **P3 按需** | CI 集成测试(Docker/PR) | Git/CI 批次完成后 |
---
## 四、CI 自动化(远期)
当 vitest + cargo test 覆盖率足够后,接入 CI:
```yaml
# .github/workflows/test.yml(或 Gitea Actions)
jobs:
rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo test --workspace
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun run test -- --run
```
当前阶段以**本地 `cargo test` + 手动 E2E** 为主,CI 自动化等测试覆盖率达标后接入。
---
## 五、Mock 策略
### 5.1 Rust 侧
| 依赖 | Mock 方式 |
|------|----------|
| LLM 调用 | 闭包注入(`Coordinator.dispatch<F>` 已支持泛型 executor) |
| Git 操作 | 集成测试用真实 git(tempdir 临时仓库) |
| 数据库 | 内存 SQLite(`Database::open_in_memory()`) |
| 编译检查 | mock 返回 exit_code(单元测试)/ 真实 cargo(集成测试) |
### 5.2 前端侧
| 依赖 | Mock 方式 |
|------|----------|
| Tauri IPC | `@vue/test-utils` mock `invoke` |
| 事件监听 | 手动 emit 测试事件 |
| i18n | 插件 mock(返回 key 本身) |
| Store | 真实 Pinia store(测试 store 逻辑本身) |
---
## 六、每个批次的测试验证流程
```
编码完成
cargo test -p df-ai(对应模块单元测试全绿)
cargo test -p df-storage(迁移+Repo 测试全绿)
cargo test -p df-ai --test git_worktree(worktree 集成测试全绿)
cargo check --workspace(编译无警告)
npx vue-tsc --noEmit(前端类型检查)
npx vite build(前端构建)
手动 E2E 检查清单(对照测试用例文档逐项验证)
SQL 查询验证(DB 数据正确性)
提交(代码+测试同一提交)
```

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